From 2ed88c2d3738f0e2d5e2d7dde44b102fbc501ef3 Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Thu, 5 Feb 2026 11:29:37 +1300 Subject: [PATCH 01/13] Add adversarial tests for permission decoding. Add additional validation to ensure that permission data invariants are not violated. --- .../decodePermission/decodePermission.test.ts | 922 ++++++++++++++++++ .../src/decodePermission/decodePermission.ts | 94 +- 2 files changed, 1009 insertions(+), 7 deletions(-) diff --git a/packages/gator-permissions-controller/src/decodePermission/decodePermission.test.ts b/packages/gator-permissions-controller/src/decodePermission/decodePermission.test.ts index dacba8c1d6d..d2a57c5619d 100644 --- a/packages/gator-permissions-controller/src/decodePermission/decodePermission.test.ts +++ b/packages/gator-permissions-controller/src/decodePermission/decodePermission.test.ts @@ -786,6 +786,110 @@ describe('decodePermission', () => { 'Invalid expiry: timestampBeforeThreshold must be greater than 0', ); }); + + it('rejects terms with zero initialAmount', () => { + const ZERO_32 = '0'.repeat(64); + const maxHex = maxAmount.toString(16).padStart(64, '0'); + const amountPerSecondHex = amountPerSecond.toString(16).padStart(64, '0'); + const startTimeHex = startTime.toString(16).padStart(64, '0'); + const terms = `0x${ZERO_32}${maxHex}${amountPerSecondHex}${startTimeHex}` as Hex; + const caveats = [ + expiryCaveat, + { + enforcer: NativeTokenStreamingEnforcer, + terms, + args: '0x', + } as const, + ]; + + expect(() => + getPermissionDataAndExpiry({ + contracts, + caveats, + permissionType, + }), + ).toThrow( + 'Invalid native-token-stream terms: initialAmount must be a positive number', + ); + }); + + it('rejects terms with zero maxAmount', () => { + const initialHex = initialAmount.toString(16).padStart(64, '0'); + const ZERO_32 = '0'.repeat(64); + const amountPerSecondHex = amountPerSecond.toString(16).padStart(64, '0'); + const startTimeHex = startTime.toString(16).padStart(64, '0'); + const terms = `0x${initialHex}${ZERO_32}${amountPerSecondHex}${startTimeHex}` as Hex; + const caveats = [ + expiryCaveat, + { + enforcer: NativeTokenStreamingEnforcer, + terms, + args: '0x', + } as const, + ]; + + expect(() => + getPermissionDataAndExpiry({ + contracts, + caveats, + permissionType, + }), + ).toThrow( + 'Invalid native-token-stream terms: maxAmount must be a positive number', + ); + }); + + it('rejects terms with zero amountPerSecond', () => { + const initialHex = initialAmount.toString(16).padStart(64, '0'); + const maxHex = maxAmount.toString(16).padStart(64, '0'); + const ZERO_32 = '0'.repeat(64); + const startTimeHex = startTime.toString(16).padStart(64, '0'); + const terms = `0x${initialHex}${maxHex}${ZERO_32}${startTimeHex}` as Hex; + const caveats = [ + expiryCaveat, + { + enforcer: NativeTokenStreamingEnforcer, + terms, + args: '0x', + } as const, + ]; + + expect(() => + getPermissionDataAndExpiry({ + contracts, + caveats, + permissionType, + }), + ).toThrow( + 'Invalid native-token-stream terms: amountPerSecond must be a positive number', + ); + }); + + it('rejects terms with zero startTime', () => { + const initialHex = initialAmount.toString(16).padStart(64, '0'); + const maxHex = maxAmount.toString(16).padStart(64, '0'); + const amountPerSecondHex = amountPerSecond.toString(16).padStart(64, '0'); + const startTimeZero = '0'.repeat(64); + const terms = `0x${initialHex}${maxHex}${amountPerSecondHex}${startTimeZero}` as Hex; + const caveats = [ + expiryCaveat, + { + enforcer: NativeTokenStreamingEnforcer, + terms, + args: '0x', + } as const, + ]; + + expect(() => + getPermissionDataAndExpiry({ + contracts, + caveats, + permissionType, + }), + ).toThrow( + 'Invalid native-token-stream terms: startTime must be a positive number', + ); + }); }); describe('native-token-periodic', () => { @@ -903,6 +1007,56 @@ describe('decodePermission', () => { }), ).toThrow('Value must be a hexadecimal string.'); }); + + it('rejects terms with zero periodDuration', () => { + const periodAmountHex = periodAmount.toString(16).padStart(64, '0'); + const periodDurationZero = '0'.repeat(64); + const startDateHex = startDate.toString(16).padStart(64, '0'); + const terms = `0x${periodAmountHex}${periodDurationZero}${startDateHex}` as Hex; + const caveats = [ + expiryCaveat, + { + enforcer: NativeTokenPeriodTransferEnforcer, + terms, + args: '0x', + } as const, + ]; + + expect(() => + getPermissionDataAndExpiry({ + contracts, + caveats, + permissionType, + }), + ).toThrow( + 'Invalid native-token-periodic terms: periodDuration must be a positive number', + ); + }); + + it('rejects terms with zero startTime', () => { + const periodAmountHex = periodAmount.toString(16).padStart(64, '0'); + const periodDurationHex = periodDuration.toString(16).padStart(64, '0'); + const startTimeZero = '0'.repeat(64); + const terms = `0x${periodAmountHex}${periodDurationHex}${startTimeZero}` as Hex; + const caveats = [ + expiryCaveat, + { + enforcer: NativeTokenPeriodTransferEnforcer, + terms, + args: '0x', + } as const, + ]; + + expect(() => + getPermissionDataAndExpiry({ + contracts, + caveats, + permissionType, + }), + ).toThrow( + 'Invalid native-token-periodic terms: startTime must be a positive number', + ); + }); }); describe('erc20-token-stream', () => { @@ -1032,6 +1186,60 @@ describe('decodePermission', () => { }), ).toThrow('Value must be a hexadecimal string.'); }); + + it('rejects terms when maxAmount is less than initialAmount', () => { + const tokenHex = tokenAddress.slice(2); + const initialAmountHex = (1000n).toString(16).padStart(64, '0'); + const maxAmountHex = (100n).toString(16).padStart(64, '0'); + const amountPerSecondHex = amountPerSecond.toString(16).padStart(64, '0'); + const startTimeHex = startTime.toString(16).padStart(64, '0'); + const terms = `0x${tokenHex}${initialAmountHex}${maxAmountHex}${amountPerSecondHex}${startTimeHex}` as Hex; + const caveats = [ + expiryCaveat, + { + enforcer: ERC20StreamingEnforcer, + terms, + args: '0x', + } as const, + ]; + + expect(() => + getPermissionDataAndExpiry({ + contracts, + caveats, + permissionType, + }), + ).toThrow( + 'Invalid erc20-token-stream terms: maxAmount must be greater than initialAmount', + ); + }); + + it('rejects terms with zero startTime', () => { + const tokenHex = tokenAddress.slice(2); + const initialAmountHex = initialAmount.toString(16).padStart(64, '0'); + const maxAmountHex = maxAmount.toString(16).padStart(64, '0'); + const amountPerSecondHex = amountPerSecond.toString(16).padStart(64, '0'); + const startTimeZero = '0'.repeat(64); + const terms = `0x${tokenHex}${initialAmountHex}${maxAmountHex}${amountPerSecondHex}${startTimeZero}` as Hex; + const caveats = [ + expiryCaveat, + { + enforcer: ERC20StreamingEnforcer, + terms, + args: '0x', + } as const, + ]; + + expect(() => + getPermissionDataAndExpiry({ + contracts, + caveats, + permissionType, + }), + ).toThrow( + 'Invalid erc20-token-stream terms: startTime must be a positive number', + ); + }); }); describe('erc20-token-periodic', () => { @@ -1155,6 +1363,58 @@ describe('decodePermission', () => { }), ).toThrow('Value must be a hexadecimal string.'); }); + + it('rejects terms with zero periodDuration', () => { + const tokenHex = tokenAddress.slice(2); + const periodAmountHex = periodAmount.toString(16).padStart(64, '0'); + const periodDurationZero = '0'.repeat(64); + const startDateHex = startDate.toString(16).padStart(64, '0'); + const terms = `0x${tokenHex}${periodAmountHex}${periodDurationZero}${startDateHex}` as Hex; + const caveats = [ + expiryCaveat, + { + enforcer: ERC20PeriodTransferEnforcer, + terms, + args: '0x', + } as const, + ]; + + expect(() => + getPermissionDataAndExpiry({ + contracts, + caveats, + permissionType, + }), + ).toThrow( + 'Invalid erc20-token-periodic terms: periodDuration must be a positive number', + ); + }); + + it('rejects terms with zero startTime', () => { + const tokenHex = tokenAddress.slice(2); + const periodAmountHex = periodAmount.toString(16).padStart(64, '0'); + const periodDurationHex = periodDuration.toString(16).padStart(64, '0'); + const startTimeZero = '0'.repeat(64); + const terms = `0x${tokenHex}${periodAmountHex}${periodDurationHex}${startTimeZero}` as Hex; + const caveats = [ + expiryCaveat, + { + enforcer: ERC20PeriodTransferEnforcer, + terms, + args: '0x', + } as const, + ]; + + expect(() => + getPermissionDataAndExpiry({ + contracts, + caveats, + permissionType, + }), + ).toThrow( + 'Invalid erc20-token-periodic terms: startTime must be a positive number', + ); + }); }); describe('erc20-token-revocation', () => { @@ -1351,4 +1611,666 @@ describe('decodePermission', () => { ).toThrow('Invalid authority'); }); }); + + describe('adversarial: attempts to violate decoder expectations', () => { + describe('identifyPermissionByEnforcers()', () => { + it('rejects empty enforcer list', () => { + expect(() => + identifyPermissionByEnforcers({ enforcers: [], contracts }), + ).toThrow('Unable to identify permission type'); + }); + + it('rejects enforcer list with only unknown/forbidden addresses', () => { + const unknown = '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef' as Hex; + expect(() => + identifyPermissionByEnforcers({ + enforcers: [unknown], + contracts, + }), + ).toThrow('Unable to identify permission type'); + }); + + it('rejects when required enforcer count is exceeded (e.g. duplicate NonceEnforcer)', () => { + const enforcers = [ + NativeTokenStreamingEnforcer, + ExactCalldataEnforcer, + NonceEnforcer, + NonceEnforcer, + ]; + expect(() => + identifyPermissionByEnforcers({ enforcers, contracts }), + ).toThrow('Unable to identify permission type'); + }); + + it('rejects mix of valid known enforcers and valid but unknown enforcer address', () => { + const unknownEnforcer = + '0xbadbadbadbadbadbadbadbadbadbadbadbadbadb' as Hex; + const enforcers = [ + NativeTokenStreamingEnforcer, + ExactCalldataEnforcer, + NonceEnforcer, + unknownEnforcer, + ]; + expect(() => + identifyPermissionByEnforcers({ enforcers, contracts }), + ).toThrow('Unable to identify permission type'); + }); + + it('rejects exactly one AllowedCalldataEnforcer for erc20-token-revocation (wrong multiplicity)', () => { + const enforcers = [ + AllowedCalldataEnforcer, + ValueLteEnforcer, + NonceEnforcer, + ]; + expect(() => + identifyPermissionByEnforcers({ enforcers, contracts }), + ).toThrow('Unable to identify permission type'); + }); + + it('rejects three AllowedCalldataEnforcer for erc20-token-revocation (excess multiplicity)', () => { + const enforcers = [ + AllowedCalldataEnforcer, + AllowedCalldataEnforcer, + AllowedCalldataEnforcer, + ValueLteEnforcer, + NonceEnforcer, + ]; + expect(() => + identifyPermissionByEnforcers({ enforcers, contracts }), + ).toThrow('Unable to identify permission type'); + }); + }); + + describe('getPermissionDataAndExpiry()', () => { + const timestampBeforeThreshold = 1720000; + const expiryCaveat = { + enforcer: TimestampEnforcer, + terms: createTimestampTerms({ + timestampAfterThreshold: 0, + timestampBeforeThreshold, + }), + args: '0x', + } as const; + + it('rejects duplicate caveats for same enforcer (e.g. two TimestampEnforcer)', () => { + const caveats = [ + expiryCaveat, + { + enforcer: TimestampEnforcer, + terms: createTimestampTerms({ + timestampAfterThreshold: 0, + timestampBeforeThreshold: 9999, + }), + args: '0x', + } as const, + { + enforcer: NativeTokenStreamingEnforcer, + terms: createNativeTokenStreamingTerms( + { + initialAmount: 1n, + maxAmount: 2n, + amountPerSecond: 1n, + startTime: 1715664, + }, + { out: 'hex' }, + ), + args: '0x', + } as const, + ]; + + expect(() => + getPermissionDataAndExpiry({ + contracts, + caveats, + permissionType: 'native-token-stream', + }), + ).toThrow('Invalid caveats'); + }); + + it('rejects duplicate permission-type enforcer caveats (e.g. two ERC20StreamingEnforcer)', () => { + const tokenAddress = + '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex; + const terms = createERC20StreamingTerms( + { + tokenAddress, + initialAmount: 1n, + maxAmount: 2n, + amountPerSecond: 1n, + startTime: 1715664, + }, + { out: 'hex' }, + ); + const caveats = [ + expiryCaveat, + { enforcer: ERC20StreamingEnforcer, terms, args: '0x' as const }, + { enforcer: ERC20StreamingEnforcer, terms, args: '0x' as const }, + ]; + + expect(() => + getPermissionDataAndExpiry({ + contracts, + caveats, + permissionType: 'erc20-token-stream', + }), + ).toThrow('Invalid caveats'); + }); + + it('rejects TimestampEnforcer terms with non-hex characters', () => { + const invalidTerms = + '0x00000000000000000000000000000000zz000000000000000000000000001a3b80' as Hex; + const caveats = [ + { + 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, + ]; + + expect(() => + getPermissionDataAndExpiry({ + contracts, + caveats, + permissionType: 'native-token-stream', + }), + ).toThrow(); + }); + + it('rejects permission-type terms shorter than expected (truncated payload)', () => { + // ERC20 stream expects [20, 32, 32, 32, 32] bytes = 148 bytes = 296 hex chars. + // Provide only 100 hex chars so last segments are truncated; hexToNumber may throw or mis-parse. + const truncatedTerms = `0x${'a'.repeat(100)}` as Hex; + const caveats = [ + expiryCaveat, + { + enforcer: ERC20StreamingEnforcer, + terms: truncatedTerms, + args: '0x', + } as const, + ]; + + expect(() => + getPermissionDataAndExpiry({ + contracts, + caveats, + permissionType: 'erc20-token-stream', + }), + ).toThrow(); + }); + + it('rejects native-token-stream terms shorter than expected', () => { + const truncatedTerms = `0x${'00'.repeat(50)}` as Hex; // 50 bytes, need 128 + const caveats = [ + expiryCaveat, + { + enforcer: NativeTokenStreamingEnforcer, + terms: truncatedTerms, + args: '0x', + } as const, + ]; + + expect(() => + getPermissionDataAndExpiry({ + contracts, + caveats, + permissionType: 'native-token-stream', + }), + ).toThrow(); + }); + + it('rejects erc20-token-revocation with only approve selector (missing zero-amount constraint)', () => { + const approveSelectorTerms = + '0x0000000000000000000000000000000000000000000000000000000000000000095ea7b3' as Hex; + const zeroValueLteTerms = + '0x0000000000000000000000000000000000000000000000000000000000000000' as Hex; + 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, + ]; + + expect(() => + getPermissionDataAndExpiry({ + contracts, + caveats, + permissionType: 'erc20-token-revocation', + }), + ).toThrow( + 'Invalid erc20-token-revocation terms: expected approve selector and zero amount constraints', + ); + }); + + it('rejects erc20-token-revocation with only zero-amount constraint (missing approve selector)', () => { + const zeroAmountTerms = + '0x00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000' as Hex; + const zeroValueLteTerms = + '0x0000000000000000000000000000000000000000000000000000000000000000' as Hex; + 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, + ]; + + expect(() => + getPermissionDataAndExpiry({ + contracts, + caveats, + permissionType: 'erc20-token-revocation', + }), + ).toThrow( + 'Invalid erc20-token-revocation terms: expected approve selector and zero amount constraints', + ); + }); + + it('rejects erc20-token-revocation when ValueLteEnforcer terms are non-zero', () => { + const approveSelectorTerms = + '0x0000000000000000000000000000000000000000000000000000000000000000095ea7b3' as Hex; + const zeroAmountTerms = + '0x00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000' as Hex; + const nonZeroValueLteTerms = + '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex; + 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, + ]; + + expect(() => + getPermissionDataAndExpiry({ + contracts, + caveats, + permissionType: 'erc20-token-revocation', + }), + ).toThrow('Invalid ValueLteEnforcer terms: maxValue must be 0'); + }); + + it('rejects TimestampEnforcer terms with wrong length (66 required)', () => { + const badLengthTerms = `0x${'0'.repeat(65)}` as Hex; // 65 hex chars after 0x + const caveats = [ + { + 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, + ]; + + expect(() => + getPermissionDataAndExpiry({ + contracts, + caveats, + permissionType: 'native-token-stream', + }), + ).toThrow('Invalid TimestampEnforcer terms length'); + }); + + it('rejects expiry timestampBeforeThreshold zero', () => { + const caveats = [ + { + enforcer: TimestampEnforcer, + terms: createTimestampTerms({ + timestampAfterThreshold: 0, + timestampBeforeThreshold: 0, + }), + args: '0x', + } as const, + { + enforcer: NativeTokenStreamingEnforcer, + terms: createNativeTokenStreamingTerms( + { + initialAmount: 1n, + maxAmount: 2n, + amountPerSecond: 1n, + startTime: 1715664, + }, + { out: 'hex' }, + ), + args: '0x', + } as const, + ]; + + expect(() => + getPermissionDataAndExpiry({ + contracts, + caveats, + permissionType: 'native-token-stream', + }), + ).toThrow( + 'Invalid expiry: timestampBeforeThreshold must be greater than 0', + ); + }); + + it('rejects expiry timestampAfterThreshold non-zero', () => { + const caveats = [ + { + enforcer: TimestampEnforcer, + terms: createTimestampTerms({ + timestampAfterThreshold: 1, + timestampBeforeThreshold: 1720000, + }), + args: '0x', + } as const, + { + enforcer: NativeTokenStreamingEnforcer, + terms: createNativeTokenStreamingTerms( + { + initialAmount: 1n, + maxAmount: 2n, + amountPerSecond: 1n, + startTime: 1715664, + }, + { out: 'hex' }, + ), + args: '0x', + } as const, + ]; + + expect(() => + getPermissionDataAndExpiry({ + contracts, + caveats, + permissionType: 'native-token-stream', + }), + ).toThrow('Invalid expiry: timestampAfterThreshold must be 0'); + }); + }); + + 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('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'); + }); + }); + }); + + describe('adversarial: intent violations — decoder accepts inputs that may not meet semantic expectations', () => { + const expiryCaveat = { + enforcer: TimestampEnforcer, + terms: createTimestampTerms({ + timestampAfterThreshold: 0, + timestampBeforeThreshold: 1720000, + }), + args: '0x', + } as const; + + it('successfully decodes erc20-token-stream with zero token address (no validation that token is non-zero)', () => { + const zeroAddress = + '0x0000000000000000000000000000000000000000' as Hex; + const caveats = [ + expiryCaveat, + { + enforcer: ERC20StreamingEnforcer, + terms: createERC20StreamingTerms( + { + tokenAddress: zeroAddress, + initialAmount: 1n, + maxAmount: 2n, + amountPerSecond: 1n, + startTime: 1715664, + }, + { out: 'hex' }, + ), + args: '0x', + } as const, + ]; + + const { expiry, data } = getPermissionDataAndExpiry({ + contracts, + caveats, + permissionType: 'erc20-token-stream', + }); + + expect(expiry).toBe(1720000); + expect(data.tokenAddress).toBe(zeroAddress); + }); + + 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, + { + enforcer: NativeTokenStreamingEnforcer, + terms, + args: '0x', + } as const, + ]; + + expect(() => + getPermissionDataAndExpiry({ + contracts, + caveats, + permissionType: 'native-token-stream', + }), + ).toThrow( + 'Invalid native-token-stream terms: initialAmount must be a positive number', + ); + }); + + it('rejects erc20-token-periodic with periodDuration 0 (validates duration is positive)', () => { + 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, + { + enforcer: ERC20PeriodTransferEnforcer, + terms, + args: '0x', + } as const, + ]; + + expect(() => + getPermissionDataAndExpiry({ + contracts, + caveats, + permissionType: 'erc20-token-periodic', + }), + ).toThrow( + 'Invalid erc20-token-periodic terms: periodDuration must be a positive number', + ); + }); + + it('rejects erc20-token-stream when initialAmount exceeds maxAmount (validates maxAmount >= initialAmount)', () => { + 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, + { + enforcer: ERC20StreamingEnforcer, + terms, + args: '0x', + } as const, + ]; + + expect(() => + getPermissionDataAndExpiry({ + contracts, + caveats, + permissionType: 'erc20-token-stream', + }), + ).toThrow( + 'Invalid erc20-token-stream terms: maxAmount must be greater than initialAmount', + ); + }); + + it('successfully decodes when terms are longer than expected format (trailing bytes ignored; no validation of total terms length)', () => { + const tokenAddress = + '0xcccccccccccccccccccccccccccccccccccccccc' as Hex; + const validTerms = createERC20StreamingTerms( + { + tokenAddress, + initialAmount: 42n, + maxAmount: 100n, + amountPerSecond: 1n, + startTime: 1715664, + }, + { out: 'hex' }, + ); + const termsWithTrailingGarbage = `${validTerms}deadbeef` as Hex; + + const caveats = [ + expiryCaveat, + { + enforcer: ERC20StreamingEnforcer, + terms: termsWithTrailingGarbage, + args: '0x', + } as const, + ]; + + const { data } = getPermissionDataAndExpiry({ + contracts, + caveats, + permissionType: 'erc20-token-stream', + }); + + expect(data.tokenAddress).toBe(tokenAddress); + expect(hexToBigInt(data.initialAmount)).toBe(42n); + expect(hexToBigInt(data.maxAmount)).toBe(100n); + expect(data.startTime).toBe(1715664); + }); + + 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, + { + enforcer: NativeTokenStreamingEnforcer, + terms, + args: '0x', + } as const, + ]; + + expect(() => + getPermissionDataAndExpiry({ + contracts, + caveats, + permissionType: 'native-token-stream', + }), + ).toThrow( + 'Invalid native-token-stream terms: startTime must be a positive number', + ); + }); + }); }); diff --git a/packages/gator-permissions-controller/src/decodePermission/decodePermission.ts b/packages/gator-permissions-controller/src/decodePermission/decodePermission.ts index ca486fc38c9..ade5a2669fc 100644 --- a/packages/gator-permissions-controller/src/decodePermission/decodePermission.ts +++ b/packages/gator-permissions-controller/src/decodePermission/decodePermission.ts @@ -1,6 +1,11 @@ import type { Caveat, Hex } from '@metamask/delegation-core'; import { ROOT_AUTHORITY } from '@metamask/delegation-core'; -import { getChecksumAddress, hexToNumber, numberToHex } from '@metamask/utils'; +import { + getChecksumAddress, + hexToBigInt, + hexToNumber, + numberToHex, +} from '@metamask/utils'; import type { DecodedPermission, @@ -206,12 +211,28 @@ export const getPermissionDataAndExpiry = ({ startTimeRaw, ] = splitHex(erc20StreamingTerms, [20, 32, 32, 32, 32]); + const startTime = hexToNumber(startTimeRaw); + const initialAmountBigInt = hexToBigInt(initialAmount); + const maxAmountBigInt = hexToBigInt(maxAmount); + + if (maxAmountBigInt < initialAmountBigInt) { + throw new Error( + 'Invalid erc20-token-stream terms: maxAmount must be greater than initialAmount', + ); + } + + if (startTime <= 0) { + throw new Error( + 'Invalid erc20-token-stream terms: startTime must be a positive number', + ); + } + data = { tokenAddress, initialAmount, maxAmount, amountPerSecond, - startTime: hexToNumber(startTimeRaw), + startTime, }; break; } @@ -224,11 +245,26 @@ export const getPermissionDataAndExpiry = ({ const [tokenAddress, periodAmount, periodDurationRaw, startTimeRaw] = splitHex(erc20PeriodicTerms, [20, 32, 32, 32]); + const periodDuration = hexToNumber(periodDurationRaw); + const startTime = hexToNumber(startTimeRaw); + + if (periodDuration <= 0) { + throw new Error( + 'Invalid erc20-token-periodic terms: periodDuration must be a positive number', + ); + } + + if (startTime <= 0) { + throw new Error( + 'Invalid erc20-token-periodic terms: startTime must be a positive number', + ); + } + data = { tokenAddress, periodAmount, - periodDuration: hexToNumber(periodDurationRaw), - startTime: hexToNumber(startTimeRaw), + periodDuration, + startTime, }; break; } @@ -242,11 +278,40 @@ export const getPermissionDataAndExpiry = ({ const [initialAmount, maxAmount, amountPerSecond, startTimeRaw] = splitHex(nativeTokenStreamingTerms, [32, 32, 32, 32]); + const initialAmountBigInt = hexToBigInt(initialAmount); + const maxAmountBigInt = hexToBigInt(maxAmount); + const amountPerSecondBigInt = hexToBigInt(amountPerSecond); + const startTime = hexToNumber(startTimeRaw); + + if (initialAmountBigInt <= 0n) { + throw new Error( + 'Invalid native-token-stream terms: initialAmount must be a positive number', + ); + } + + if (maxAmountBigInt <= 0n) { + throw new Error( + 'Invalid native-token-stream terms: maxAmount must be a positive number', + ); + } + + 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', + ); + } + data = { initialAmount, maxAmount, amountPerSecond, - startTime: hexToNumber(startTimeRaw), + startTime, }; break; } @@ -261,10 +326,25 @@ export const getPermissionDataAndExpiry = ({ [32, 32, 32], ); + const periodDuration = hexToNumber(periodDurationRaw); + const startTime = hexToNumber(startTimeRaw); + + if (periodDuration <= 0) { + throw new Error( + 'Invalid native-token-periodic terms: periodDuration must be a positive number', + ); + } + + if (startTime <= 0) { + throw new Error( + 'Invalid native-token-periodic terms: startTime must be a positive number', + ); + } + data = { periodAmount, - periodDuration: hexToNumber(periodDurationRaw), - startTime: hexToNumber(startTimeRaw), + periodDuration, + startTime, }; break; } From bfb1bde689836868e2be15556f9e94a9b7079181 Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Mon, 2 Mar 2026 16:24:35 +1300 Subject: [PATCH 02/13] Refactor PermissionRule and rule generation so that each permission type is self-describing and can be more easily tested in isolation. Add validation and test coverage for each permission type. --- .../src/GatorPermissionsController.test.ts | 41 + .../src/GatorPermissionsController.ts | 29 +- .../decodePermission/decodePermission.test.ts | 1839 ++--------------- .../src/decodePermission/decodePermission.ts | 414 +--- .../src/decodePermission/index.ts | 11 +- .../rules/erc20TokenPeriodic.test.ts | 212 ++ .../rules/erc20TokenPeriodic.ts | 74 + .../rules/erc20TokenRevocation.test.ts | 252 +++ .../rules/erc20TokenRevocation.ts | 92 + .../rules/erc20TokenStream.test.ts | 157 ++ .../rules/erc20TokenStream.ts | 82 + .../src/decodePermission/rules/index.ts | 36 + .../rules/makePermissionRule.test.ts | 53 + .../rules/makePermissionRule.ts | 93 + .../rules/nativeTokenPeriodic.test.ts | 197 ++ .../rules/nativeTokenPeriodic.ts | 75 + .../rules/nativeTokenStream.test.ts | 380 ++++ .../rules/nativeTokenStream.ts | 90 + .../src/decodePermission/types.ts | 65 + .../src/decodePermission/utils.test.ts | 35 +- .../src/decodePermission/utils.ts | 171 +- 21 files changed, 2246 insertions(+), 2152 deletions(-) create mode 100644 packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenPeriodic.test.ts create mode 100644 packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenPeriodic.ts create mode 100644 packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenRevocation.test.ts create mode 100644 packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenRevocation.ts create mode 100644 packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenStream.test.ts create mode 100644 packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenStream.ts create mode 100644 packages/gator-permissions-controller/src/decodePermission/rules/index.ts create mode 100644 packages/gator-permissions-controller/src/decodePermission/rules/makePermissionRule.test.ts create mode 100644 packages/gator-permissions-controller/src/decodePermission/rules/makePermissionRule.ts create mode 100644 packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenPeriodic.test.ts create mode 100644 packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenPeriodic.ts create mode 100644 packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenStream.test.ts create mode 100644 packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenStream.ts diff --git a/packages/gator-permissions-controller/src/GatorPermissionsController.test.ts b/packages/gator-permissions-controller/src/GatorPermissionsController.test.ts index a3bb45ea4a9..648875c6a70 100644 --- a/packages/gator-permissions-controller/src/GatorPermissionsController.test.ts +++ b/packages/gator-permissions-controller/src/GatorPermissionsController.test.ts @@ -721,6 +721,47 @@ describe('GatorPermissionsController', () => { ).toThrow('Failed to decode permission'); }); + it('throws when caveat terms are invalid for the matched permission rule', () => { + const { + TimestampEnforcer, + NativeTokenStreamingEnforcer, + ExactCalldataEnforcer, + NonceEnforcer, + } = contracts; + + const expiryTerms = createTimestampTerms( + { timestampAfterThreshold: 0, timestampBeforeThreshold: 1720000 }, + { out: 'hex' }, + ); + + // Enforcers match native-token-stream but stream terms are truncated (invalid) + const truncatedStreamTerms = `0x${'00'.repeat(50)}` as Hex; + const caveats = [ + { enforcer: TimestampEnforcer, terms: expiryTerms, args: '0x' } as const, + { + enforcer: NativeTokenStreamingEnforcer, + terms: truncatedStreamTerms, + args: '0x', + } as const, + { enforcer: ExactCalldataEnforcer, terms: '0x', args: '0x' } as const, + { enforcer: NonceEnforcer, terms: '0x', args: '0x' } as const, + ]; + + expect(() => + controller.decodePermissionFromPermissionContextForOrigin({ + origin: controller.permissionsProviderSnapId, + chainId, + delegation: { + delegate: delegatorAddressA, + delegator: delegateAddressB, + authority: ROOT_AUTHORITY as Hex, + caveats, + }, + metadata: buildMetadata(''), + }), + ).toThrow('Failed to decode permission'); + }); + it('throws when authority is not ROOT_AUTHORITY', () => { const { TimestampEnforcer, diff --git a/packages/gator-permissions-controller/src/GatorPermissionsController.ts b/packages/gator-permissions-controller/src/GatorPermissionsController.ts index 96881af131f..5865d53f47b 100644 --- a/packages/gator-permissions-controller/src/GatorPermissionsController.ts +++ b/packages/gator-permissions-controller/src/GatorPermissionsController.ts @@ -22,8 +22,8 @@ import type { Hex } from '@metamask/utils'; import { DELEGATION_FRAMEWORK_VERSION } from './constants'; import type { DecodedPermission } from './decodePermission'; import { - getPermissionDataAndExpiry, - identifyPermissionByEnforcers, + createPermissionRulesForContracts, + findRuleWithMatchingCaveatAddresses, reconstructDecodedPermission, } from './decodePermission'; import { @@ -586,21 +586,30 @@ export default class GatorPermissionsController extends BaseController< try { const enforcers = caveats.map((caveat) => caveat.enforcer); + const permissionRules = createPermissionRulesForContracts(contracts); - const permissionType = identifyPermissionByEnforcers({ + // find the single rule where the specified enforcers contain all the required enforcers + // and no forbidden enforcers + const matchingRule = findRuleWithMatchingCaveatAddresses({ enforcers, - contracts, + permissionRules, }); - const { expiry, data } = getPermissionDataAndExpiry({ - contracts, - caveats, - permissionType, - }); + // validate the terms of each caveat against the matching rule, returning the decoded result + // this happens in a single function, as decoding is an inherent part of validation. + const decodeResult = matchingRule.validateAndDecodePermission(caveats); + + if (!decodeResult.isValid) { + throw new PermissionDecodingError({ + cause: decodeResult.error, + }); + } + + const { expiry, data } = decodeResult; const permission = reconstructDecodedPermission({ chainId, - permissionType, + permissionType: matchingRule.permissionType, delegator, delegate, authority, diff --git a/packages/gator-permissions-controller/src/decodePermission/decodePermission.test.ts b/packages/gator-permissions-controller/src/decodePermission/decodePermission.test.ts index d2a57c5619d..bf53fb58b3d 100644 --- a/packages/gator-permissions-controller/src/decodePermission/decodePermission.test.ts +++ b/packages/gator-permissions-controller/src/decodePermission/decodePermission.test.ts @@ -11,11 +11,10 @@ import { CHAIN_ID, DELEGATOR_CONTRACTS, } from '@metamask/delegation-deployments'; -import { hexToBigInt, numberToHex } from '@metamask/utils'; +import { numberToHex } from '@metamask/utils'; import { - getPermissionDataAndExpiry, - identifyPermissionByEnforcers, + findRuleWithMatchingCaveatAddresses, reconstructDecodedPermission, } from './decodePermission'; import type { @@ -23,6 +22,22 @@ import type { DeployedContractsByName, PermissionType, } from './types'; +import type { PermissionRule } from './types'; +import { createPermissionRulesForContracts } from './rules'; + +/** Mock rule whose validateAndDecodePermission returns invalid (for error-path tests). */ +function createMockPermissionRuleThatReturnsInvalid(errorMessage: string): PermissionRule { + return { + permissionType: 'native-token-stream', + requiredEnforcers: new Map(), + optionalEnforcers: new Set(), + caveatAddressesMatch: () => true, + validateAndDecodePermission: () => ({ + isValid: false, + error: new Error(errorMessage), + }), + }; +} // These tests use the live deployments table for version 1.3.0 to // construct deterministic caveat address sets for a known chain. @@ -43,7 +58,7 @@ describe('decodePermission', () => { NonceEnforcer, } = contracts; - describe('identifyPermissionByEnforcers()', () => { + describe('getPermissionRuleMatchingCaveatTypes()', () => { const zeroAddress = '0x0000000000000000000000000000000000000000' as Hex; it('throws if multiple permission types match', () => { @@ -59,9 +74,9 @@ describe('decodePermission', () => { } as unknown as DeployedContractsByName; expect(() => { - identifyPermissionByEnforcers({ + findRuleWithMatchingCaveatAddresses({ enforcers, - contracts: contractsWithDuplicates, + permissionRules: createPermissionRulesForContracts(contractsWithDuplicates), }); }).toThrow('Multiple permission types match'); }); @@ -75,8 +90,11 @@ describe('decodePermission', () => { ExactCalldataEnforcer, NonceEnforcer, ]; - const result = identifyPermissionByEnforcers({ enforcers, contracts }); - expect(result).toBe(expectedPermissionType); + const result = findRuleWithMatchingCaveatAddresses({ + enforcers, + permissionRules: createPermissionRulesForContracts(contracts), + }); + expect(result.permissionType).toBe(expectedPermissionType); }); it('allows TimestampEnforcer as extra', () => { @@ -86,8 +104,11 @@ describe('decodePermission', () => { NonceEnforcer, TimestampEnforcer, ]; - const result = identifyPermissionByEnforcers({ enforcers, contracts }); - expect(result).toBe(expectedPermissionType); + const result = findRuleWithMatchingCaveatAddresses({ + enforcers, + permissionRules: createPermissionRulesForContracts(contracts), + }); + expect(result.permissionType).toBe(expectedPermissionType); }); it('rejects forbidden extra caveat', () => { @@ -99,14 +120,20 @@ describe('decodePermission', () => { ValueLteEnforcer, ]; expect(() => - identifyPermissionByEnforcers({ enforcers, contracts }), + findRuleWithMatchingCaveatAddresses({ + enforcers, + permissionRules: createPermissionRulesForContracts(contracts), + }), ).toThrow('Unable to identify permission type'); }); it('rejects when required caveats are missing', () => { const enforcers = [ExactCalldataEnforcer]; expect(() => - identifyPermissionByEnforcers({ enforcers, contracts }), + findRuleWithMatchingCaveatAddresses({ + enforcers, + permissionRules: createPermissionRulesForContracts(contracts), + }), ).toThrow('Unable to identify permission type'); }); @@ -116,8 +143,11 @@ describe('decodePermission', () => { ExactCalldataEnforcer.toLowerCase() as unknown as Hex, NonceEnforcer.toLowerCase() as unknown as Hex, ]; - const result = identifyPermissionByEnforcers({ enforcers, contracts }); - expect(result).toBe('native-token-stream'); + const result = findRuleWithMatchingCaveatAddresses({ + enforcers, + permissionRules: createPermissionRulesForContracts(contracts), + }); + expect(result.permissionType).toBe('native-token-stream'); }); it('throws if a contract is not found', () => { @@ -132,9 +162,9 @@ describe('decodePermission', () => { } as unknown as DeployedContractsByName; expect(() => - identifyPermissionByEnforcers({ + findRuleWithMatchingCaveatAddresses({ enforcers, - contracts: contractsWithoutTimestampEnforcer, + permissionRules: createPermissionRulesForContracts(contractsWithoutTimestampEnforcer), }), ).toThrow('Contract not found: TimestampEnforcer'); }); @@ -148,8 +178,11 @@ describe('decodePermission', () => { ExactCalldataEnforcer, NonceEnforcer, ]; - const result = identifyPermissionByEnforcers({ enforcers, contracts }); - expect(result).toBe(expectedPermissionType); + const result = findRuleWithMatchingCaveatAddresses({ + enforcers, + permissionRules: createPermissionRulesForContracts(contracts), + }); + expect(result.permissionType).toBe(expectedPermissionType); }); it('allows TimestampEnforcer as extra', () => { @@ -159,8 +192,11 @@ describe('decodePermission', () => { NonceEnforcer, TimestampEnforcer, ]; - const result = identifyPermissionByEnforcers({ enforcers, contracts }); - expect(result).toBe(expectedPermissionType); + const result = findRuleWithMatchingCaveatAddresses({ + enforcers, + permissionRules: createPermissionRulesForContracts(contracts), + }); + expect(result.permissionType).toBe(expectedPermissionType); }); it('rejects forbidden extra caveat', () => { @@ -172,14 +208,20 @@ describe('decodePermission', () => { ValueLteEnforcer, ]; expect(() => - identifyPermissionByEnforcers({ enforcers, contracts }), + findRuleWithMatchingCaveatAddresses({ + enforcers, + permissionRules: createPermissionRulesForContracts(contracts), + }), ).toThrow('Unable to identify permission type'); }); it('rejects when required caveats are missing', () => { const enforcers = [ExactCalldataEnforcer]; expect(() => - identifyPermissionByEnforcers({ enforcers, contracts }), + findRuleWithMatchingCaveatAddresses({ + enforcers, + permissionRules: createPermissionRulesForContracts(contracts), + }), ).toThrow('Unable to identify permission type'); }); @@ -189,8 +231,11 @@ describe('decodePermission', () => { ExactCalldataEnforcer.toLowerCase() as unknown as Hex, NonceEnforcer.toLowerCase() as unknown as Hex, ]; - const result = identifyPermissionByEnforcers({ enforcers, contracts }); - expect(result).toBe(expectedPermissionType); + const result = findRuleWithMatchingCaveatAddresses({ + enforcers, + permissionRules: createPermissionRulesForContracts(contracts), + }); + expect(result.permissionType).toBe(expectedPermissionType); }); it('throws if a contract is not found', () => { @@ -205,9 +250,9 @@ describe('decodePermission', () => { } as unknown as DeployedContractsByName; expect(() => - identifyPermissionByEnforcers({ + findRuleWithMatchingCaveatAddresses({ enforcers, - contracts: contractsWithoutTimestampEnforcer, + permissionRules: createPermissionRulesForContracts(contractsWithoutTimestampEnforcer), }), ).toThrow('Contract not found: TimestampEnforcer'); }); @@ -221,8 +266,11 @@ describe('decodePermission', () => { ValueLteEnforcer, NonceEnforcer, ]; - const result = identifyPermissionByEnforcers({ enforcers, contracts }); - expect(result).toBe(expectedPermissionType); + const result = findRuleWithMatchingCaveatAddresses({ + enforcers, + permissionRules: createPermissionRulesForContracts(contracts), + }); + expect(result.permissionType).toBe(expectedPermissionType); }); it('allows TimestampEnforcer as extra', () => { @@ -232,8 +280,11 @@ describe('decodePermission', () => { NonceEnforcer, TimestampEnforcer, ]; - const result = identifyPermissionByEnforcers({ enforcers, contracts }); - expect(result).toBe(expectedPermissionType); + const result = findRuleWithMatchingCaveatAddresses({ + enforcers, + permissionRules: createPermissionRulesForContracts(contracts), + }); + expect(result.permissionType).toBe(expectedPermissionType); }); it('rejects forbidden extra caveat', () => { @@ -245,14 +296,20 @@ describe('decodePermission', () => { ExactCalldataEnforcer, ]; expect(() => - identifyPermissionByEnforcers({ enforcers, contracts }), + findRuleWithMatchingCaveatAddresses({ + enforcers, + permissionRules: createPermissionRulesForContracts(contracts), + }), ).toThrow('Unable to identify permission type'); }); it('rejects when required caveats are missing', () => { const enforcers = [ERC20StreamingEnforcer]; expect(() => - identifyPermissionByEnforcers({ enforcers, contracts }), + findRuleWithMatchingCaveatAddresses({ + enforcers, + permissionRules: createPermissionRulesForContracts(contracts), + }), ).toThrow('Unable to identify permission type'); }); @@ -262,8 +319,11 @@ describe('decodePermission', () => { ValueLteEnforcer.toLowerCase() as unknown as Hex, NonceEnforcer.toLowerCase() as unknown as Hex, ]; - const result = identifyPermissionByEnforcers({ enforcers, contracts }); - expect(result).toBe(expectedPermissionType); + const result = findRuleWithMatchingCaveatAddresses({ + enforcers, + permissionRules: createPermissionRulesForContracts(contracts), + }); + expect(result.permissionType).toBe(expectedPermissionType); }); it('throws if a contract is not found', () => { @@ -278,9 +338,9 @@ describe('decodePermission', () => { } as unknown as DeployedContractsByName; expect(() => - identifyPermissionByEnforcers({ + findRuleWithMatchingCaveatAddresses({ enforcers, - contracts: contractsWithoutTimestampEnforcer, + permissionRules: createPermissionRulesForContracts(contractsWithoutTimestampEnforcer), }), ).toThrow('Contract not found: TimestampEnforcer'); }); @@ -294,8 +354,11 @@ describe('decodePermission', () => { ValueLteEnforcer, NonceEnforcer, ]; - const result = identifyPermissionByEnforcers({ enforcers, contracts }); - expect(result).toBe(expectedPermissionType); + const result = findRuleWithMatchingCaveatAddresses({ + enforcers, + permissionRules: createPermissionRulesForContracts(contracts), + }); + expect(result.permissionType).toBe(expectedPermissionType); }); it('allows TimestampEnforcer as extra', () => { @@ -305,8 +368,11 @@ describe('decodePermission', () => { NonceEnforcer, TimestampEnforcer, ]; - const result = identifyPermissionByEnforcers({ enforcers, contracts }); - expect(result).toBe(expectedPermissionType); + const result = findRuleWithMatchingCaveatAddresses({ + enforcers, + permissionRules: createPermissionRulesForContracts(contracts), + }); + expect(result.permissionType).toBe(expectedPermissionType); }); it('rejects forbidden extra caveat', () => { @@ -318,14 +384,20 @@ describe('decodePermission', () => { ExactCalldataEnforcer, ]; expect(() => - identifyPermissionByEnforcers({ enforcers, contracts }), + findRuleWithMatchingCaveatAddresses({ + enforcers, + permissionRules: createPermissionRulesForContracts(contracts), + }), ).toThrow('Unable to identify permission type'); }); it('rejects when required caveats are missing', () => { const enforcers = [ERC20PeriodTransferEnforcer]; expect(() => - identifyPermissionByEnforcers({ enforcers, contracts }), + findRuleWithMatchingCaveatAddresses({ + enforcers, + permissionRules: createPermissionRulesForContracts(contracts), + }), ).toThrow('Unable to identify permission type'); }); @@ -335,8 +407,11 @@ describe('decodePermission', () => { ValueLteEnforcer.toLowerCase() as unknown as Hex, NonceEnforcer.toLowerCase() as unknown as Hex, ]; - const result = identifyPermissionByEnforcers({ enforcers, contracts }); - expect(result).toBe(expectedPermissionType); + const result = findRuleWithMatchingCaveatAddresses({ + enforcers, + permissionRules: createPermissionRulesForContracts(contracts), + }); + expect(result.permissionType).toBe(expectedPermissionType); }); it('throws if a contract is not found', () => { @@ -351,9 +426,9 @@ describe('decodePermission', () => { } as unknown as DeployedContractsByName; expect(() => - identifyPermissionByEnforcers({ + findRuleWithMatchingCaveatAddresses({ enforcers, - contracts: contractsWithoutTimestampEnforcer, + permissionRules: createPermissionRulesForContracts(contractsWithoutTimestampEnforcer), }), ).toThrow('Contract not found: TimestampEnforcer'); }); @@ -369,8 +444,11 @@ describe('decodePermission', () => { ValueLteEnforcer, NonceEnforcer, ]; - const result = identifyPermissionByEnforcers({ enforcers, contracts }); - expect(result).toBe(expectedPermissionType); + const result = findRuleWithMatchingCaveatAddresses({ + enforcers, + permissionRules: createPermissionRulesForContracts(contracts), + }); + expect(result.permissionType).toBe(expectedPermissionType); }); it('allows TimestampEnforcer as extra', () => { @@ -381,8 +459,11 @@ describe('decodePermission', () => { NonceEnforcer, TimestampEnforcer, ]; - const result = identifyPermissionByEnforcers({ enforcers, contracts }); - expect(result).toBe(expectedPermissionType); + const result = findRuleWithMatchingCaveatAddresses({ + enforcers, + permissionRules: createPermissionRulesForContracts(contracts), + }); + expect(result.permissionType).toBe(expectedPermissionType); }); it('rejects when only one AllowedCalldataEnforcer is provided', () => { @@ -392,7 +473,10 @@ describe('decodePermission', () => { NonceEnforcer, ]; expect(() => - identifyPermissionByEnforcers({ enforcers, contracts }), + findRuleWithMatchingCaveatAddresses({ + enforcers, + permissionRules: createPermissionRulesForContracts(contracts), + }), ).toThrow('Unable to identify permission type'); }); @@ -405,7 +489,10 @@ describe('decodePermission', () => { NonceEnforcer, ]; expect(() => - identifyPermissionByEnforcers({ enforcers, contracts }), + findRuleWithMatchingCaveatAddresses({ + enforcers, + permissionRules: createPermissionRulesForContracts(contracts), + }), ).toThrow('Unable to identify permission type'); }); @@ -416,7 +503,10 @@ describe('decodePermission', () => { NonceEnforcer, ]; expect(() => - identifyPermissionByEnforcers({ enforcers, contracts }), + findRuleWithMatchingCaveatAddresses({ + enforcers, + permissionRules: createPermissionRulesForContracts(contracts), + }), ).toThrow('Unable to identify permission type'); }); @@ -430,7 +520,10 @@ describe('decodePermission', () => { ExactCalldataEnforcer, ]; expect(() => - identifyPermissionByEnforcers({ enforcers, contracts }), + findRuleWithMatchingCaveatAddresses({ + enforcers, + permissionRules: createPermissionRulesForContracts(contracts), + }), ).toThrow('Unable to identify permission type'); }); @@ -441,8 +534,11 @@ describe('decodePermission', () => { ValueLteEnforcer.toLowerCase() as unknown as Hex, NonceEnforcer.toLowerCase() as unknown as Hex, ]; - const result = identifyPermissionByEnforcers({ enforcers, contracts }); - expect(result).toBe(expectedPermissionType); + const result = findRuleWithMatchingCaveatAddresses({ + enforcers, + permissionRules: createPermissionRulesForContracts(contracts), + }); + expect(result.permissionType).toBe(expectedPermissionType); }); it('throws if a contract is not found', () => { @@ -458,1068 +554,15 @@ describe('decodePermission', () => { } as unknown as DeployedContractsByName; expect(() => - identifyPermissionByEnforcers({ + findRuleWithMatchingCaveatAddresses({ enforcers, - contracts: contractsWithoutAllowedCalldataEnforcer, + permissionRules: createPermissionRulesForContracts(contractsWithoutAllowedCalldataEnforcer), }), ).toThrow('Contract not found: AllowedCalldataEnforcer'); }); }); }); - describe('getPermissionDataAndExpiry', () => { - const timestampBeforeThreshold = 1720000; - const timestampAfterThreshold = 0; - - const expiryCaveat = { - enforcer: TimestampEnforcer, - terms: createTimestampTerms({ - timestampAfterThreshold, - timestampBeforeThreshold, - }), - args: '0x', - } as const; - - it('throws if an invalid permission type is provided', () => { - const caveats = [expiryCaveat]; - expect(() => { - getPermissionDataAndExpiry({ - contracts, - caveats, - permissionType: - 'invalid-permission-type' as unknown as PermissionType, - }); - }).toThrow('Invalid permission type'); - }); - - describe('native-token-stream', () => { - const permissionType = 'native-token-stream'; - - const initialAmount = 123456n; - const maxAmount = 999999n; - const amountPerSecond = 1n; - const startTime = 1715664; - - it('returns the correct expiry and data', () => { - const caveats = [ - expiryCaveat, - { - enforcer: NativeTokenStreamingEnforcer, - terms: createNativeTokenStreamingTerms( - { - initialAmount, - maxAmount, - amountPerSecond, - startTime, - }, - { out: 'hex' }, - ), - args: '0x', - } as const, - ]; - - const { expiry, data } = getPermissionDataAndExpiry({ - contracts, - caveats, - permissionType, - }); - - expect(expiry).toBe(timestampBeforeThreshold); - expect(hexToBigInt(data.initialAmount)).toBe(initialAmount); - expect(hexToBigInt(data.maxAmount)).toBe(maxAmount); - expect(hexToBigInt(data.amountPerSecond)).toBe(amountPerSecond); - expect(data.startTime).toBe(startTime); - }); - - it('returns null expiry, and correct data if no expiry caveat is provided', () => { - const caveats = [ - { - enforcer: NativeTokenStreamingEnforcer, - terms: createNativeTokenStreamingTerms( - { - initialAmount, - maxAmount, - amountPerSecond, - startTime, - }, - { out: 'hex' }, - ), - args: '0x', - } as const, - ]; - - const { expiry, data } = getPermissionDataAndExpiry({ - contracts, - caveats, - permissionType, - }); - - expect(expiry).toBeNull(); - expect(hexToBigInt(data.initialAmount)).toBe(initialAmount); - expect(hexToBigInt(data.maxAmount)).toBe(maxAmount); - expect(hexToBigInt(data.amountPerSecond)).toBe(amountPerSecond); - expect(data.startTime).toBe(startTime); - }); - - it('rejects invalid expiry with timestampAfterThreshold', () => { - const caveats = [ - { - enforcer: TimestampEnforcer, - terms: createTimestampTerms({ - timestampAfterThreshold: 1, - timestampBeforeThreshold, - }), - args: '0x', - } as const, - { - enforcer: NativeTokenStreamingEnforcer, - terms: createNativeTokenStreamingTerms( - { - initialAmount, - maxAmount, - amountPerSecond, - startTime, - }, - { out: 'hex' }, - ), - args: '0x', - } as const, - ]; - - expect(() => - getPermissionDataAndExpiry({ - contracts, - caveats, - permissionType, - }), - ).toThrow('Invalid expiry: timestampAfterThreshold must be 0'); - }); - - it('rejects invalid nativeTokenStream terms', () => { - const caveats = [ - expiryCaveat, - { - enforcer: NativeTokenStreamingEnforcer, - terms: '0x00', - args: '0x', - } as const, - ]; - - expect(() => - getPermissionDataAndExpiry({ - contracts, - caveats, - permissionType, - }), - ).toThrow('Value must be a hexadecimal string.'); - }); - - it('rejects expiry terms that are too short', () => { - const caveats = [ - { - enforcer: TimestampEnforcer, - terms: '0x1234' as Hex, - args: '0x', - } as const, - { - enforcer: NativeTokenStreamingEnforcer, - terms: createNativeTokenStreamingTerms( - { - initialAmount, - maxAmount, - amountPerSecond, - startTime, - }, - { out: 'hex' }, - ), - args: '0x', - } as const, - ]; - - expect(() => - getPermissionDataAndExpiry({ - contracts, - caveats, - permissionType, - }), - ).toThrow( - 'Invalid TimestampEnforcer terms length: expected 66 characters (0x + 64 hex), got 6', - ); - }); - - it('rejects expiry terms that are too long', () => { - const caveats = [ - { - enforcer: TimestampEnforcer, - terms: `0x${'0'.repeat(68)}` as const, - args: '0x', - } as const, - { - enforcer: NativeTokenStreamingEnforcer, - terms: createNativeTokenStreamingTerms( - { - initialAmount, - maxAmount, - amountPerSecond, - startTime, - }, - { out: 'hex' }, - ), - args: '0x', - } as const, - ]; - - expect(() => - getPermissionDataAndExpiry({ - contracts, - caveats, - permissionType, - }), - ).toThrow( - 'Invalid TimestampEnforcer terms length: expected 66 characters (0x + 64 hex), got 70', - ); - }); - - it('rejects expiry timestamp that is not a safe integer', () => { - // Use maximum uint128 value which exceeds Number.MAX_SAFE_INTEGER - const maxUint128 = 'f'.repeat(32); - const termsHex = `0x${'0'.repeat(32)}${maxUint128}` as Hex; - const caveats = [ - { - enforcer: TimestampEnforcer, - terms: termsHex, - args: '0x', - } as const, - { - enforcer: NativeTokenStreamingEnforcer, - terms: createNativeTokenStreamingTerms( - { - initialAmount, - maxAmount, - amountPerSecond, - startTime, - }, - { out: 'hex' }, - ), - args: '0x', - } as const, - ]; - - expect(() => - getPermissionDataAndExpiry({ - contracts, - caveats, - permissionType, - }), - ).toThrow('Value is not a safe integer'); - }); - - it('handles large valid expiry timestamp', () => { - // Use a large but valid timestamp (year 9999: 253402300799) - const largeTimestamp = 253402300799; - const caveats = [ - { - enforcer: TimestampEnforcer, - terms: createTimestampTerms({ - timestampAfterThreshold: 0, - timestampBeforeThreshold: largeTimestamp, - }), - args: '0x', - } as const, - { - enforcer: NativeTokenStreamingEnforcer, - terms: createNativeTokenStreamingTerms( - { - initialAmount, - maxAmount, - amountPerSecond, - startTime, - }, - { out: 'hex' }, - ), - args: '0x', - } as const, - ]; - - const { expiry, data } = getPermissionDataAndExpiry({ - contracts, - caveats, - permissionType, - }); - - expect(expiry).toBe(largeTimestamp); - expect(hexToBigInt(data.initialAmount)).toBe(initialAmount); - }); - - it('rejects when expiry timestamp is 0', () => { - const caveats = [ - { - enforcer: TimestampEnforcer, - terms: createTimestampTerms({ - timestampAfterThreshold: 0, - timestampBeforeThreshold: 0, - }), - args: '0x', - } as const, - { - enforcer: NativeTokenStreamingEnforcer, - terms: createNativeTokenStreamingTerms( - { - initialAmount, - maxAmount, - amountPerSecond, - startTime, - }, - { out: 'hex' }, - ), - args: '0x', - } as const, - ]; - - expect(() => - getPermissionDataAndExpiry({ - contracts, - caveats, - permissionType, - }), - ).toThrow( - 'Invalid expiry: timestampBeforeThreshold must be greater than 0', - ); - }); - - it('rejects terms with zero initialAmount', () => { - const ZERO_32 = '0'.repeat(64); - const maxHex = maxAmount.toString(16).padStart(64, '0'); - const amountPerSecondHex = amountPerSecond.toString(16).padStart(64, '0'); - const startTimeHex = startTime.toString(16).padStart(64, '0'); - const terms = `0x${ZERO_32}${maxHex}${amountPerSecondHex}${startTimeHex}` as Hex; - const caveats = [ - expiryCaveat, - { - enforcer: NativeTokenStreamingEnforcer, - terms, - args: '0x', - } as const, - ]; - - expect(() => - getPermissionDataAndExpiry({ - contracts, - caveats, - permissionType, - }), - ).toThrow( - 'Invalid native-token-stream terms: initialAmount must be a positive number', - ); - }); - - it('rejects terms with zero maxAmount', () => { - const initialHex = initialAmount.toString(16).padStart(64, '0'); - const ZERO_32 = '0'.repeat(64); - const amountPerSecondHex = amountPerSecond.toString(16).padStart(64, '0'); - const startTimeHex = startTime.toString(16).padStart(64, '0'); - const terms = `0x${initialHex}${ZERO_32}${amountPerSecondHex}${startTimeHex}` as Hex; - const caveats = [ - expiryCaveat, - { - enforcer: NativeTokenStreamingEnforcer, - terms, - args: '0x', - } as const, - ]; - - expect(() => - getPermissionDataAndExpiry({ - contracts, - caveats, - permissionType, - }), - ).toThrow( - 'Invalid native-token-stream terms: maxAmount must be a positive number', - ); - }); - - it('rejects terms with zero amountPerSecond', () => { - const initialHex = initialAmount.toString(16).padStart(64, '0'); - const maxHex = maxAmount.toString(16).padStart(64, '0'); - const ZERO_32 = '0'.repeat(64); - const startTimeHex = startTime.toString(16).padStart(64, '0'); - const terms = `0x${initialHex}${maxHex}${ZERO_32}${startTimeHex}` as Hex; - const caveats = [ - expiryCaveat, - { - enforcer: NativeTokenStreamingEnforcer, - terms, - args: '0x', - } as const, - ]; - - expect(() => - getPermissionDataAndExpiry({ - contracts, - caveats, - permissionType, - }), - ).toThrow( - 'Invalid native-token-stream terms: amountPerSecond must be a positive number', - ); - }); - - it('rejects terms with zero startTime', () => { - const initialHex = initialAmount.toString(16).padStart(64, '0'); - const maxHex = maxAmount.toString(16).padStart(64, '0'); - const amountPerSecondHex = amountPerSecond.toString(16).padStart(64, '0'); - const startTimeZero = '0'.repeat(64); - const terms = `0x${initialHex}${maxHex}${amountPerSecondHex}${startTimeZero}` as Hex; - const caveats = [ - expiryCaveat, - { - enforcer: NativeTokenStreamingEnforcer, - terms, - args: '0x', - } as const, - ]; - - expect(() => - getPermissionDataAndExpiry({ - contracts, - caveats, - permissionType, - }), - ).toThrow( - 'Invalid native-token-stream terms: startTime must be a positive number', - ); - }); - }); - - describe('native-token-periodic', () => { - const permissionType = 'native-token-periodic'; - - const periodAmount = 123456n; - const periodDuration = 3600; - const startDate = 1715664; - - it('returns the correct expiry and data', () => { - const caveats = [ - expiryCaveat, - { - enforcer: NativeTokenPeriodTransferEnforcer, - terms: createNativeTokenPeriodTransferTerms( - { - periodAmount, - periodDuration, - startDate, - }, - { out: 'hex' }, - ), - args: '0x', - } as const, - ]; - - const { expiry, data } = getPermissionDataAndExpiry({ - contracts, - caveats, - permissionType, - }); - - expect(expiry).toBe(timestampBeforeThreshold); - expect(hexToBigInt(data.periodAmount)).toBe(periodAmount); - expect(data.periodDuration).toBe(periodDuration); - expect(data.startTime).toBe(startDate); - }); - - it('returns null expiry, and correct data if no expiry caveat is provided', () => { - const caveats = [ - { - enforcer: NativeTokenPeriodTransferEnforcer, - terms: createNativeTokenPeriodTransferTerms( - { - periodAmount, - periodDuration, - startDate, - }, - { out: 'hex' }, - ), - args: '0x', - } as const, - ]; - - const { expiry, data } = getPermissionDataAndExpiry({ - contracts, - caveats, - permissionType, - }); - - expect(expiry).toBeNull(); - expect(hexToBigInt(data.periodAmount)).toBe(periodAmount); - expect(data.periodDuration).toBe(periodDuration); - expect(data.startTime).toBe(startDate); - }); - - it('rejects invalid expiry with timestampAfterThreshold', () => { - const caveats = [ - { - enforcer: TimestampEnforcer, - terms: createTimestampTerms({ - timestampAfterThreshold: 1, - timestampBeforeThreshold, - }), - args: '0x', - } as const, - { - enforcer: NativeTokenPeriodTransferEnforcer, - terms: createNativeTokenPeriodTransferTerms( - { - periodAmount, - periodDuration, - startDate, - }, - { out: 'hex' }, - ), - args: '0x', - } as const, - ]; - - expect(() => - getPermissionDataAndExpiry({ - contracts, - caveats, - permissionType, - }), - ).toThrow('Invalid expiry: timestampAfterThreshold must be 0'); - }); - - it('rejects invalid nativeTokenPeriodic terms', () => { - const caveats = [ - expiryCaveat, - { - enforcer: NativeTokenPeriodTransferEnforcer, - terms: '0x00', - args: '0x', - } as const, - ]; - - expect(() => - getPermissionDataAndExpiry({ - contracts, - caveats, - permissionType, - }), - ).toThrow('Value must be a hexadecimal string.'); - }); - - it('rejects terms with zero periodDuration', () => { - const periodAmountHex = periodAmount.toString(16).padStart(64, '0'); - const periodDurationZero = '0'.repeat(64); - const startDateHex = startDate.toString(16).padStart(64, '0'); - const terms = `0x${periodAmountHex}${periodDurationZero}${startDateHex}` as Hex; - const caveats = [ - expiryCaveat, - { - enforcer: NativeTokenPeriodTransferEnforcer, - terms, - args: '0x', - } as const, - ]; - - expect(() => - getPermissionDataAndExpiry({ - contracts, - caveats, - permissionType, - }), - ).toThrow( - 'Invalid native-token-periodic terms: periodDuration must be a positive number', - ); - }); - - it('rejects terms with zero startTime', () => { - const periodAmountHex = periodAmount.toString(16).padStart(64, '0'); - const periodDurationHex = periodDuration.toString(16).padStart(64, '0'); - const startTimeZero = '0'.repeat(64); - const terms = `0x${periodAmountHex}${periodDurationHex}${startTimeZero}` as Hex; - const caveats = [ - expiryCaveat, - { - enforcer: NativeTokenPeriodTransferEnforcer, - terms, - args: '0x', - } as const, - ]; - - expect(() => - getPermissionDataAndExpiry({ - contracts, - caveats, - permissionType, - }), - ).toThrow( - 'Invalid native-token-periodic terms: startTime must be a positive number', - ); - }); - }); - - describe('erc20-token-stream', () => { - const permissionType = 'erc20-token-stream'; - - const tokenAddress = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex; - const initialAmount = 555n; - const maxAmount = 999n; - const amountPerSecond = 2n; - const startTime = 1715665; - - it('returns the correct expiry and data', () => { - const caveats = [ - expiryCaveat, - { - enforcer: ERC20StreamingEnforcer, - terms: createERC20StreamingTerms( - { - tokenAddress, - initialAmount, - maxAmount, - amountPerSecond, - startTime, - }, - { out: 'hex' }, - ), - args: '0x', - } as const, - ]; - - const { expiry, data } = getPermissionDataAndExpiry({ - contracts, - caveats, - permissionType, - }); - - expect(expiry).toBe(timestampBeforeThreshold); - expect(data.tokenAddress).toBe(tokenAddress); - expect(hexToBigInt(data.initialAmount)).toBe(initialAmount); - expect(hexToBigInt(data.maxAmount)).toBe(maxAmount); - expect(hexToBigInt(data.amountPerSecond)).toBe(amountPerSecond); - expect(data.startTime).toBe(startTime); - }); - - it('returns null expiry, and correct data if no expiry caveat is provided', () => { - const caveats = [ - { - enforcer: ERC20StreamingEnforcer, - terms: createERC20StreamingTerms( - { - tokenAddress, - initialAmount, - maxAmount, - amountPerSecond, - startTime, - }, - { out: 'hex' }, - ), - args: '0x', - } as const, - ]; - - const { expiry, data } = getPermissionDataAndExpiry({ - contracts, - caveats, - permissionType, - }); - - expect(expiry).toBeNull(); - expect(data.tokenAddress).toBe(tokenAddress); - expect(hexToBigInt(data.initialAmount)).toBe(initialAmount); - expect(hexToBigInt(data.maxAmount)).toBe(maxAmount); - expect(hexToBigInt(data.amountPerSecond)).toBe(amountPerSecond); - expect(data.startTime).toBe(startTime); - }); - - it('rejects invalid expiry with timestampAfterThreshold', () => { - const caveats = [ - { - enforcer: TimestampEnforcer, - terms: createTimestampTerms({ - timestampAfterThreshold: 1, - timestampBeforeThreshold, - }), - args: '0x', - } as const, - { - enforcer: ERC20StreamingEnforcer, - terms: createERC20StreamingTerms( - { - tokenAddress, - initialAmount, - maxAmount, - amountPerSecond, - startTime, - }, - { out: 'hex' }, - ), - args: '0x', - } as const, - ]; - - expect(() => - getPermissionDataAndExpiry({ - contracts, - caveats, - permissionType, - }), - ).toThrow('Invalid expiry: timestampAfterThreshold must be 0'); - }); - - it('rejects invalid erc20-token-stream terms', () => { - const caveats = [ - expiryCaveat, - { - enforcer: ERC20StreamingEnforcer, - terms: '0x00', - args: '0x', - } as const, - ]; - - expect(() => - getPermissionDataAndExpiry({ - contracts, - caveats, - permissionType, - }), - ).toThrow('Value must be a hexadecimal string.'); - }); - - it('rejects terms when maxAmount is less than initialAmount', () => { - const tokenHex = tokenAddress.slice(2); - const initialAmountHex = (1000n).toString(16).padStart(64, '0'); - const maxAmountHex = (100n).toString(16).padStart(64, '0'); - const amountPerSecondHex = amountPerSecond.toString(16).padStart(64, '0'); - const startTimeHex = startTime.toString(16).padStart(64, '0'); - const terms = `0x${tokenHex}${initialAmountHex}${maxAmountHex}${amountPerSecondHex}${startTimeHex}` as Hex; - const caveats = [ - expiryCaveat, - { - enforcer: ERC20StreamingEnforcer, - terms, - args: '0x', - } as const, - ]; - - expect(() => - getPermissionDataAndExpiry({ - contracts, - caveats, - permissionType, - }), - ).toThrow( - 'Invalid erc20-token-stream terms: maxAmount must be greater than initialAmount', - ); - }); - - it('rejects terms with zero startTime', () => { - const tokenHex = tokenAddress.slice(2); - const initialAmountHex = initialAmount.toString(16).padStart(64, '0'); - const maxAmountHex = maxAmount.toString(16).padStart(64, '0'); - const amountPerSecondHex = amountPerSecond.toString(16).padStart(64, '0'); - const startTimeZero = '0'.repeat(64); - const terms = `0x${tokenHex}${initialAmountHex}${maxAmountHex}${amountPerSecondHex}${startTimeZero}` as Hex; - const caveats = [ - expiryCaveat, - { - enforcer: ERC20StreamingEnforcer, - terms, - args: '0x', - } as const, - ]; - - expect(() => - getPermissionDataAndExpiry({ - contracts, - caveats, - permissionType, - }), - ).toThrow( - 'Invalid erc20-token-stream terms: startTime must be a positive number', - ); - }); - }); - - describe('erc20-token-periodic', () => { - const permissionType = 'erc20-token-periodic'; - - const tokenAddress = '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Hex; - const periodAmount = 123n; - const periodDuration = 86400; - const startDate = 1715666; - - it('returns the correct expiry and data', () => { - const caveats = [ - expiryCaveat, - { - enforcer: ERC20PeriodTransferEnforcer, - terms: createERC20TokenPeriodTransferTerms( - { - tokenAddress, - periodAmount, - periodDuration, - startDate, - }, - { out: 'hex' }, - ), - args: '0x', - } as const, - ]; - - const { expiry, data } = getPermissionDataAndExpiry({ - contracts, - caveats, - permissionType, - }); - - expect(expiry).toBe(timestampBeforeThreshold); - expect(data.tokenAddress).toBe(tokenAddress); - expect(hexToBigInt(data.periodAmount)).toBe(periodAmount); - expect(data.periodDuration).toBe(periodDuration); - expect(data.startTime).toBe(startDate); - }); - - it('returns null expiry, and correct data if no expiry caveat is provided', () => { - const caveats = [ - { - enforcer: ERC20PeriodTransferEnforcer, - terms: createERC20TokenPeriodTransferTerms( - { - tokenAddress, - periodAmount, - periodDuration, - startDate, - }, - { out: 'hex' }, - ), - args: '0x', - } as const, - ]; - - const { expiry, data } = getPermissionDataAndExpiry({ - contracts, - caveats, - permissionType, - }); - - expect(expiry).toBeNull(); - expect(data.tokenAddress).toBe(tokenAddress); - expect(hexToBigInt(data.periodAmount)).toBe(periodAmount); - expect(data.periodDuration).toBe(periodDuration); - expect(data.startTime).toBe(startDate); - }); - - it('rejects invalid expiry with timestampAfterThreshold', () => { - const caveats = [ - { - enforcer: TimestampEnforcer, - terms: createTimestampTerms({ - timestampAfterThreshold: 1, - timestampBeforeThreshold, - }), - args: '0x', - } as const, - { - enforcer: ERC20PeriodTransferEnforcer, - terms: createERC20TokenPeriodTransferTerms( - { - tokenAddress, - periodAmount, - periodDuration, - startDate, - }, - { out: 'hex' }, - ), - args: '0x', - } as const, - ]; - - expect(() => - getPermissionDataAndExpiry({ - contracts, - caveats, - permissionType, - }), - ).toThrow('Invalid expiry: timestampAfterThreshold must be 0'); - }); - - it('rejects invalid erc20-token-periodic terms', () => { - const caveats = [ - expiryCaveat, - { - enforcer: ERC20PeriodTransferEnforcer, - terms: '0x00', - args: '0x', - } as const, - ]; - - expect(() => - getPermissionDataAndExpiry({ - contracts, - caveats, - permissionType, - }), - ).toThrow('Value must be a hexadecimal string.'); - }); - - it('rejects terms with zero periodDuration', () => { - const tokenHex = tokenAddress.slice(2); - const periodAmountHex = periodAmount.toString(16).padStart(64, '0'); - const periodDurationZero = '0'.repeat(64); - const startDateHex = startDate.toString(16).padStart(64, '0'); - const terms = `0x${tokenHex}${periodAmountHex}${periodDurationZero}${startDateHex}` as Hex; - const caveats = [ - expiryCaveat, - { - enforcer: ERC20PeriodTransferEnforcer, - terms, - args: '0x', - } as const, - ]; - - expect(() => - getPermissionDataAndExpiry({ - contracts, - caveats, - permissionType, - }), - ).toThrow( - 'Invalid erc20-token-periodic terms: periodDuration must be a positive number', - ); - }); - - it('rejects terms with zero startTime', () => { - const tokenHex = tokenAddress.slice(2); - const periodAmountHex = periodAmount.toString(16).padStart(64, '0'); - const periodDurationHex = periodDuration.toString(16).padStart(64, '0'); - const startTimeZero = '0'.repeat(64); - const terms = `0x${tokenHex}${periodAmountHex}${periodDurationHex}${startTimeZero}` as Hex; - const caveats = [ - expiryCaveat, - { - enforcer: ERC20PeriodTransferEnforcer, - terms, - args: '0x', - } as const, - ]; - - expect(() => - getPermissionDataAndExpiry({ - contracts, - caveats, - permissionType, - }), - ).toThrow( - 'Invalid erc20-token-periodic terms: startTime must be a positive number', - ); - }); - }); - - describe('erc20-token-revocation', () => { - const permissionType = 'erc20-token-revocation'; - const approveSelectorTerms = - '0x0000000000000000000000000000000000000000000000000000000000000000095ea7b3' as Hex; - const zeroAmountTerms = - '0x00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000' as Hex; - const zeroValueLteTerms = - '0x0000000000000000000000000000000000000000000000000000000000000000' as Hex; - - it('returns the correct expiry and data', () => { - 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 { expiry, data } = getPermissionDataAndExpiry({ - contracts, - caveats, - permissionType, - }); - - expect(expiry).toStrictEqual(timestampBeforeThreshold); - expect(data).toStrictEqual({}); - }); - - it('rejects invalid allowed calldata terms', () => { - const caveats = [ - expiryCaveat, - { - enforcer: AllowedCalldataEnforcer, - terms: - '0x0000000000000000000000000000000000000000000000000000000000000000deadbeef' as Hex, - args: '0x', - } as const, - { - enforcer: AllowedCalldataEnforcer, - terms: zeroAmountTerms, - args: '0x', - } as const, - { - enforcer: ValueLteEnforcer, - terms: zeroValueLteTerms, - args: '0x', - } as const, - ]; - - expect(() => - getPermissionDataAndExpiry({ - contracts, - caveats, - permissionType, - }), - ).toThrow( - 'Invalid erc20-token-revocation terms: expected approve selector and zero amount constraints', - ); - }); - - it('rejects non-zero valueLte terms', () => { - const caveats = [ - expiryCaveat, - { - enforcer: AllowedCalldataEnforcer, - terms: approveSelectorTerms, - args: '0x', - } as const, - { - enforcer: AllowedCalldataEnforcer, - terms: zeroAmountTerms, - args: '0x', - } as const, - { - enforcer: ValueLteEnforcer, - terms: - '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex, - args: '0x', - } as const, - ]; - - expect(() => - getPermissionDataAndExpiry({ - contracts, - caveats, - permissionType, - }), - ).toThrow('Invalid ValueLteEnforcer terms: maxValue must be 0'); - }); - }); - }); - describe('reconstructDecodedPermission', () => { const delegator = '0x1111111111111111111111111111111111111111' as Hex; const delegate = '0x2222222222222222222222222222222222222222' as Hex; @@ -1613,19 +656,22 @@ describe('decodePermission', () => { }); describe('adversarial: attempts to violate decoder expectations', () => { - describe('identifyPermissionByEnforcers()', () => { + describe('getPermissionRuleMatchingCaveatTypes()', () => { it('rejects empty enforcer list', () => { expect(() => - identifyPermissionByEnforcers({ enforcers: [], contracts }), + findRuleWithMatchingCaveatAddresses({ + enforcers: [], + permissionRules: createPermissionRulesForContracts(contracts), + }), ).toThrow('Unable to identify permission type'); }); it('rejects enforcer list with only unknown/forbidden addresses', () => { const unknown = '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef' as Hex; expect(() => - identifyPermissionByEnforcers({ + findRuleWithMatchingCaveatAddresses({ enforcers: [unknown], - contracts, + permissionRules: createPermissionRulesForContracts(contracts), }), ).toThrow('Unable to identify permission type'); }); @@ -1638,7 +684,10 @@ describe('decodePermission', () => { NonceEnforcer, ]; expect(() => - identifyPermissionByEnforcers({ enforcers, contracts }), + findRuleWithMatchingCaveatAddresses({ + enforcers, + permissionRules: createPermissionRulesForContracts(contracts), + }), ).toThrow('Unable to identify permission type'); }); @@ -1652,7 +701,10 @@ describe('decodePermission', () => { unknownEnforcer, ]; expect(() => - identifyPermissionByEnforcers({ enforcers, contracts }), + findRuleWithMatchingCaveatAddresses({ + enforcers, + permissionRules: createPermissionRulesForContracts(contracts), + }), ).toThrow('Unable to identify permission type'); }); @@ -1663,7 +715,10 @@ describe('decodePermission', () => { NonceEnforcer, ]; expect(() => - identifyPermissionByEnforcers({ enforcers, contracts }), + findRuleWithMatchingCaveatAddresses({ + enforcers, + permissionRules: createPermissionRulesForContracts(contracts), + }), ).toThrow('Unable to identify permission type'); }); @@ -1676,365 +731,11 @@ describe('decodePermission', () => { NonceEnforcer, ]; expect(() => - identifyPermissionByEnforcers({ enforcers, contracts }), - ).toThrow('Unable to identify permission type'); - }); - }); - - describe('getPermissionDataAndExpiry()', () => { - const timestampBeforeThreshold = 1720000; - const expiryCaveat = { - enforcer: TimestampEnforcer, - terms: createTimestampTerms({ - timestampAfterThreshold: 0, - timestampBeforeThreshold, + findRuleWithMatchingCaveatAddresses({ + enforcers, + permissionRules: createPermissionRulesForContracts(contracts), }), - args: '0x', - } as const; - - it('rejects duplicate caveats for same enforcer (e.g. two TimestampEnforcer)', () => { - const caveats = [ - expiryCaveat, - { - enforcer: TimestampEnforcer, - terms: createTimestampTerms({ - timestampAfterThreshold: 0, - timestampBeforeThreshold: 9999, - }), - args: '0x', - } as const, - { - enforcer: NativeTokenStreamingEnforcer, - terms: createNativeTokenStreamingTerms( - { - initialAmount: 1n, - maxAmount: 2n, - amountPerSecond: 1n, - startTime: 1715664, - }, - { out: 'hex' }, - ), - args: '0x', - } as const, - ]; - - expect(() => - getPermissionDataAndExpiry({ - contracts, - caveats, - permissionType: 'native-token-stream', - }), - ).toThrow('Invalid caveats'); - }); - - it('rejects duplicate permission-type enforcer caveats (e.g. two ERC20StreamingEnforcer)', () => { - const tokenAddress = - '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex; - const terms = createERC20StreamingTerms( - { - tokenAddress, - initialAmount: 1n, - maxAmount: 2n, - amountPerSecond: 1n, - startTime: 1715664, - }, - { out: 'hex' }, - ); - const caveats = [ - expiryCaveat, - { enforcer: ERC20StreamingEnforcer, terms, args: '0x' as const }, - { enforcer: ERC20StreamingEnforcer, terms, args: '0x' as const }, - ]; - - expect(() => - getPermissionDataAndExpiry({ - contracts, - caveats, - permissionType: 'erc20-token-stream', - }), - ).toThrow('Invalid caveats'); - }); - - it('rejects TimestampEnforcer terms with non-hex characters', () => { - const invalidTerms = - '0x00000000000000000000000000000000zz000000000000000000000000001a3b80' as Hex; - const caveats = [ - { - 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, - ]; - - expect(() => - getPermissionDataAndExpiry({ - contracts, - caveats, - permissionType: 'native-token-stream', - }), - ).toThrow(); - }); - - it('rejects permission-type terms shorter than expected (truncated payload)', () => { - // ERC20 stream expects [20, 32, 32, 32, 32] bytes = 148 bytes = 296 hex chars. - // Provide only 100 hex chars so last segments are truncated; hexToNumber may throw or mis-parse. - const truncatedTerms = `0x${'a'.repeat(100)}` as Hex; - const caveats = [ - expiryCaveat, - { - enforcer: ERC20StreamingEnforcer, - terms: truncatedTerms, - args: '0x', - } as const, - ]; - - expect(() => - getPermissionDataAndExpiry({ - contracts, - caveats, - permissionType: 'erc20-token-stream', - }), - ).toThrow(); - }); - - it('rejects native-token-stream terms shorter than expected', () => { - const truncatedTerms = `0x${'00'.repeat(50)}` as Hex; // 50 bytes, need 128 - const caveats = [ - expiryCaveat, - { - enforcer: NativeTokenStreamingEnforcer, - terms: truncatedTerms, - args: '0x', - } as const, - ]; - - expect(() => - getPermissionDataAndExpiry({ - contracts, - caveats, - permissionType: 'native-token-stream', - }), - ).toThrow(); - }); - - it('rejects erc20-token-revocation with only approve selector (missing zero-amount constraint)', () => { - const approveSelectorTerms = - '0x0000000000000000000000000000000000000000000000000000000000000000095ea7b3' as Hex; - const zeroValueLteTerms = - '0x0000000000000000000000000000000000000000000000000000000000000000' as Hex; - 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, - ]; - - expect(() => - getPermissionDataAndExpiry({ - contracts, - caveats, - permissionType: 'erc20-token-revocation', - }), - ).toThrow( - 'Invalid erc20-token-revocation terms: expected approve selector and zero amount constraints', - ); - }); - - it('rejects erc20-token-revocation with only zero-amount constraint (missing approve selector)', () => { - const zeroAmountTerms = - '0x00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000' as Hex; - const zeroValueLteTerms = - '0x0000000000000000000000000000000000000000000000000000000000000000' as Hex; - 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, - ]; - - expect(() => - getPermissionDataAndExpiry({ - contracts, - caveats, - permissionType: 'erc20-token-revocation', - }), - ).toThrow( - 'Invalid erc20-token-revocation terms: expected approve selector and zero amount constraints', - ); - }); - - it('rejects erc20-token-revocation when ValueLteEnforcer terms are non-zero', () => { - const approveSelectorTerms = - '0x0000000000000000000000000000000000000000000000000000000000000000095ea7b3' as Hex; - const zeroAmountTerms = - '0x00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000' as Hex; - const nonZeroValueLteTerms = - '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex; - 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, - ]; - - expect(() => - getPermissionDataAndExpiry({ - contracts, - caveats, - permissionType: 'erc20-token-revocation', - }), - ).toThrow('Invalid ValueLteEnforcer terms: maxValue must be 0'); - }); - - it('rejects TimestampEnforcer terms with wrong length (66 required)', () => { - const badLengthTerms = `0x${'0'.repeat(65)}` as Hex; // 65 hex chars after 0x - const caveats = [ - { - 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, - ]; - - expect(() => - getPermissionDataAndExpiry({ - contracts, - caveats, - permissionType: 'native-token-stream', - }), - ).toThrow('Invalid TimestampEnforcer terms length'); - }); - - it('rejects expiry timestampBeforeThreshold zero', () => { - const caveats = [ - { - enforcer: TimestampEnforcer, - terms: createTimestampTerms({ - timestampAfterThreshold: 0, - timestampBeforeThreshold: 0, - }), - args: '0x', - } as const, - { - enforcer: NativeTokenStreamingEnforcer, - terms: createNativeTokenStreamingTerms( - { - initialAmount: 1n, - maxAmount: 2n, - amountPerSecond: 1n, - startTime: 1715664, - }, - { out: 'hex' }, - ), - args: '0x', - } as const, - ]; - - expect(() => - getPermissionDataAndExpiry({ - contracts, - caveats, - permissionType: 'native-token-stream', - }), - ).toThrow( - 'Invalid expiry: timestampBeforeThreshold must be greater than 0', - ); - }); - - it('rejects expiry timestampAfterThreshold non-zero', () => { - const caveats = [ - { - enforcer: TimestampEnforcer, - terms: createTimestampTerms({ - timestampAfterThreshold: 1, - timestampBeforeThreshold: 1720000, - }), - args: '0x', - } as const, - { - enforcer: NativeTokenStreamingEnforcer, - terms: createNativeTokenStreamingTerms( - { - initialAmount: 1n, - maxAmount: 2n, - amountPerSecond: 1n, - startTime: 1715664, - }, - { out: 'hex' }, - ), - args: '0x', - } as const, - ]; - - expect(() => - getPermissionDataAndExpiry({ - contracts, - caveats, - permissionType: 'native-token-stream', - }), - ).toThrow('Invalid expiry: timestampAfterThreshold must be 0'); + ).toThrow('Unable to identify permission type'); }); }); @@ -2087,190 +788,4 @@ describe('decodePermission', () => { }); }); }); - - describe('adversarial: intent violations — decoder accepts inputs that may not meet semantic expectations', () => { - const expiryCaveat = { - enforcer: TimestampEnforcer, - terms: createTimestampTerms({ - timestampAfterThreshold: 0, - timestampBeforeThreshold: 1720000, - }), - args: '0x', - } as const; - - it('successfully decodes erc20-token-stream with zero token address (no validation that token is non-zero)', () => { - const zeroAddress = - '0x0000000000000000000000000000000000000000' as Hex; - const caveats = [ - expiryCaveat, - { - enforcer: ERC20StreamingEnforcer, - terms: createERC20StreamingTerms( - { - tokenAddress: zeroAddress, - initialAmount: 1n, - maxAmount: 2n, - amountPerSecond: 1n, - startTime: 1715664, - }, - { out: 'hex' }, - ), - args: '0x', - } as const, - ]; - - const { expiry, data } = getPermissionDataAndExpiry({ - contracts, - caveats, - permissionType: 'erc20-token-stream', - }); - - expect(expiry).toBe(1720000); - expect(data.tokenAddress).toBe(zeroAddress); - }); - - 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, - { - enforcer: NativeTokenStreamingEnforcer, - terms, - args: '0x', - } as const, - ]; - - expect(() => - getPermissionDataAndExpiry({ - contracts, - caveats, - permissionType: 'native-token-stream', - }), - ).toThrow( - 'Invalid native-token-stream terms: initialAmount must be a positive number', - ); - }); - - it('rejects erc20-token-periodic with periodDuration 0 (validates duration is positive)', () => { - 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, - { - enforcer: ERC20PeriodTransferEnforcer, - terms, - args: '0x', - } as const, - ]; - - expect(() => - getPermissionDataAndExpiry({ - contracts, - caveats, - permissionType: 'erc20-token-periodic', - }), - ).toThrow( - 'Invalid erc20-token-periodic terms: periodDuration must be a positive number', - ); - }); - - it('rejects erc20-token-stream when initialAmount exceeds maxAmount (validates maxAmount >= initialAmount)', () => { - 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, - { - enforcer: ERC20StreamingEnforcer, - terms, - args: '0x', - } as const, - ]; - - expect(() => - getPermissionDataAndExpiry({ - contracts, - caveats, - permissionType: 'erc20-token-stream', - }), - ).toThrow( - 'Invalid erc20-token-stream terms: maxAmount must be greater than initialAmount', - ); - }); - - it('successfully decodes when terms are longer than expected format (trailing bytes ignored; no validation of total terms length)', () => { - const tokenAddress = - '0xcccccccccccccccccccccccccccccccccccccccc' as Hex; - const validTerms = createERC20StreamingTerms( - { - tokenAddress, - initialAmount: 42n, - maxAmount: 100n, - amountPerSecond: 1n, - startTime: 1715664, - }, - { out: 'hex' }, - ); - const termsWithTrailingGarbage = `${validTerms}deadbeef` as Hex; - - const caveats = [ - expiryCaveat, - { - enforcer: ERC20StreamingEnforcer, - terms: termsWithTrailingGarbage, - args: '0x', - } as const, - ]; - - const { data } = getPermissionDataAndExpiry({ - contracts, - caveats, - permissionType: 'erc20-token-stream', - }); - - expect(data.tokenAddress).toBe(tokenAddress); - expect(hexToBigInt(data.initialAmount)).toBe(42n); - expect(hexToBigInt(data.maxAmount)).toBe(100n); - expect(data.startTime).toBe(1715664); - }); - - 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, - { - enforcer: NativeTokenStreamingEnforcer, - terms, - args: '0x', - } as const, - ]; - - expect(() => - getPermissionDataAndExpiry({ - contracts, - caveats, - permissionType: 'native-token-stream', - }), - ).toThrow( - 'Invalid native-token-stream terms: startTime must be a positive number', - ); - }); - }); }); diff --git a/packages/gator-permissions-controller/src/decodePermission/decodePermission.ts b/packages/gator-permissions-controller/src/decodePermission/decodePermission.ts index ade5a2669fc..60ec78e5043 100644 --- a/packages/gator-permissions-controller/src/decodePermission/decodePermission.ts +++ b/packages/gator-permissions-controller/src/decodePermission/decodePermission.ts @@ -1,405 +1,61 @@ -import type { Caveat, Hex } from '@metamask/delegation-core'; +import type { Hex } from '@metamask/delegation-core'; import { ROOT_AUTHORITY } from '@metamask/delegation-core'; -import { - getChecksumAddress, - hexToBigInt, - hexToNumber, - numberToHex, -} from '@metamask/utils'; +import { numberToHex } from '@metamask/utils'; import type { DecodedPermission, - DeployedContractsByName, PermissionType, } from './types'; -import { - createPermissionRulesForChainId, - getChecksumEnforcersByChainId, - getTermsByEnforcer, - splitHex, -} from './utils'; +import type { PermissionRule } from './types'; + +/* + * Decoding can be driven entirely by permission rules: + * + * const permissionRules = createPermissionRulesForChainId(contracts); + * const matchingRules = permissionRules.filter(rule => rule.caveatAddressesMatch(caveatAddresses)); + * // Expect exactly one match for current rule set; then: + * const result = matchingRules[0].validateAndDecodePermission(caveats); + * if (result.isValid) { ... result.expiry, result.data ... } + * + * getPermissionRuleMatchingCaveatTypes and getPermissionDataAndExpiry use these rules + * internally and preserve the existing throw-on-failure API. + */ /** - * Identifies the unique permission type that matches a given set of enforcer - * contract addresses for a specific chain. + * Returns the unique permission rule that matches a given set of enforcer + * contract addresses (caveat types) for a specific chain. * - * A permission type matches when: + * A rule matches when: * - All of its required enforcers are present in the provided list; and - * - No provided enforcer falls outside the union of the type's required and + * - No provided enforcer falls outside the union of the rule's required and * optional enforcers (currently only `TimestampEnforcer` is allowed extra). * - * If exactly one permission type matches, its identifier is returned. + * If exactly one rule matches, it is returned. * * @param args - The arguments to this function. * @param args.enforcers - List of enforcer contract addresses (hex strings). - * - * @param args.contracts - The deployed contracts for the chain. - * @returns The identifier of the matching permission type. - * @throws If no permission type matches, or if more than one permission type matches. + * @param args.permissionRules - The permission rules for the chain. + * @returns The matching permission rule. + * @throws If no rule matches, or if more than one rule matches. */ -export const identifyPermissionByEnforcers = ({ +export const findRuleWithMatchingCaveatAddresses = ({ enforcers, - contracts, + permissionRules, }: { enforcers: Hex[]; - contracts: DeployedContractsByName; -}): PermissionType => { - // Build frequency map for enforcers (using checksummed addresses) - const counts = new Map(); - for (const addr of enforcers.map(getChecksumAddress)) { - counts.set(addr, (counts.get(addr) ?? 0) + 1); - } - const enforcersSet = new Set(counts.keys()); - - const permissionRules = createPermissionRulesForChainId(contracts); - - let matchingPermissionType: PermissionType | null = null; - - for (const { - optionalEnforcers, - requiredEnforcers, - permissionType, - } of permissionRules) { - // union of optional + required enforcers. Any other address is forbidden. - const allowedEnforcers = new Set([ - ...optionalEnforcers, - ...requiredEnforcers.keys(), - ]); - - let hasForbiddenEnforcers = false; - - for (const caveat of enforcersSet) { - if (!allowedEnforcers.has(caveat)) { - hasForbiddenEnforcers = true; - break; - } - } + permissionRules: PermissionRule[]; +}): PermissionRule => { + const matchingRules = permissionRules.filter((rule) => + rule.caveatAddressesMatch(enforcers), + ); - // exact multiplicity match for required enforcers - let meetsRequiredCounts = true; - for (const [addr, requiredCount] of requiredEnforcers.entries()) { - if ((counts.get(addr) ?? 0) !== requiredCount) { - meetsRequiredCounts = false; - break; - } - } - - if (meetsRequiredCounts && !hasForbiddenEnforcers) { - if (matchingPermissionType) { - throw new Error('Multiple permission types match'); - } - matchingPermissionType = permissionType; - } - } - - if (!matchingPermissionType) { + if (matchingRules.length === 0) { throw new Error('Unable to identify permission type'); } - - return matchingPermissionType; -}; - -/** - * Extracts the expiry timestamp from TimestampEnforcer caveat terms. - * - * Based on the TimestampEnforcer contract encoding: - * - Terms are 32 bytes total (64 hex characters without '0x') - * - First 16 bytes (32 hex chars): timestampAfterThreshold (uint128) - must be 0 - * - Last 16 bytes (32 hex chars): timestampBeforeThreshold (uint128) - this is the expiry - * - * @param terms - The hex-encoded terms from a TimestampEnforcer caveat - * @returns The expiry timestamp in seconds - * @throws If the terms are not exactly 32 bytes, if the timestampAfterThreshold is non-zero, - * or if the timestampBeforeThreshold is zero - */ -const extractExpiryFromCaveatTerms = (terms: Hex): number => { - // Validate terms length: must be exactly 32 bytes (64 hex chars + '0x' prefix = 66 chars) - if (terms.length !== 66) { - throw new Error( - `Invalid TimestampEnforcer terms length: expected 66 characters (0x + 64 hex), got ${terms.length}`, - ); - } - - const [after, before] = splitHex(terms, [16, 16]); - - if (hexToNumber(after) !== 0) { - throw new Error('Invalid expiry: timestampAfterThreshold must be 0'); - } - - const expiry = hexToNumber(before); - - if (expiry === 0) { - throw new Error( - 'Invalid expiry: timestampBeforeThreshold must be greater than 0', - ); + if (matchingRules.length > 1) { + throw new Error('Multiple permission types match'); } - - return expiry; -}; - -/** - * Extracts the permission-specific data payload and the expiry timestamp from - * the provided caveats for a given permission type. - * - * This function locates the relevant caveat enforcer for the `permissionType`, - * interprets its `terms` by splitting the hex string into byte-sized segments, - * and converts each segment into the appropriate numeric or address shape. - * - * The expiry timestamp is derived from the `TimestampEnforcer` terms and must - * have a zero `timestampAfterThreshold` and a positive `timestampBeforeThreshold`. - * - * @param args - The arguments to this function. - * @param args.contracts - The deployed contracts for the chain. - * @param args.caveats - Caveats decoded from the permission context. - * @param args.permissionType - The previously identified permission type. - * - * @returns An object containing the `expiry` timestamp and the decoded `data` payload. - * @throws If the caveats are malformed, missing, or the terms fail to decode. - */ -export const getPermissionDataAndExpiry = ({ - contracts, - caveats, - permissionType, -}: { - contracts: DeployedContractsByName; - caveats: Caveat[]; - permissionType: PermissionType; -}): { - expiry: number | null; - data: DecodedPermission['permission']['data']; -} => { - const checksumCaveats = caveats.map((caveat) => ({ - ...caveat, - enforcer: getChecksumAddress(caveat.enforcer), - })); - - const { - erc20StreamingEnforcer, - erc20PeriodicEnforcer, - nativeTokenStreamingEnforcer, - nativeTokenPeriodicEnforcer, - timestampEnforcer, - allowedCalldataEnforcer, - valueLteEnforcer, - } = getChecksumEnforcersByChainId(contracts); - - const expiryTerms = getTermsByEnforcer({ - caveats: checksumCaveats, - enforcer: timestampEnforcer, - throwIfNotFound: false, - }); - - let expiry: number | null = null; - if (expiryTerms) { - expiry = extractExpiryFromCaveatTerms(expiryTerms); - } - - let data: DecodedPermission['permission']['data']; - - switch (permissionType) { - case 'erc20-token-stream': { - const erc20StreamingTerms = getTermsByEnforcer({ - caveats: checksumCaveats, - enforcer: erc20StreamingEnforcer, - }); - - const [ - tokenAddress, - initialAmount, - maxAmount, - amountPerSecond, - startTimeRaw, - ] = splitHex(erc20StreamingTerms, [20, 32, 32, 32, 32]); - - const startTime = hexToNumber(startTimeRaw); - const initialAmountBigInt = hexToBigInt(initialAmount); - const maxAmountBigInt = hexToBigInt(maxAmount); - - if (maxAmountBigInt < initialAmountBigInt) { - throw new Error( - 'Invalid erc20-token-stream terms: maxAmount must be greater than initialAmount', - ); - } - - if (startTime <= 0) { - throw new Error( - 'Invalid erc20-token-stream terms: startTime must be a positive number', - ); - } - - data = { - tokenAddress, - initialAmount, - maxAmount, - amountPerSecond, - startTime, - }; - break; - } - case 'erc20-token-periodic': { - const erc20PeriodicTerms = getTermsByEnforcer({ - caveats: checksumCaveats, - enforcer: erc20PeriodicEnforcer, - }); - - const [tokenAddress, periodAmount, periodDurationRaw, startTimeRaw] = - splitHex(erc20PeriodicTerms, [20, 32, 32, 32]); - - const periodDuration = hexToNumber(periodDurationRaw); - const startTime = hexToNumber(startTimeRaw); - - if (periodDuration <= 0) { - throw new Error( - 'Invalid erc20-token-periodic terms: periodDuration must be a positive number', - ); - } - - if (startTime <= 0) { - throw new Error( - 'Invalid erc20-token-periodic terms: startTime must be a positive number', - ); - } - - data = { - tokenAddress, - periodAmount, - periodDuration, - startTime, - }; - break; - } - - case 'native-token-stream': { - const nativeTokenStreamingTerms = getTermsByEnforcer({ - caveats: checksumCaveats, - enforcer: nativeTokenStreamingEnforcer, - }); - - const [initialAmount, maxAmount, amountPerSecond, startTimeRaw] = - splitHex(nativeTokenStreamingTerms, [32, 32, 32, 32]); - - const initialAmountBigInt = hexToBigInt(initialAmount); - const maxAmountBigInt = hexToBigInt(maxAmount); - const amountPerSecondBigInt = hexToBigInt(amountPerSecond); - const startTime = hexToNumber(startTimeRaw); - - if (initialAmountBigInt <= 0n) { - throw new Error( - 'Invalid native-token-stream terms: initialAmount must be a positive number', - ); - } - - if (maxAmountBigInt <= 0n) { - throw new Error( - 'Invalid native-token-stream terms: maxAmount must be a positive number', - ); - } - - 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', - ); - } - - data = { - initialAmount, - maxAmount, - amountPerSecond, - startTime, - }; - break; - } - case 'native-token-periodic': { - const nativeTokenPeriodicTerms = getTermsByEnforcer({ - caveats: checksumCaveats, - enforcer: nativeTokenPeriodicEnforcer, - }); - - const [periodAmount, periodDurationRaw, startTimeRaw] = splitHex( - nativeTokenPeriodicTerms, - [32, 32, 32], - ); - - const periodDuration = hexToNumber(periodDurationRaw); - const startTime = hexToNumber(startTimeRaw); - - if (periodDuration <= 0) { - throw new Error( - 'Invalid native-token-periodic terms: periodDuration must be a positive number', - ); - } - - if (startTime <= 0) { - throw new Error( - 'Invalid native-token-periodic terms: startTime must be a positive number', - ); - } - - data = { - periodAmount, - periodDuration, - startTime, - }; - break; - } - case 'erc20-token-revocation': { - // 0 value for ValueLteEnforcer - const ZERO_32_BYTES = - '0x0000000000000000000000000000000000000000000000000000000000000000' as const; - - // Approve() 4byte selector starting at index 0 - const ERC20_APPROVE_SELECTOR_TERMS = - '0x0000000000000000000000000000000000000000000000000000000000000000095ea7b3' as const; - - // 0 amount starting at index 24 - const ERC20_APPROVE_ZERO_AMOUNT_TERMS = - '0x00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000' as const; - - const allowedCalldataCaveats = checksumCaveats.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: checksumCaveats, - enforcer: valueLteEnforcer, - }); - - if (valueLteTerms !== ZERO_32_BYTES) { - throw new Error('Invalid ValueLteEnforcer terms: maxValue must be 0'); - } - - data = {}; - break; - } - default: - throw new Error('Invalid permission type'); - } - - return { expiry, data }; + return matchingRules[0]; }; /** diff --git a/packages/gator-permissions-controller/src/decodePermission/index.ts b/packages/gator-permissions-controller/src/decodePermission/index.ts index 432d1973162..4b60d0722eb 100644 --- a/packages/gator-permissions-controller/src/decodePermission/index.ts +++ b/packages/gator-permissions-controller/src/decodePermission/index.ts @@ -1,7 +1,12 @@ export { - identifyPermissionByEnforcers, - getPermissionDataAndExpiry, + findRuleWithMatchingCaveatAddresses, reconstructDecodedPermission, } from './decodePermission'; +export { createPermissionRulesForContracts } from './rules'; -export type { DecodedPermission } from './types'; +export type { + DecodedPermission, + PermissionRule, + ValidateAndDecodeResult, + ValidateDecodedPermission, +} from './types'; diff --git a/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenPeriodic.test.ts b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenPeriodic.test.ts new file mode 100644 index 00000000000..c5e5a7fa489 --- /dev/null +++ b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenPeriodic.test.ts @@ -0,0 +1,212 @@ +import { + createERC20TokenPeriodTransferTerms, + createTimestampTerms, +} from '@metamask/delegation-core'; +import type { Hex } from '@metamask/delegation-core'; +import { CHAIN_ID, DELEGATOR_CONTRACTS } from '@metamask/delegation-deployments'; + +import { createPermissionRulesForContracts } from './index'; + +describe('erc20-token-periodic rule', () => { + const chainId = CHAIN_ID.sepolia; + const contracts = DELEGATOR_CONTRACTS['1.3.0'][chainId]; + const { TimestampEnforcer, ERC20PeriodTransferEnforcer } = contracts; + const permissionRules = createPermissionRulesForContracts(contracts); + const rule = permissionRules.find( + (r) => r.permissionType === 'erc20-token-periodic', + )!; + + const expiryCaveat = { + enforcer: TimestampEnforcer, + terms: createTimestampTerms({ + timestampAfterThreshold: 0, + timestampBeforeThreshold: 1720000, + }), + 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, + { + enforcer: ERC20PeriodTransferEnforcer, + terms, + args: '0x' as const, + }, + { + 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 caveats'); + }); + + it('rejects truncated terms', () => { + const truncatedTerms = `0x${'a'.repeat(100)}` as Hex; // 50 bytes, need 116 + const caveats = [ + expiryCaveat, + { + enforcer: ERC20PeriodTransferEnforcer, + terms: truncatedTerms, + 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: expected 116 bytes', + ); + }); + + it('successfully decodes valid erc20-token-periodic caveats', () => { + const tokenAddress = + '0xcccccccccccccccccccccccccccccccccccccccc' as Hex; + const caveats = [ + expiryCaveat, + { + enforcer: ERC20PeriodTransferEnforcer, + terms: createERC20TokenPeriodTransferTerms( + { + tokenAddress, + 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).toBe(tokenAddress); + expect(result.data.periodAmount).toBeDefined(); + expect(result.data.periodDuration).toBe(86400); + expect(result.data.startTime).toBe(1715664); + }); + + 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, + { + 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: 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, + { + enforcer: ERC20PeriodTransferEnforcer, + terms: termsWithTrailing, + 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: 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, + { + 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: startTime must be a positive number', + ); + }); +}); diff --git a/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenPeriodic.ts b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenPeriodic.ts new file mode 100644 index 00000000000..df544e95626 --- /dev/null +++ b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenPeriodic.ts @@ -0,0 +1,74 @@ +import { hexToNumber } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; + +import type { + ChecksumCaveat, + ChecksumEnforcersByChainId, + DecodedPermission, + PermissionRule, +} from '../types'; +import { getByteLength, getTermsByEnforcer, splitHex } from '../utils'; +import { makePermissionRule } from './makePermissionRule'; + +/** + * Creates the erc20-token-periodic permission rule. + */ +export function makeErc20TokenPeriodicRule( + enforcers: ChecksumEnforcersByChainId, +): PermissionRule { + const { + timestampEnforcer, + erc20PeriodicEnforcer, + valueLteEnforcer, + nonceEnforcer, + } = enforcers; + return makePermissionRule({ + permissionType: 'erc20-token-periodic', + optionalEnforcers: [timestampEnforcer], + timestampEnforcer, + requiredEnforcers: new Map([ + [erc20PeriodicEnforcer, 1], + [valueLteEnforcer, 1], + [nonceEnforcer, 1], + ]), + decodeData: (caveats) => + decodeErc20Periodic(caveats, erc20PeriodicEnforcer), + }); +} + +/** + * Decodes erc20-token-periodic permission data from caveats; throws on invalid. + */ +export function decodeErc20Periodic( + caveats: ChecksumCaveat[], + enforcer: Hex, +): DecodedPermission['permission']['data'] { + const terms = getTermsByEnforcer({ caveats, enforcer }); + + 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 startTime = hexToNumber(startTimeRaw); + + if (periodDuration <= 0) { + throw new Error( + 'Invalid erc20-token-periodic terms: periodDuration must be a positive number', + ); + } + + 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/rules/erc20TokenRevocation.test.ts b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenRevocation.test.ts new file mode 100644 index 00000000000..6d5218b6411 --- /dev/null +++ b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenRevocation.test.ts @@ -0,0 +1,252 @@ +import { createTimestampTerms } from '@metamask/delegation-core'; +import { CHAIN_ID, DELEGATOR_CONTRACTS } from '@metamask/delegation-deployments'; + +import { createPermissionRulesForContracts } from './index'; + +describe('erc20-token-revocation rule', () => { + const chainId = CHAIN_ID.sepolia; + const contracts = DELEGATOR_CONTRACTS['1.3.0'][chainId]; + const { TimestampEnforcer, AllowedCalldataEnforcer, ValueLteEnforcer } = + contracts; + const permissionRules = createPermissionRulesForContracts(contracts); + const rule = permissionRules.find( + (r) => r.permissionType === 'erc20-token-revocation', + )!; + + const expiryCaveat = { + enforcer: TimestampEnforcer, + terms: createTimestampTerms({ + timestampAfterThreshold: 0, + timestampBeforeThreshold: 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 = 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-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 = 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-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 = 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 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 = 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 caveats'); + }); + + it('rejects when ValueLteEnforcer terms have wrong length', () => { + const approveSelectorTerms = + '0x0000000000000000000000000000000000000000000000000000000000000000095ea7b3' as const; + const zeroAmountTerms = + '0x00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000' as const; + const shortValueLteTerms = '0x0000' as const; // 2 bytes, need 32 + const caveats = [ + expiryCaveat, + { + enforcer: AllowedCalldataEnforcer, + terms: approveSelectorTerms, + args: '0x' as const, + }, + { + enforcer: AllowedCalldataEnforcer, + terms: zeroAmountTerms, + args: '0x' as const, + }, + { + enforcer: ValueLteEnforcer, + terms: shortValueLteTerms, + 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-revocation terms: ValueLteEnforcer terms must be 32 bytes', + ); + }); + + 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 = 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).toEqual({}); + }); +}); diff --git a/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenRevocation.ts b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenRevocation.ts new file mode 100644 index 00000000000..9cd153e5eb9 --- /dev/null +++ b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenRevocation.ts @@ -0,0 +1,92 @@ +import type { Hex } from '@metamask/utils'; + +import type { + ChecksumCaveat, + ChecksumEnforcersByChainId, + DecodedPermission, + PermissionRule, +} from '../types'; +import { getByteLength, getTermsByEnforcer } from '../utils'; +import { makePermissionRule } from './makePermissionRule'; + +/** + * Creates the erc20-token-revocation permission rule. + */ +export function makeErc20TokenRevocationRule( + enforcers: ChecksumEnforcersByChainId, +): PermissionRule { + const { + timestampEnforcer, + allowedCalldataEnforcer, + valueLteEnforcer, + nonceEnforcer, + } = enforcers; + return makePermissionRule({ + permissionType: 'erc20-token-revocation', + optionalEnforcers: [timestampEnforcer], + timestampEnforcer, + requiredEnforcers: new Map([ + [allowedCalldataEnforcer, 2], + [valueLteEnforcer, 1], + [nonceEnforcer, 1], + ]), + decodeData: (caveats) => + decodeErc20Revocation(caveats, allowedCalldataEnforcer, valueLteEnforcer), + }); +} + +const ZERO_32_BYTES = + '0x0000000000000000000000000000000000000000000000000000000000000000' as const; +const ERC20_APPROVE_SELECTOR_TERMS = + '0x0000000000000000000000000000000000000000000000000000000000000000095ea7b3' as const; +const ERC20_APPROVE_ZERO_AMOUNT_TERMS = + '0x00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000' as const; + +/** + * Decodes erc20-token-revocation permission data from caveats; throws on invalid. + */ +export function decodeErc20Revocation( + caveats: ChecksumCaveat[], + allowedCalldataEnforcer: Hex, + valueLteEnforcer: Hex, +): DecodedPermission['permission']['data'] { + const allowedCalldataCaveats = caveats.filter( + (c) => c.enforcer === allowedCalldataEnforcer, + ); + const allowedCalldataTerms = allowedCalldataCaveats.map((c) => + c.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, + }); + + const EXPECTED_VALUE_LTE_TERMS_BYTELENGTH = 32; + + if (getByteLength(valueLteTerms) !== EXPECTED_VALUE_LTE_TERMS_BYTELENGTH) { + throw new Error( + 'Invalid erc20-token-revocation terms: ValueLteEnforcer terms must be 32 bytes', + ); + } + + 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/rules/erc20TokenStream.test.ts b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenStream.test.ts new file mode 100644 index 00000000000..e907eb6328e --- /dev/null +++ b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenStream.test.ts @@ -0,0 +1,157 @@ +import { + createERC20StreamingTerms, + createTimestampTerms, +} from '@metamask/delegation-core'; +import type { Hex } from '@metamask/delegation-core'; +import { CHAIN_ID, DELEGATOR_CONTRACTS } from '@metamask/delegation-deployments'; + +import { createPermissionRulesForContracts } from './index'; + +describe('erc20-token-stream rule', () => { + const chainId = CHAIN_ID.sepolia; + const contracts = DELEGATOR_CONTRACTS['1.3.0'][chainId]; + const { TimestampEnforcer, ERC20StreamingEnforcer } = contracts; + const permissionRules = createPermissionRulesForContracts(contracts); + const rule = permissionRules.find((r) => r.permissionType === 'erc20-token-stream')!; + + const expiryCaveat = { + enforcer: TimestampEnforcer, + terms: createTimestampTerms({ + timestampAfterThreshold: 0, + timestampBeforeThreshold: 1720000, + }), + 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, + { enforcer: ERC20StreamingEnforcer, terms, args: '0x' as const }, + { 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 caveats'); + }); + + it('rejects truncated terms', () => { + const truncatedTerms = `0x${'a'.repeat(100)}` as Hex; + const caveats = [ + expiryCaveat, + { enforcer: ERC20StreamingEnforcer, terms: truncatedTerms, 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: expected 148 bytes'); + }); + + it('decodes zero token address', () => { + const zeroAddress = '0x0000000000000000000000000000000000000000' as Hex; + const caveats = [ + expiryCaveat, + { + enforcer: ERC20StreamingEnforcer, + terms: createERC20StreamingTerms( + { tokenAddress: zeroAddress, 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 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, + { 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('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, + { enforcer: ERC20StreamingEnforcer, terms: termsWithTrailing, 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: expected 148 bytes'); + }); + + 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, + { 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: startTime must be a positive number', + ); + }); +}); diff --git a/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenStream.ts b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenStream.ts new file mode 100644 index 00000000000..4e5b9a069ea --- /dev/null +++ b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenStream.ts @@ -0,0 +1,82 @@ +import { hexToBigInt, hexToNumber } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; + +import type { + ChecksumCaveat, + ChecksumEnforcersByChainId, + DecodedPermission, + PermissionRule, +} from '../types'; +import { getByteLength, getTermsByEnforcer, splitHex } from '../utils'; +import { makePermissionRule } from './makePermissionRule'; + +/** + * Creates the erc20-token-stream permission rule. + */ +export function makeErc20TokenStreamRule( + enforcers: ChecksumEnforcersByChainId, +): PermissionRule { + const { + timestampEnforcer, + erc20StreamingEnforcer, + valueLteEnforcer, + nonceEnforcer, + } = enforcers; + return makePermissionRule({ + permissionType: 'erc20-token-stream', + optionalEnforcers: [timestampEnforcer], + timestampEnforcer, + requiredEnforcers: new Map([ + [erc20StreamingEnforcer, 1], + [valueLteEnforcer, 1], + [nonceEnforcer, 1], + ]), + decodeData: (caveats) => + decodeErc20Stream(caveats, erc20StreamingEnforcer), + }); +} + +/** + * Decodes erc20-token-stream permission data from caveats; throws on invalid. + */ +export function decodeErc20Stream( + caveats: ChecksumCaveat[], + enforcer: Hex, +): DecodedPermission['permission']['data'] { + const terms = getTermsByEnforcer({ caveats, enforcer }); + + 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); + + if (maxAmountBigInt < initialAmountBigInt) { + throw new Error( + 'Invalid erc20-token-stream terms: maxAmount must be greater than initialAmount', + ); + } + + 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/rules/index.ts b/packages/gator-permissions-controller/src/decodePermission/rules/index.ts new file mode 100644 index 00000000000..170a383ca17 --- /dev/null +++ b/packages/gator-permissions-controller/src/decodePermission/rules/index.ts @@ -0,0 +1,36 @@ +import type { + DeployedContractsByName, + PermissionRule, +} from '../types'; +import { getChecksumEnforcersByChainId } from '../utils'; +import { makeErc20TokenPeriodicRule } from './erc20TokenPeriodic'; +import { makeErc20TokenRevocationRule } from './erc20TokenRevocation'; +import { makeErc20TokenStreamRule } from './erc20TokenStream'; +import { makeNativeTokenPeriodicRule } from './nativeTokenPeriodic'; +import { makeNativeTokenStreamRule } from './nativeTokenStream'; + +/** + * Builds the canonical set of permission matching rules for a chain. + * + * Each rule specifies the `permissionType`, required/optional enforcers, + * and provides `caveatAddressesMatch` and `validateAndDecodePermission` so the + * entire decode flow can be driven by the rules. + * + * @param contracts - The deployed contracts for the chain. + * @returns A list of permission rules used to identify and decode permission types. + * @throws Propagates any errors from resolving enforcer addresses. + */ +export const createPermissionRulesForContracts = ( + contracts: DeployedContractsByName, +): PermissionRule[] => { + const enforcers = getChecksumEnforcersByChainId(contracts); + return [ + makeNativeTokenStreamRule(enforcers), + makeNativeTokenPeriodicRule(enforcers), + makeErc20TokenStreamRule(enforcers), + makeErc20TokenPeriodicRule(enforcers), + makeErc20TokenRevocationRule(enforcers), + ]; +}; + + diff --git a/packages/gator-permissions-controller/src/decodePermission/rules/makePermissionRule.test.ts b/packages/gator-permissions-controller/src/decodePermission/rules/makePermissionRule.test.ts new file mode 100644 index 00000000000..f95e179631f --- /dev/null +++ b/packages/gator-permissions-controller/src/decodePermission/rules/makePermissionRule.test.ts @@ -0,0 +1,53 @@ +import { createTimestampTerms } from '@metamask/delegation-core'; +import { CHAIN_ID, DELEGATOR_CONTRACTS } from '@metamask/delegation-deployments'; +import type { Hex } from '@metamask/utils'; + +import type { DecodedPermission } from '../types'; +import { makePermissionRule } from './makePermissionRule'; + +describe('makePermissionRule', () => { + const contracts = DELEGATOR_CONTRACTS['1.3.0'][CHAIN_ID.sepolia]; + const timestampEnforcer = contracts.TimestampEnforcer as Hex; + const requiredEnforcer = contracts.NonceEnforcer as Hex; + + it('calls optional validate callback when provided and decoding succeeds', () => { + const validate = jest.fn(); + const decodeData = jest.fn().mockReturnValue({}); + + const rule = makePermissionRule({ + permissionType: 'native-token-stream', + timestampEnforcer, + optionalEnforcers: [], + requiredEnforcers: new Map([[requiredEnforcer, 1]]), + decodeData, + validate, + }); + + const caveats = [ + { + enforcer: timestampEnforcer, + terms: createTimestampTerms({ + timestampAfterThreshold: 0, + timestampBeforeThreshold: 1720000, + }), + args: '0x' as Hex, + }, + { + 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(result.expiry).toBe(1720000); + expect(result.data).toEqual({}); + expect(decodeData).toHaveBeenCalled(); + expect(validate).toHaveBeenCalledWith({}, 1720000); + }); +}); diff --git a/packages/gator-permissions-controller/src/decodePermission/rules/makePermissionRule.ts b/packages/gator-permissions-controller/src/decodePermission/rules/makePermissionRule.ts new file mode 100644 index 00000000000..4243bb7c016 --- /dev/null +++ b/packages/gator-permissions-controller/src/decodePermission/rules/makePermissionRule.ts @@ -0,0 +1,93 @@ +import type { Caveat } from '@metamask/delegation-core'; +import { getChecksumAddress } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; + +import type { + ChecksumCaveat, + DecodedPermission, + PermissionRule, + PermissionType, + ValidateAndDecodeResult, + ValidateDecodedPermission, +} from '../types'; +import { + buildEnforcerCountsAndSet, + enforcersMatchRule, + extractExpiryFromCaveatTerms, + getTermsByEnforcer, +} from '../utils'; + +/** + * Creates a single permission rule with the given type, enforcer sets, and + * decode/validate callbacks. + * + * @param args - The arguments to this function. + * @param args.optionalEnforcers - Enforcer addresses that may appear in addition to required. + * @param args.timestampEnforcer - Address of the TimestampEnforcer used to extract expiry. + * @param args.permissionType - The permission type identifier. + * @param args.requiredEnforcers - Map of required enforcer address to required count. + * @param args.decodeData - Callback to decode caveats into permission data; may throw. + * @param args.validate - Optional callback to validate decoded data and expiry; throw to reject. + * @returns A permission rule with caveatAddressesMatch and validateAndDecodePermission. + */ +export function makePermissionRule({ + optionalEnforcers, + timestampEnforcer, + permissionType, + requiredEnforcers, + decodeData, + validate, +}: { + optionalEnforcers: Hex[]; + timestampEnforcer: Hex; + permissionType: PermissionType; + requiredEnforcers: Map; + decodeData: ( + caveats: ChecksumCaveat[], + ) => DecodedPermission['permission']['data']; + validate?: ValidateDecodedPermission; +}): PermissionRule { + const optionalEnforcersSet = new Set(optionalEnforcers); + return { + permissionType, + requiredEnforcers, + optionalEnforcers: optionalEnforcersSet, + caveatAddressesMatch(caveatAddresses: Hex[]): boolean { + const { counts, enforcersSet } = + buildEnforcerCountsAndSet(caveatAddresses); + + return enforcersMatchRule( + counts, + enforcersSet, + requiredEnforcers, + optionalEnforcersSet, + ); + }, + validateAndDecodePermission(caveats: Caveat[]): ValidateAndDecodeResult { + const checksumCaveats: ChecksumCaveat[] = caveats.map((c) => ({ + ...c, + enforcer: getChecksumAddress(c.enforcer), + })); + try { + let expiry: number | null = null; + + const expiryTerms = getTermsByEnforcer({ + caveats: checksumCaveats, + enforcer: timestampEnforcer, + throwIfNotFound: false, + }); + + if (expiryTerms) { + expiry = extractExpiryFromCaveatTerms(expiryTerms); + } + const data = decodeData(checksumCaveats); + if (validate) { + validate(data, expiry); + } + return { isValid: true, expiry, data }; + } catch (err) { + return { isValid: false, error: err as Error }; + } + }, + }; +} diff --git a/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenPeriodic.test.ts b/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenPeriodic.test.ts new file mode 100644 index 00000000000..875e9b6489d --- /dev/null +++ b/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenPeriodic.test.ts @@ -0,0 +1,197 @@ +import { + createNativeTokenPeriodTransferTerms, + createTimestampTerms, +} from '@metamask/delegation-core'; +import type { Hex } from '@metamask/delegation-core'; +import { CHAIN_ID, DELEGATOR_CONTRACTS } from '@metamask/delegation-deployments'; + +import { createPermissionRulesForContracts } from './index'; + +describe('native-token-periodic rule', () => { + const chainId = CHAIN_ID.sepolia; + const contracts = DELEGATOR_CONTRACTS['1.3.0'][chainId]; + const { TimestampEnforcer, NativeTokenPeriodTransferEnforcer } = contracts; + const permissionRules = createPermissionRulesForContracts(contracts); + const rule = permissionRules.find( + (r) => r.permissionType === 'native-token-periodic', + )!; + + const expiryCaveat = { + enforcer: TimestampEnforcer, + terms: createTimestampTerms({ + timestampAfterThreshold: 0, + timestampBeforeThreshold: 1720000, + }), + args: '0x' as const, + }; + + it('rejects duplicate NativeTokenPeriodTransferEnforcer caveats', () => { + const terms = createNativeTokenPeriodTransferTerms( + { + periodAmount: 100n, + periodDuration: 86400, + startDate: 1715664, + }, + { out: 'hex' }, + ); + const caveats = [ + expiryCaveat, + { + enforcer: NativeTokenPeriodTransferEnforcer, + terms, + args: '0x' as const, + }, + { + enforcer: NativeTokenPeriodTransferEnforcer, + 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 caveats'); + }); + + it('rejects truncated terms', () => { + const truncatedTerms = `0x${'00'.repeat(40)}` as Hex; // 40 bytes, need 96 + const caveats = [ + expiryCaveat, + { + enforcer: NativeTokenPeriodTransferEnforcer, + terms: truncatedTerms, + 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 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, + { + enforcer: NativeTokenPeriodTransferEnforcer, + terms: termsWithTrailing, + 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 native-token-periodic terms: expected 96 bytes', + ); + }); + + it('successfully decodes valid native-token-periodic caveats', () => { + const caveats = [ + expiryCaveat, + { + enforcer: NativeTokenPeriodTransferEnforcer, + terms: createNativeTokenPeriodTransferTerms( + { + periodAmount: 100n, + 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.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, + { + enforcer: NativeTokenPeriodTransferEnforcer, + 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 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, + { + enforcer: NativeTokenPeriodTransferEnforcer, + 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 native-token-periodic terms: startTime must be a positive number', + ); + }); +}); diff --git a/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenPeriodic.ts b/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenPeriodic.ts new file mode 100644 index 00000000000..ffc3578305f --- /dev/null +++ b/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenPeriodic.ts @@ -0,0 +1,75 @@ +import { hexToNumber } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; + +import type { + ChecksumCaveat, + ChecksumEnforcersByChainId, + DecodedPermission, + PermissionRule, +} from '../types'; +import { getByteLength, getTermsByEnforcer, splitHex } from '../utils'; +import { makePermissionRule } from './makePermissionRule'; + +/** + * Creates the native-token-periodic permission rule. + */ +export function makeNativeTokenPeriodicRule( + enforcers: ChecksumEnforcersByChainId, +): PermissionRule { + const { + timestampEnforcer, + nativeTokenPeriodicEnforcer, + exactCalldataEnforcer, + nonceEnforcer, + } = enforcers; + return makePermissionRule({ + permissionType: 'native-token-periodic', + optionalEnforcers: [timestampEnforcer], + timestampEnforcer, + requiredEnforcers: new Map([ + [nativeTokenPeriodicEnforcer, 1], + [exactCalldataEnforcer, 1], + [nonceEnforcer, 1], + ]), + decodeData: (caveats) => + decodeNativePeriodic(caveats, nativeTokenPeriodicEnforcer), + }); +} + +/** + * Decodes native-token-periodic permission data from caveats; throws on invalid. + */ +export function decodeNativePeriodic( + caveats: ChecksumCaveat[], + enforcer: Hex, +): DecodedPermission['permission']['data'] { + const terms = getTermsByEnforcer({ caveats, enforcer }); + + 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); + + if (periodDuration <= 0) { + throw new Error( + 'Invalid native-token-periodic terms: periodDuration must be a positive number', + ); + } + + 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/rules/nativeTokenStream.test.ts b/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenStream.test.ts new file mode 100644 index 00000000000..1760d378ec6 --- /dev/null +++ b/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenStream.test.ts @@ -0,0 +1,380 @@ +import { + createNativeTokenStreamingTerms, + createTimestampTerms, +} from '@metamask/delegation-core'; +import type { Hex } from '@metamask/delegation-core'; +import { CHAIN_ID, DELEGATOR_CONTRACTS } from '@metamask/delegation-deployments'; + +import { createPermissionRulesForContracts } from './index'; + +describe('native-token-stream rule', () => { + const chainId = CHAIN_ID.sepolia; + const contracts = DELEGATOR_CONTRACTS['1.3.0'][chainId]; + const { TimestampEnforcer, NativeTokenStreamingEnforcer } = contracts; + const permissionRules = createPermissionRulesForContracts(contracts); + const rule = permissionRules.find((r) => r.permissionType === 'native-token-stream')!; + + const expiryCaveat = { + enforcer: TimestampEnforcer, + terms: createTimestampTerms({ + timestampAfterThreshold: 0, + timestampBeforeThreshold: 1720000, + }), + args: '0x' as const, + }; + + it('rejects duplicate caveats for same enforcer (e.g. two TimestampEnforcer)', () => { + const caveats = [ + expiryCaveat, + { + enforcer: TimestampEnforcer, + terms: createTimestampTerms({ + timestampAfterThreshold: 0, + timestampBeforeThreshold: 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 = 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 caveats'); + }); + + it('rejects TimestampEnforcer terms with non-hex characters', () => { + const invalidTerms = + '0x00000000000000000000000000000000zz000000000000000000000000001a3b80' as Hex; + const caveats = [ + { 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 = 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).toBeDefined(); + }); + + it('rejects native-token-stream terms shorter than expected', () => { + const truncatedTerms = `0x${'00'.repeat(50)}` as Hex; + const caveats = [ + expiryCaveat, + { + enforcer: NativeTokenStreamingEnforcer, + terms: truncatedTerms, + 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 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, + { + enforcer: NativeTokenStreamingEnforcer, + terms: termsWithTrailing, + 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 native-token-stream terms: expected 128 bytes', + ); + }); + + it('successfully decodes valid native-token-stream caveats', () => { + const caveats = [ + expiryCaveat, + { + enforcer: NativeTokenStreamingEnforcer, + terms: createNativeTokenStreamingTerms( + { + initialAmount: 10n, + maxAmount: 100n, + amountPerSecond: 5n, + 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.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 = `0x${'0'.repeat(65)}` as Hex; + const caveats = [ + { 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 = 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 TimestampEnforcer terms length'); + }); + + it('rejects expiry timestampBeforeThreshold zero', () => { + const caveats = [ + { + enforcer: TimestampEnforcer, + terms: createTimestampTerms({ + timestampAfterThreshold: 0, + timestampBeforeThreshold: 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 = 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 expiry: timestampBeforeThreshold must be greater than 0', + ); + }); + + it('rejects expiry timestampAfterThreshold non-zero', () => { + const caveats = [ + { + enforcer: TimestampEnforcer, + terms: createTimestampTerms({ + timestampAfterThreshold: 1, + timestampBeforeThreshold: 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 = 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 expiry: timestampAfterThreshold must be 0', + ); + }); + + 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, + { enforcer: NativeTokenStreamingEnforcer, 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 native-token-stream terms: initialAmount must be a positive number', + ); + }); + + it('rejects native-token-stream when maxAmount is zero', () => { + const initialAmountHex = (1n).toString(16).padStart(64, '0'); + const maxAmountZero = '0'.repeat(64); + const amountPerSecondHex = (1n).toString(16).padStart(64, '0'); + const startTimeHex = (1715664).toString(16).padStart(64, '0'); + const terms = `0x${initialAmountHex}${maxAmountZero}${amountPerSecondHex}${startTimeHex}` as Hex; + const caveats = [ + expiryCaveat, + { enforcer: NativeTokenStreamingEnforcer, 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 native-token-stream terms: maxAmount must be a positive number', + ); + }); + + 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, + { enforcer: NativeTokenStreamingEnforcer, 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 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, + { enforcer: NativeTokenStreamingEnforcer, 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 native-token-stream terms: startTime must be a positive number', + ); + }); +}); diff --git a/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenStream.ts b/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenStream.ts new file mode 100644 index 00000000000..b6dfde20491 --- /dev/null +++ b/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenStream.ts @@ -0,0 +1,90 @@ +import { hexToBigInt, hexToNumber } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; + +import type { + ChecksumCaveat, + ChecksumEnforcersByChainId, + DecodedPermission, + PermissionRule, +} from '../types'; +import { getByteLength, getTermsByEnforcer, splitHex } from '../utils'; +import { makePermissionRule } from './makePermissionRule'; + +/** + * Creates the native-token-stream permission rule. + */ +export function makeNativeTokenStreamRule( + enforcers: ChecksumEnforcersByChainId, +): PermissionRule { + const { + timestampEnforcer, + nativeTokenStreamingEnforcer, + exactCalldataEnforcer, + nonceEnforcer, + } = enforcers; + return makePermissionRule({ + permissionType: 'native-token-stream', + optionalEnforcers: [timestampEnforcer], + timestampEnforcer, + requiredEnforcers: new Map([ + [nativeTokenStreamingEnforcer, 1], + [exactCalldataEnforcer, 1], + [nonceEnforcer, 1], + ]), + decodeData: (caveats) => + decodeNativeStream(caveats, nativeTokenStreamingEnforcer), + }); +} + +/** + * Decodes native-token-stream permission data from caveats; throws on invalid. + */ +export function decodeNativeStream( + caveats: ChecksumCaveat[], + enforcer: Hex, +): DecodedPermission['permission']['data'] { + const terms = getTermsByEnforcer({ caveats, enforcer }); + + 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 (initialAmountBigInt <= 0n) { + throw new Error( + 'Invalid native-token-stream terms: initialAmount must be a positive number', + ); + } + + if (maxAmountBigInt <= 0n) { + throw new Error( + 'Invalid native-token-stream terms: maxAmount must be a positive number', + ); + } + + 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/types.ts b/packages/gator-permissions-controller/src/decodePermission/types.ts index 85a28233375..af83104953f 100644 --- a/packages/gator-permissions-controller/src/decodePermission/types.ts +++ b/packages/gator-permissions-controller/src/decodePermission/types.ts @@ -1,8 +1,10 @@ +import type { Caveat } from '@metamask/delegation-core'; import type { PermissionRequest, PermissionTypes, } from '@metamask/7715-permission-types'; import type { DELEGATOR_CONTRACTS } from '@metamask/delegation-deployments'; +import type { Hex } from '@metamask/utils'; export type DeployedContractsByName = (typeof DELEGATOR_CONTRACTS)[number][number]; @@ -35,3 +37,66 @@ export type DecodedPermission = Pick< * Supported permission type identifiers that can be decoded from a permission context. */ export type PermissionType = DecodedPermission['permission']['type']; + +/** + * Checksummed enforcer contract addresses for a chain (from getChecksumEnforcersByChainId). + */ +export type ChecksumEnforcersByChainId = { + erc20StreamingEnforcer: Hex; + erc20PeriodicEnforcer: Hex; + nativeTokenStreamingEnforcer: Hex; + nativeTokenPeriodicEnforcer: Hex; + exactCalldataEnforcer: Hex; + valueLteEnforcer: Hex; + timestampEnforcer: Hex; + nonceEnforcer: Hex; + allowedCalldataEnforcer: Hex; +}; + +/** Caveat with checksummed enforcer address; used by rule decode functions. */ +export type ChecksumCaveat = Caveat & { enforcer: Hex }; + +/** + * Result of validating and decoding permission terms from caveats. + * When valid, includes expiry and decoded data; when invalid, includes the error. + */ +export type ValidateAndDecodeResult = + | { + isValid: true; + expiry: number | null; + data: DecodedPermission['permission']['data']; + } + | { isValid: false; error: Error }; + +/** + * Optional post-decode validation for a permission rule. Runs after decoding + * with the decoded data and expiry; throw to reject. + */ +export type ValidateDecodedPermission = ( + data: DecodedPermission['permission']['data'], + expiry: number | null, +) => void; + +/** + * A rule that defines the required and optional enforcers for a permission type, + * and provides methods to test whether caveat addresses match the rule and to + * validate and decode permission terms from caveats. + */ +export type PermissionRule = { + permissionType: PermissionType; + requiredEnforcers: Map; + optionalEnforcers: Set; + /** + * Returns true if the given caveat addresses (enforcer addresses) match this + * rule (required enforcers present with correct multiplicity, no forbidden enforcers). + */ + caveatAddressesMatch: (caveatAddresses: Hex[]) => boolean; + /** + * Validates and decodes permission terms from the caveats. Returns a result + * object with isValid; when valid, includes expiry and data. + */ + validateAndDecodePermission: ( + caveats: Caveat[], + ) => ValidateAndDecodeResult; +}; + diff --git a/packages/gator-permissions-controller/src/decodePermission/utils.test.ts b/packages/gator-permissions-controller/src/decodePermission/utils.test.ts index e4b6e50f6da..3d640bea277 100644 --- a/packages/gator-permissions-controller/src/decodePermission/utils.test.ts +++ b/packages/gator-permissions-controller/src/decodePermission/utils.test.ts @@ -3,8 +3,8 @@ import { getChecksumAddress } from '@metamask/utils'; import type { Hex } from '@metamask/utils'; import type { DeployedContractsByName } from './types'; +import { createPermissionRulesForContracts } from './rules'; import { - createPermissionRulesForChainId, getChecksumEnforcersByChainId, getTermsByEnforcer, isSubset, @@ -85,7 +85,7 @@ describe('createPermissionRulesForChainId', () => { // native-token-periodic // erc20-token-revocation const permissionTypeCount = 5; - const rules = createPermissionRulesForChainId(contracts); + const rules = createPermissionRulesForContracts(contracts); expect(rules).toHaveLength(permissionTypeCount); const byType = Object.fromEntries( @@ -192,6 +192,37 @@ describe('createPermissionRulesForChainId', () => { ]), ); }); + + it('each rule has caveatAddressesMatch and validateAndDecodePermission', () => { + const contracts = buildContracts(); + const rules = createPermissionRulesForContracts(contracts); + const { + nativeTokenStreamingEnforcer, + exactCalldataEnforcer, + nonceEnforcer, + timestampEnforcer, + } = getChecksumEnforcersByChainId(contracts); + + for (const rule of rules) { + expect(typeof rule.caveatAddressesMatch).toBe('function'); + expect(typeof rule.validateAndDecodePermission).toBe('function'); + } + + const nativeStreamRule = rules.find( + (r) => r.permissionType === 'native-token-stream', + ); + expect(nativeStreamRule).toBeDefined(); + + const matchingCaveatAddresses: Hex[] = [ + nativeTokenStreamingEnforcer, + exactCalldataEnforcer, + nonceEnforcer, + timestampEnforcer, + ]; + expect(nativeStreamRule!.caveatAddressesMatch(matchingCaveatAddresses)).toBe( + true, + ); + }); }); describe('isSubset', () => { diff --git a/packages/gator-permissions-controller/src/decodePermission/utils.ts b/packages/gator-permissions-controller/src/decodePermission/utils.ts index 720a9b027b2..10b38825002 100644 --- a/packages/gator-permissions-controller/src/decodePermission/utils.ts +++ b/packages/gator-permissions-controller/src/decodePermission/utils.ts @@ -1,17 +1,11 @@ import type { Caveat } from '@metamask/delegation-core'; -import { getChecksumAddress } from '@metamask/utils'; +import { getChecksumAddress, hexToNumber } from '@metamask/utils'; import type { Hex } from '@metamask/utils'; -import type { DeployedContractsByName, PermissionType } from './types'; - -/** - * A rule that defines the required and allowed enforcers for a permission type. - */ -export type PermissionRule = { - permissionType: PermissionType; - requiredEnforcers: Map; - optionalEnforcers: Set; -}; +import type { + ChecksumEnforcersByChainId, + DeployedContractsByName, +} from './types'; /** * The names of the enforcer contracts for each permission type. @@ -28,6 +22,15 @@ const ENFORCER_CONTRACT_NAMES = { AllowedCalldataEnforcer: 'AllowedCalldataEnforcer', }; +/** + * Get the byte length of a hex string. + * @param hex - The hex string to get the byte length of. + * @returns The byte length of the hex string. + */ +export const getByteLength = (hex: Hex): number => { + return (hex.length - 2) / 2; +}; + /** * Resolves and returns checksummed addresses of all known enforcer contracts * for a given `chainId` under the current delegation framework version. @@ -38,17 +41,7 @@ const ENFORCER_CONTRACT_NAMES = { */ export const getChecksumEnforcersByChainId = ( contracts: DeployedContractsByName, -): { - erc20StreamingEnforcer: Hex; - erc20PeriodicEnforcer: Hex; - nativeTokenStreamingEnforcer: Hex; - nativeTokenPeriodicEnforcer: Hex; - exactCalldataEnforcer: Hex; - valueLteEnforcer: Hex; - timestampEnforcer: Hex; - nonceEnforcer: Hex; - allowedCalldataEnforcer: Hex; -} => { +): ChecksumEnforcersByChainId => { const getChecksumContractAddress = (contractName: string): Hex => { const address = contracts[contractName]; @@ -105,84 +98,70 @@ export const getChecksumEnforcersByChainId = ( }; /** - * Builds the canonical set of permission matching rules for a chain. - * - * Each rule specifies the `permissionType`, the set of `requiredEnforcers` - * that must be present, and the set of `optionalEnforcers` that may appear in - * addition to the required set. + * Extracts the expiry timestamp from TimestampEnforcer caveat terms. + * Terms are 32 bytes: first 16 bytes timestampAfterThreshold (must be 0), + * last 16 bytes timestampBeforeThreshold (expiry). * - * @param contracts - The deployed contracts for the chain. - * @returns A list of permission rules used to identify permission types. - * @throws Propagates any errors from resolving enforcer addresses. + * @param terms - The hex-encoded terms from a TimestampEnforcer caveat. + * @returns The expiry timestamp in seconds. + * @throws If terms are invalid. */ -export const createPermissionRulesForChainId: ( - contracts: DeployedContractsByName, -) => PermissionRule[] = (contracts: DeployedContractsByName) => { - const { - erc20StreamingEnforcer, - erc20PeriodicEnforcer, - nativeTokenStreamingEnforcer, - nativeTokenPeriodicEnforcer, - exactCalldataEnforcer, - valueLteEnforcer, - timestampEnforcer, - nonceEnforcer, - allowedCalldataEnforcer, - } = getChecksumEnforcersByChainId(contracts); - - // the optional enforcers are the same for all permission types - const optionalEnforcers = new Set([timestampEnforcer]); +export const extractExpiryFromCaveatTerms = (terms: Hex): number => { + if (terms.length !== 66) { + throw new Error( + `Invalid TimestampEnforcer terms length: expected 66 characters (0x + 64 hex), got ${terms.length}`, + ); + } + const [after, before] = splitHex(terms, [16, 16]); + if (hexToNumber(after) !== 0) { + throw new Error('Invalid expiry: timestampAfterThreshold must be 0'); + } + const expiry = hexToNumber(before); + if (expiry === 0) { + throw new Error( + 'Invalid expiry: timestampBeforeThreshold must be greater than 0', + ); + } + return expiry; +}; - const permissionRules: PermissionRule[] = [ - { - requiredEnforcers: new Map([ - [nativeTokenStreamingEnforcer, 1], - [exactCalldataEnforcer, 1], - [nonceEnforcer, 1], - ]), - optionalEnforcers, - permissionType: 'native-token-stream', - }, - { - requiredEnforcers: new Map([ - [nativeTokenPeriodicEnforcer, 1], - [exactCalldataEnforcer, 1], - [nonceEnforcer, 1], - ]), - optionalEnforcers, - permissionType: 'native-token-periodic', - }, - { - requiredEnforcers: new Map([ - [erc20StreamingEnforcer, 1], - [valueLteEnforcer, 1], - [nonceEnforcer, 1], - ]), - optionalEnforcers, - permissionType: 'erc20-token-stream', - }, - { - requiredEnforcers: new Map([ - [erc20PeriodicEnforcer, 1], - [valueLteEnforcer, 1], - [nonceEnforcer, 1], - ]), - optionalEnforcers, - permissionType: 'erc20-token-periodic', - }, - { - requiredEnforcers: new Map([ - [allowedCalldataEnforcer, 2], - [valueLteEnforcer, 1], - [nonceEnforcer, 1], - ]), - optionalEnforcers, - permissionType: 'erc20-token-revocation', - }, - ]; +/** + * Builds enforcer counts and set from caveat addresses (checksummed). + * Used by caveatAddressesMatch. + */ +export function buildEnforcerCountsAndSet(caveatAddresses: Hex[]): { + counts: Map; + enforcersSet: Set; +} { + const counts = new Map(); + for (const addr of caveatAddresses.map(getChecksumAddress)) { + counts.set(addr, (counts.get(addr) ?? 0) + 1); + } + return { counts, enforcersSet: new Set(counts.keys()) }; +} - return permissionRules; -}; +/** + * Returns true if the given counts/set match the rule (required counts exact, + * no enforcer outside required + optional). + */ +export function enforcersMatchRule( + counts: Map, + enforcersSet: Set, + requiredEnforcers: Map, + optionalEnforcers: Set, +): boolean { + const allowedEnforcers = new Set([ + ...optionalEnforcers, + ...requiredEnforcers.keys(), + ]); + for (const addr of enforcersSet) { + if (!allowedEnforcers.has(addr)) return false; + } + for (const [addr, requiredCount] of requiredEnforcers.entries()) { + if ((counts.get(addr) ?? 0) !== requiredCount) return false; + } + return true; +} /** * Determines whether all elements of `subset` are contained within `superset`. From 7c1ec70436b1b32a5ae01ea1e38b32ff9edcf671 Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Mon, 2 Mar 2026 16:56:41 +1300 Subject: [PATCH 03/13] Linting fixes --- .../src/GatorPermissionsController.test.ts | 8 +- .../src/GatorPermissionsController.ts | 2 +- .../decodePermission/decodePermission.test.ts | 162 ++++++++---------- .../src/decodePermission/decodePermission.ts | 5 +- .../rules/erc20TokenPeriodic.test.ts | 41 +++-- .../rules/erc20TokenPeriodic.ts | 13 +- .../rules/erc20TokenRevocation.test.ts | 16 +- .../rules/erc20TokenRevocation.ts | 16 +- .../rules/erc20TokenStream.test.ts | 82 ++++++--- .../rules/erc20TokenStream.ts | 29 ++-- .../src/decodePermission/rules/index.ts | 9 +- .../rules/makePermissionRule.test.ts | 18 +- .../rules/makePermissionRule.ts | 18 +- .../rules/nativeTokenPeriodic.test.ts | 26 ++- .../rules/nativeTokenPeriodic.ts | 20 ++- .../rules/nativeTokenStream.test.ts | 46 +++-- .../rules/nativeTokenStream.ts | 13 +- .../src/decodePermission/types.ts | 3 +- .../src/decodePermission/utils.test.ts | 9 +- .../src/decodePermission/utils.ts | 24 ++- 20 files changed, 336 insertions(+), 224 deletions(-) diff --git a/packages/gator-permissions-controller/src/GatorPermissionsController.test.ts b/packages/gator-permissions-controller/src/GatorPermissionsController.test.ts index 648875c6a70..15d6a971f2b 100644 --- a/packages/gator-permissions-controller/src/GatorPermissionsController.test.ts +++ b/packages/gator-permissions-controller/src/GatorPermissionsController.test.ts @@ -735,9 +735,13 @@ describe('GatorPermissionsController', () => { ); // Enforcers match native-token-stream but stream terms are truncated (invalid) - const truncatedStreamTerms = `0x${'00'.repeat(50)}` as Hex; + const truncatedStreamTerms = `0x${'00'.repeat(50)}`; const caveats = [ - { enforcer: TimestampEnforcer, terms: expiryTerms, args: '0x' } as const, + { + enforcer: TimestampEnforcer, + terms: expiryTerms, + args: '0x', + } as const, { enforcer: NativeTokenStreamingEnforcer, terms: truncatedStreamTerms, diff --git a/packages/gator-permissions-controller/src/GatorPermissionsController.ts b/packages/gator-permissions-controller/src/GatorPermissionsController.ts index 5865d53f47b..5a2489778fe 100644 --- a/packages/gator-permissions-controller/src/GatorPermissionsController.ts +++ b/packages/gator-permissions-controller/src/GatorPermissionsController.ts @@ -588,7 +588,7 @@ export default class GatorPermissionsController extends BaseController< const enforcers = caveats.map((caveat) => caveat.enforcer); const permissionRules = createPermissionRulesForContracts(contracts); - // find the single rule where the specified enforcers contain all the required enforcers + // find the single rule where the specified enforcers contain all the required enforcers // and no forbidden enforcers const matchingRule = findRuleWithMatchingCaveatAddresses({ enforcers, diff --git a/packages/gator-permissions-controller/src/decodePermission/decodePermission.test.ts b/packages/gator-permissions-controller/src/decodePermission/decodePermission.test.ts index bf53fb58b3d..9fa8abf45c1 100644 --- a/packages/gator-permissions-controller/src/decodePermission/decodePermission.test.ts +++ b/packages/gator-permissions-controller/src/decodePermission/decodePermission.test.ts @@ -1,11 +1,4 @@ -import { - createNativeTokenStreamingTerms, - createNativeTokenPeriodTransferTerms, - createERC20StreamingTerms, - createERC20TokenPeriodTransferTerms, - createTimestampTerms, - ROOT_AUTHORITY, -} from '@metamask/delegation-core'; +import { ROOT_AUTHORITY } from '@metamask/delegation-core'; import type { Hex } from '@metamask/delegation-core'; import { CHAIN_ID, @@ -17,27 +10,8 @@ import { findRuleWithMatchingCaveatAddresses, reconstructDecodedPermission, } from './decodePermission'; -import type { - DecodedPermission, - DeployedContractsByName, - PermissionType, -} from './types'; -import type { PermissionRule } from './types'; import { createPermissionRulesForContracts } from './rules'; - -/** Mock rule whose validateAndDecodePermission returns invalid (for error-path tests). */ -function createMockPermissionRuleThatReturnsInvalid(errorMessage: string): PermissionRule { - return { - permissionType: 'native-token-stream', - requiredEnforcers: new Map(), - optionalEnforcers: new Set(), - caveatAddressesMatch: () => true, - validateAndDecodePermission: () => ({ - isValid: false, - error: new Error(errorMessage), - }), - }; -} +import type { DecodedPermission, DeployedContractsByName } from './types'; // These tests use the live deployments table for version 1.3.0 to // construct deterministic caveat address sets for a known chain. @@ -76,7 +50,9 @@ describe('decodePermission', () => { expect(() => { findRuleWithMatchingCaveatAddresses({ enforcers, - permissionRules: createPermissionRulesForContracts(contractsWithDuplicates), + permissionRules: createPermissionRulesForContracts( + contractsWithDuplicates, + ), }); }).toThrow('Multiple permission types match'); }); @@ -121,9 +97,9 @@ describe('decodePermission', () => { ]; expect(() => findRuleWithMatchingCaveatAddresses({ - enforcers, - permissionRules: createPermissionRulesForContracts(contracts), - }), + enforcers, + permissionRules: createPermissionRulesForContracts(contracts), + }), ).toThrow('Unable to identify permission type'); }); @@ -131,9 +107,9 @@ describe('decodePermission', () => { const enforcers = [ExactCalldataEnforcer]; expect(() => findRuleWithMatchingCaveatAddresses({ - enforcers, - permissionRules: createPermissionRulesForContracts(contracts), - }), + enforcers, + permissionRules: createPermissionRulesForContracts(contracts), + }), ).toThrow('Unable to identify permission type'); }); @@ -164,7 +140,9 @@ describe('decodePermission', () => { expect(() => findRuleWithMatchingCaveatAddresses({ enforcers, - permissionRules: createPermissionRulesForContracts(contractsWithoutTimestampEnforcer), + permissionRules: createPermissionRulesForContracts( + contractsWithoutTimestampEnforcer, + ), }), ).toThrow('Contract not found: TimestampEnforcer'); }); @@ -209,9 +187,9 @@ describe('decodePermission', () => { ]; expect(() => findRuleWithMatchingCaveatAddresses({ - enforcers, - permissionRules: createPermissionRulesForContracts(contracts), - }), + enforcers, + permissionRules: createPermissionRulesForContracts(contracts), + }), ).toThrow('Unable to identify permission type'); }); @@ -219,9 +197,9 @@ describe('decodePermission', () => { const enforcers = [ExactCalldataEnforcer]; expect(() => findRuleWithMatchingCaveatAddresses({ - enforcers, - permissionRules: createPermissionRulesForContracts(contracts), - }), + enforcers, + permissionRules: createPermissionRulesForContracts(contracts), + }), ).toThrow('Unable to identify permission type'); }); @@ -252,7 +230,9 @@ describe('decodePermission', () => { expect(() => findRuleWithMatchingCaveatAddresses({ enforcers, - permissionRules: createPermissionRulesForContracts(contractsWithoutTimestampEnforcer), + permissionRules: createPermissionRulesForContracts( + contractsWithoutTimestampEnforcer, + ), }), ).toThrow('Contract not found: TimestampEnforcer'); }); @@ -297,9 +277,9 @@ describe('decodePermission', () => { ]; expect(() => findRuleWithMatchingCaveatAddresses({ - enforcers, - permissionRules: createPermissionRulesForContracts(contracts), - }), + enforcers, + permissionRules: createPermissionRulesForContracts(contracts), + }), ).toThrow('Unable to identify permission type'); }); @@ -307,9 +287,9 @@ describe('decodePermission', () => { const enforcers = [ERC20StreamingEnforcer]; expect(() => findRuleWithMatchingCaveatAddresses({ - enforcers, - permissionRules: createPermissionRulesForContracts(contracts), - }), + enforcers, + permissionRules: createPermissionRulesForContracts(contracts), + }), ).toThrow('Unable to identify permission type'); }); @@ -340,7 +320,9 @@ describe('decodePermission', () => { expect(() => findRuleWithMatchingCaveatAddresses({ enforcers, - permissionRules: createPermissionRulesForContracts(contractsWithoutTimestampEnforcer), + permissionRules: createPermissionRulesForContracts( + contractsWithoutTimestampEnforcer, + ), }), ).toThrow('Contract not found: TimestampEnforcer'); }); @@ -385,9 +367,9 @@ describe('decodePermission', () => { ]; expect(() => findRuleWithMatchingCaveatAddresses({ - enforcers, - permissionRules: createPermissionRulesForContracts(contracts), - }), + enforcers, + permissionRules: createPermissionRulesForContracts(contracts), + }), ).toThrow('Unable to identify permission type'); }); @@ -395,9 +377,9 @@ describe('decodePermission', () => { const enforcers = [ERC20PeriodTransferEnforcer]; expect(() => findRuleWithMatchingCaveatAddresses({ - enforcers, - permissionRules: createPermissionRulesForContracts(contracts), - }), + enforcers, + permissionRules: createPermissionRulesForContracts(contracts), + }), ).toThrow('Unable to identify permission type'); }); @@ -428,7 +410,9 @@ describe('decodePermission', () => { expect(() => findRuleWithMatchingCaveatAddresses({ enforcers, - permissionRules: createPermissionRulesForContracts(contractsWithoutTimestampEnforcer), + permissionRules: createPermissionRulesForContracts( + contractsWithoutTimestampEnforcer, + ), }), ).toThrow('Contract not found: TimestampEnforcer'); }); @@ -474,9 +458,9 @@ describe('decodePermission', () => { ]; expect(() => findRuleWithMatchingCaveatAddresses({ - enforcers, - permissionRules: createPermissionRulesForContracts(contracts), - }), + enforcers, + permissionRules: createPermissionRulesForContracts(contracts), + }), ).toThrow('Unable to identify permission type'); }); @@ -490,9 +474,9 @@ describe('decodePermission', () => { ]; expect(() => findRuleWithMatchingCaveatAddresses({ - enforcers, - permissionRules: createPermissionRulesForContracts(contracts), - }), + enforcers, + permissionRules: createPermissionRulesForContracts(contracts), + }), ).toThrow('Unable to identify permission type'); }); @@ -504,9 +488,9 @@ describe('decodePermission', () => { ]; expect(() => findRuleWithMatchingCaveatAddresses({ - enforcers, - permissionRules: createPermissionRulesForContracts(contracts), - }), + enforcers, + permissionRules: createPermissionRulesForContracts(contracts), + }), ).toThrow('Unable to identify permission type'); }); @@ -521,9 +505,9 @@ describe('decodePermission', () => { ]; expect(() => findRuleWithMatchingCaveatAddresses({ - enforcers, - permissionRules: createPermissionRulesForContracts(contracts), - }), + enforcers, + permissionRules: createPermissionRulesForContracts(contracts), + }), ).toThrow('Unable to identify permission type'); }); @@ -556,7 +540,9 @@ describe('decodePermission', () => { expect(() => findRuleWithMatchingCaveatAddresses({ enforcers, - permissionRules: createPermissionRulesForContracts(contractsWithoutAllowedCalldataEnforcer), + permissionRules: createPermissionRulesForContracts( + contractsWithoutAllowedCalldataEnforcer, + ), }), ).toThrow('Contract not found: AllowedCalldataEnforcer'); }); @@ -660,9 +646,9 @@ describe('decodePermission', () => { it('rejects empty enforcer list', () => { expect(() => findRuleWithMatchingCaveatAddresses({ - enforcers: [], - permissionRules: createPermissionRulesForContracts(contracts), - }), + enforcers: [], + permissionRules: createPermissionRulesForContracts(contracts), + }), ).toThrow('Unable to identify permission type'); }); @@ -685,9 +671,9 @@ describe('decodePermission', () => { ]; expect(() => findRuleWithMatchingCaveatAddresses({ - enforcers, - permissionRules: createPermissionRulesForContracts(contracts), - }), + enforcers, + permissionRules: createPermissionRulesForContracts(contracts), + }), ).toThrow('Unable to identify permission type'); }); @@ -702,9 +688,9 @@ describe('decodePermission', () => { ]; expect(() => findRuleWithMatchingCaveatAddresses({ - enforcers, - permissionRules: createPermissionRulesForContracts(contracts), - }), + enforcers, + permissionRules: createPermissionRulesForContracts(contracts), + }), ).toThrow('Unable to identify permission type'); }); @@ -716,9 +702,9 @@ describe('decodePermission', () => { ]; expect(() => findRuleWithMatchingCaveatAddresses({ - enforcers, - permissionRules: createPermissionRulesForContracts(contracts), - }), + enforcers, + permissionRules: createPermissionRulesForContracts(contracts), + }), ).toThrow('Unable to identify permission type'); }); @@ -732,18 +718,16 @@ describe('decodePermission', () => { ]; expect(() => findRuleWithMatchingCaveatAddresses({ - enforcers, - permissionRules: createPermissionRulesForContracts(contracts), - }), + enforcers, + permissionRules: createPermissionRulesForContracts(contracts), + }), ).toThrow('Unable to identify permission type'); }); }); describe('reconstructDecodedPermission()', () => { - const delegator = - '0x1111111111111111111111111111111111111111' as Hex; - const delegate = - '0x2222222222222222222222222222222222222222' as Hex; + const delegator = '0x1111111111111111111111111111111111111111' as Hex; + const delegate = '0x2222222222222222222222222222222222222222' as Hex; const data: DecodedPermission['permission']['data'] = { initialAmount: '0x01', maxAmount: '0x02', diff --git a/packages/gator-permissions-controller/src/decodePermission/decodePermission.ts b/packages/gator-permissions-controller/src/decodePermission/decodePermission.ts index 60ec78e5043..944204d746c 100644 --- a/packages/gator-permissions-controller/src/decodePermission/decodePermission.ts +++ b/packages/gator-permissions-controller/src/decodePermission/decodePermission.ts @@ -2,10 +2,7 @@ import type { Hex } from '@metamask/delegation-core'; import { ROOT_AUTHORITY } from '@metamask/delegation-core'; import { numberToHex } from '@metamask/utils'; -import type { - DecodedPermission, - PermissionType, -} from './types'; +import type { DecodedPermission, PermissionType } from './types'; import type { PermissionRule } from './types'; /* 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 c5e5a7fa489..5f9f3d4eb2f 100644 --- a/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenPeriodic.test.ts +++ b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenPeriodic.test.ts @@ -3,9 +3,12 @@ import { createTimestampTerms, } from '@metamask/delegation-core'; import type { Hex } from '@metamask/delegation-core'; -import { CHAIN_ID, DELEGATOR_CONTRACTS } from '@metamask/delegation-deployments'; +import { + CHAIN_ID, + DELEGATOR_CONTRACTS, +} from '@metamask/delegation-deployments'; -import { createPermissionRulesForContracts } from './index'; +import { createPermissionRulesForContracts } from '.'; describe('erc20-token-periodic rule', () => { const chainId = CHAIN_ID.sepolia; @@ -13,8 +16,11 @@ describe('erc20-token-periodic rule', () => { const { TimestampEnforcer, ERC20PeriodTransferEnforcer } = contracts; const permissionRules = createPermissionRulesForContracts(contracts); const rule = permissionRules.find( - (r) => r.permissionType === 'erc20-token-periodic', - )!; + (candidate) => candidate.permissionType === 'erc20-token-periodic', + ); + if (!rule) { + throw new Error('Rule not found'); + } const expiryCaveat = { enforcer: TimestampEnforcer, @@ -26,8 +32,7 @@ describe('erc20-token-periodic rule', () => { }; it('rejects duplicate ERC20PeriodTransferEnforcer caveats', () => { - const tokenAddress = - '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex; + const tokenAddress = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex; const terms = createERC20TokenPeriodTransferTerms( { tokenAddress, @@ -62,7 +67,7 @@ describe('erc20-token-periodic rule', () => { }); it('rejects truncated terms', () => { - const truncatedTerms = `0x${'a'.repeat(100)}` as Hex; // 50 bytes, need 116 + const truncatedTerms = `0x${'a'.repeat(100)}`; // 50 bytes, need 116 const caveats = [ expiryCaveat, { @@ -85,8 +90,7 @@ describe('erc20-token-periodic rule', () => { }); it('successfully decodes valid erc20-token-periodic caveats', () => { - const tokenAddress = - '0xcccccccccccccccccccccccccccccccccccccccc' as Hex; + const tokenAddress = '0xcccccccccccccccccccccccccccccccccccccccc' as Hex; const caveats = [ expiryCaveat, { @@ -119,12 +123,12 @@ describe('erc20-token-periodic rule', () => { }); it('rejects when periodDuration is 0', () => { - const tokenAddress = - '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex; - const periodAmountHex = (100n).toString(16).padStart(64, '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 terms = + `0x${tokenAddress.slice(2)}${periodAmountHex}${periodDurationZero}${startDateHex}` as Hex; const caveats = [ expiryCaveat, @@ -149,8 +153,7 @@ describe('erc20-token-periodic rule', () => { }); it('rejects when terms have trailing bytes', () => { - const tokenAddress = - '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Hex; + const tokenAddress = '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Hex; const validTerms = createERC20TokenPeriodTransferTerms( { tokenAddress, @@ -183,12 +186,12 @@ describe('erc20-token-periodic rule', () => { }); it('rejects when startTime is 0', () => { - const tokenAddress = - '0xdddddddddddddddddddddddddddddddddddddddd' as Hex; - const periodAmountHex = (100n).toString(16).padStart(64, '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 terms = + `0x${tokenAddress.slice(2)}${periodAmountHex}${periodDurationHex}${startTimeZero}` as Hex; const caveats = [ expiryCaveat, { diff --git a/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenPeriodic.ts b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenPeriodic.ts index df544e95626..3ca58d71044 100644 --- a/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenPeriodic.ts +++ b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenPeriodic.ts @@ -1,6 +1,7 @@ import { hexToNumber } from '@metamask/utils'; import type { Hex } from '@metamask/utils'; +import { makePermissionRule } from './makePermissionRule'; import type { ChecksumCaveat, ChecksumEnforcersByChainId, @@ -8,10 +9,12 @@ import type { PermissionRule, } from '../types'; import { getByteLength, getTermsByEnforcer, splitHex } from '../utils'; -import { makePermissionRule } from './makePermissionRule'; /** * Creates the erc20-token-periodic permission rule. + * + * @param enforcers - Checksummed enforcer addresses for the chain. + * @returns The erc20-token-periodic permission rule. */ export function makeErc20TokenPeriodicRule( enforcers: ChecksumEnforcersByChainId, @@ -38,6 +41,10 @@ export function makeErc20TokenPeriodicRule( /** * Decodes erc20-token-periodic permission data from caveats; throws on invalid. + * + * @param caveats - Caveats from the permission context (checksummed). + * @param enforcer - Address of the ERC20PeriodTransferEnforcer. + * @returns Decoded periodic terms (tokenAddress, periodAmount, periodDuration, startTime). */ export function decodeErc20Periodic( caveats: ChecksumCaveat[], @@ -48,9 +55,7 @@ export function decodeErc20Periodic( 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', - ); + throw new Error('Invalid erc20-token-periodic terms: expected 116 bytes'); } const [tokenAddress, periodAmount, periodDurationRaw, startTimeRaw] = diff --git a/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenRevocation.test.ts b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenRevocation.test.ts index 6d5218b6411..ef215b8195b 100644 --- a/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenRevocation.test.ts +++ b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenRevocation.test.ts @@ -1,7 +1,10 @@ import { createTimestampTerms } from '@metamask/delegation-core'; -import { CHAIN_ID, DELEGATOR_CONTRACTS } from '@metamask/delegation-deployments'; +import { + CHAIN_ID, + DELEGATOR_CONTRACTS, +} from '@metamask/delegation-deployments'; -import { createPermissionRulesForContracts } from './index'; +import { createPermissionRulesForContracts } from '.'; describe('erc20-token-revocation rule', () => { const chainId = CHAIN_ID.sepolia; @@ -10,8 +13,11 @@ describe('erc20-token-revocation rule', () => { contracts; const permissionRules = createPermissionRulesForContracts(contracts); const rule = permissionRules.find( - (r) => r.permissionType === 'erc20-token-revocation', - )!; + (candidate) => candidate.permissionType === 'erc20-token-revocation', + ); + if (!rule) { + throw new Error('Rule not found'); + } const expiryCaveat = { enforcer: TimestampEnforcer, @@ -247,6 +253,6 @@ describe('erc20-token-revocation rule', () => { } expect(result.expiry).toBe(1720000); - expect(result.data).toEqual({}); + expect(result.data).toStrictEqual({}); }); }); diff --git a/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenRevocation.ts b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenRevocation.ts index 9cd153e5eb9..739f6997284 100644 --- a/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenRevocation.ts +++ b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenRevocation.ts @@ -1,5 +1,6 @@ import type { Hex } from '@metamask/utils'; +import { makePermissionRule } from './makePermissionRule'; import type { ChecksumCaveat, ChecksumEnforcersByChainId, @@ -7,10 +8,12 @@ import type { PermissionRule, } from '../types'; import { getByteLength, getTermsByEnforcer } from '../utils'; -import { makePermissionRule } from './makePermissionRule'; /** * Creates the erc20-token-revocation permission rule. + * + * @param enforcers - Checksummed enforcer addresses for the chain. + * @returns The erc20-token-revocation permission rule. */ export function makeErc20TokenRevocationRule( enforcers: ChecksumEnforcersByChainId, @@ -44,6 +47,11 @@ const ERC20_APPROVE_ZERO_AMOUNT_TERMS = /** * Decodes erc20-token-revocation permission data from caveats; throws on invalid. + * + * @param caveats - Caveats from the permission context (checksummed). + * @param allowedCalldataEnforcer - Address of the AllowedCalldataEnforcer. + * @param valueLteEnforcer - Address of the ValueLteEnforcer. + * @returns Empty object (revocation has no decoded data payload). */ export function decodeErc20Revocation( caveats: ChecksumCaveat[], @@ -51,10 +59,10 @@ export function decodeErc20Revocation( valueLteEnforcer: Hex, ): DecodedPermission['permission']['data'] { const allowedCalldataCaveats = caveats.filter( - (c) => c.enforcer === allowedCalldataEnforcer, + (caveat) => caveat.enforcer === allowedCalldataEnforcer, ); - const allowedCalldataTerms = allowedCalldataCaveats.map((c) => - c.terms.toLowerCase(), + const allowedCalldataTerms = allowedCalldataCaveats.map((caveat) => + caveat.terms.toLowerCase(), ); const hasApproveSelector = allowedCalldataTerms.includes( 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 e907eb6328e..1f37330971e 100644 --- a/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenStream.test.ts +++ b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenStream.test.ts @@ -3,16 +3,24 @@ import { createTimestampTerms, } from '@metamask/delegation-core'; import type { Hex } from '@metamask/delegation-core'; -import { CHAIN_ID, DELEGATOR_CONTRACTS } from '@metamask/delegation-deployments'; +import { + CHAIN_ID, + DELEGATOR_CONTRACTS, +} from '@metamask/delegation-deployments'; -import { createPermissionRulesForContracts } from './index'; +import { createPermissionRulesForContracts } from '.'; describe('erc20-token-stream rule', () => { const chainId = CHAIN_ID.sepolia; const contracts = DELEGATOR_CONTRACTS['1.3.0'][chainId]; const { TimestampEnforcer, ERC20StreamingEnforcer } = contracts; const permissionRules = createPermissionRulesForContracts(contracts); - const rule = permissionRules.find((r) => r.permissionType === 'erc20-token-stream')!; + const rule = permissionRules.find( + (candidate) => candidate.permissionType === 'erc20-token-stream', + ); + if (!rule) { + throw new Error('Rule not found'); + } const expiryCaveat = { enforcer: TimestampEnforcer, @@ -26,7 +34,13 @@ describe('erc20-token-stream rule', () => { it('rejects duplicate ERC20StreamingEnforcer caveats', () => { const tokenAddress = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex; const terms = createERC20StreamingTerms( - { tokenAddress, initialAmount: 1n, maxAmount: 2n, amountPerSecond: 1n, startTime: 1715664 }, + { + tokenAddress, + initialAmount: 1n, + maxAmount: 2n, + amountPerSecond: 1n, + startTime: 1715664, + }, { out: 'hex' }, ); const caveats = [ @@ -46,10 +60,14 @@ describe('erc20-token-stream rule', () => { }); it('rejects truncated terms', () => { - const truncatedTerms = `0x${'a'.repeat(100)}` as Hex; + const truncatedTerms = `0x${'a'.repeat(100)}`; const caveats = [ expiryCaveat, - { enforcer: ERC20StreamingEnforcer, terms: truncatedTerms, args: '0x' as const }, + { + enforcer: ERC20StreamingEnforcer, + terms: truncatedTerms, + args: '0x' as const, + }, ]; const result = rule.validateAndDecodePermission(caveats); expect(result.isValid).toBe(false); @@ -59,7 +77,9 @@ describe('erc20-token-stream rule', () => { throw new Error('Expected invalid result'); } - expect(result.error.message).toContain('Invalid erc20-token-stream terms: expected 148 bytes'); + expect(result.error.message).toContain( + 'Invalid erc20-token-stream terms: expected 148 bytes', + ); }); it('decodes zero token address', () => { @@ -69,7 +89,13 @@ describe('erc20-token-stream rule', () => { { enforcer: ERC20StreamingEnforcer, terms: createERC20StreamingTerms( - { tokenAddress: zeroAddress, initialAmount: 1n, maxAmount: 2n, amountPerSecond: 1n, startTime: 1715664 }, + { + tokenAddress: zeroAddress, + initialAmount: 1n, + maxAmount: 2n, + amountPerSecond: 1n, + startTime: 1715664, + }, { out: 'hex' }, ), args: '0x' as const, @@ -89,11 +115,12 @@ describe('erc20-token-stream rule', () => { 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 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 terms = + `0x${tokenAddress.slice(2)}${initialAmountHex}${maxAmountHex}${amountPerSecondHex}${startTimeHex}` as Hex; const caveats = [ expiryCaveat, { enforcer: ERC20StreamingEnforcer, terms, args: '0x' as const }, @@ -106,19 +133,31 @@ describe('erc20-token-stream rule', () => { throw new Error('Expected invalid result'); } - expect(result.error.message).toContain('maxAmount must be greater than initialAmount'); + 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 }, + { + tokenAddress, + initialAmount: 42n, + maxAmount: 100n, + amountPerSecond: 1n, + startTime: 1715664, + }, { out: 'hex' }, ); const termsWithTrailing = `${validTerms}deadbeef` as Hex; const caveats = [ expiryCaveat, - { enforcer: ERC20StreamingEnforcer, terms: termsWithTrailing, args: '0x' as const }, + { + enforcer: ERC20StreamingEnforcer, + terms: termsWithTrailing, + args: '0x' as const, + }, ]; const result = rule.validateAndDecodePermission(caveats); expect(result.isValid).toBe(false); @@ -128,16 +167,19 @@ describe('erc20-token-stream rule', () => { throw new Error('Expected invalid result'); } - expect(result.error.message).toContain('Invalid erc20-token-stream terms: expected 148 bytes'); + expect(result.error.message).toContain( + 'Invalid erc20-token-stream terms: expected 148 bytes', + ); }); 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 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 terms = + `0x${tokenAddress.slice(2)}${initialAmountHex}${maxAmountHex}${amountPerSecondHex}${startTimeZero}` as Hex; const caveats = [ expiryCaveat, { enforcer: ERC20StreamingEnforcer, terms, args: '0x' as const }, diff --git a/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenStream.ts b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenStream.ts index 4e5b9a069ea..d4f950ad015 100644 --- a/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenStream.ts +++ b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenStream.ts @@ -1,6 +1,7 @@ import { hexToBigInt, hexToNumber } from '@metamask/utils'; import type { Hex } from '@metamask/utils'; +import { makePermissionRule } from './makePermissionRule'; import type { ChecksumCaveat, ChecksumEnforcersByChainId, @@ -8,10 +9,12 @@ import type { PermissionRule, } from '../types'; import { getByteLength, getTermsByEnforcer, splitHex } from '../utils'; -import { makePermissionRule } from './makePermissionRule'; /** * Creates the erc20-token-stream permission rule. + * + * @param enforcers - Checksummed enforcer addresses for the chain. + * @returns The erc20-token-stream permission rule. */ export function makeErc20TokenStreamRule( enforcers: ChecksumEnforcersByChainId, @@ -31,13 +34,16 @@ export function makeErc20TokenStreamRule( [valueLteEnforcer, 1], [nonceEnforcer, 1], ]), - decodeData: (caveats) => - decodeErc20Stream(caveats, erc20StreamingEnforcer), + decodeData: (caveats) => decodeErc20Stream(caveats, erc20StreamingEnforcer), }); } /** * Decodes erc20-token-stream permission data from caveats; throws on invalid. + * + * @param caveats - Caveats from the permission context (checksummed). + * @param enforcer - Address of the ERC20StreamingEnforcer. + * @returns Decoded stream terms (tokenAddress, amounts, startTime). */ export function decodeErc20Stream( caveats: ChecksumCaveat[], @@ -48,15 +54,18 @@ export function decodeErc20Stream( const EXPECTED_TERMS_BYTELENGTH = 148; if (getByteLength(terms) !== EXPECTED_TERMS_BYTELENGTH) { - throw new Error( - 'Invalid erc20-token-stream terms: expected 148 bytes', - ); + 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 [ + tokenAddress, + initialAmount, + maxAmount, + amountPerSecond, + startTimeRaw, + ] = splitHex(terms, [20, 32, 32, 32, 32]); + + const startTime = hexToNumber(startTimeRaw); const initialAmountBigInt = hexToBigInt(initialAmount); const maxAmountBigInt = hexToBigInt(maxAmount); diff --git a/packages/gator-permissions-controller/src/decodePermission/rules/index.ts b/packages/gator-permissions-controller/src/decodePermission/rules/index.ts index 170a383ca17..b395e54fd51 100644 --- a/packages/gator-permissions-controller/src/decodePermission/rules/index.ts +++ b/packages/gator-permissions-controller/src/decodePermission/rules/index.ts @@ -1,13 +1,10 @@ -import type { - DeployedContractsByName, - PermissionRule, -} from '../types'; -import { getChecksumEnforcersByChainId } from '../utils'; import { makeErc20TokenPeriodicRule } from './erc20TokenPeriodic'; import { makeErc20TokenRevocationRule } from './erc20TokenRevocation'; import { makeErc20TokenStreamRule } from './erc20TokenStream'; import { makeNativeTokenPeriodicRule } from './nativeTokenPeriodic'; import { makeNativeTokenStreamRule } from './nativeTokenStream'; +import type { DeployedContractsByName, PermissionRule } from '../types'; +import { getChecksumEnforcersByChainId } from '../utils'; /** * Builds the canonical set of permission matching rules for a chain. @@ -32,5 +29,3 @@ export const createPermissionRulesForContracts = ( makeErc20TokenRevocationRule(enforcers), ]; }; - - 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 f95e179631f..7f0923ec533 100644 --- a/packages/gator-permissions-controller/src/decodePermission/rules/makePermissionRule.test.ts +++ b/packages/gator-permissions-controller/src/decodePermission/rules/makePermissionRule.test.ts @@ -1,17 +1,23 @@ import { createTimestampTerms } from '@metamask/delegation-core'; -import { CHAIN_ID, DELEGATOR_CONTRACTS } from '@metamask/delegation-deployments'; +import { + CHAIN_ID, + DELEGATOR_CONTRACTS, +} from '@metamask/delegation-deployments'; import type { Hex } from '@metamask/utils'; -import type { DecodedPermission } from '../types'; import { makePermissionRule } from './makePermissionRule'; +import type { DecodedPermission } from '../types'; describe('makePermissionRule', () => { const contracts = DELEGATOR_CONTRACTS['1.3.0'][CHAIN_ID.sepolia]; - const timestampEnforcer = contracts.TimestampEnforcer as Hex; - const requiredEnforcer = contracts.NonceEnforcer as Hex; + const timestampEnforcer = contracts.TimestampEnforcer; + const requiredEnforcer = contracts.NonceEnforcer; it('calls optional validate callback when provided and decoding succeeds', () => { - const validate = jest.fn(); + const validate = jest.fn< + void, + [DecodedPermission['permission']['data'], number | null] + >(); const decodeData = jest.fn().mockReturnValue({}); const rule = makePermissionRule({ @@ -46,7 +52,7 @@ describe('makePermissionRule', () => { throw new Error('Expected valid result'); } expect(result.expiry).toBe(1720000); - expect(result.data).toEqual({}); + expect(result.data).toStrictEqual({}); expect(decodeData).toHaveBeenCalled(); expect(validate).toHaveBeenCalledWith({}, 1720000); }); diff --git a/packages/gator-permissions-controller/src/decodePermission/rules/makePermissionRule.ts b/packages/gator-permissions-controller/src/decodePermission/rules/makePermissionRule.ts index 4243bb7c016..4ca80a5ac14 100644 --- a/packages/gator-permissions-controller/src/decodePermission/rules/makePermissionRule.ts +++ b/packages/gator-permissions-controller/src/decodePermission/rules/makePermissionRule.ts @@ -63,20 +63,22 @@ export function makePermissionRule({ optionalEnforcersSet, ); }, - validateAndDecodePermission(caveats: Caveat[]): ValidateAndDecodeResult { - const checksumCaveats: ChecksumCaveat[] = caveats.map((c) => ({ - ...c, - enforcer: getChecksumAddress(c.enforcer), + validateAndDecodePermission( + caveats: Caveat[], + ): ValidateAndDecodeResult { + const checksumCaveats: ChecksumCaveat[] = caveats.map((caveat) => ({ + ...caveat, + enforcer: getChecksumAddress(caveat.enforcer), })); try { let expiry: number | null = null; - + const expiryTerms = getTermsByEnforcer({ caveats: checksumCaveats, enforcer: timestampEnforcer, throwIfNotFound: false, }); - + if (expiryTerms) { expiry = extractExpiryFromCaveatTerms(expiryTerms); } @@ -85,8 +87,8 @@ export function makePermissionRule({ validate(data, expiry); } return { isValid: true, expiry, data }; - } catch (err) { - return { isValid: false, error: err as Error }; + } catch (caughtError) { + return { isValid: false, error: caughtError as Error }; } }, }; diff --git a/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenPeriodic.test.ts b/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenPeriodic.test.ts index 875e9b6489d..5f560c71a1f 100644 --- a/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenPeriodic.test.ts +++ b/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenPeriodic.test.ts @@ -3,9 +3,12 @@ import { createTimestampTerms, } from '@metamask/delegation-core'; import type { Hex } from '@metamask/delegation-core'; -import { CHAIN_ID, DELEGATOR_CONTRACTS } from '@metamask/delegation-deployments'; +import { + CHAIN_ID, + DELEGATOR_CONTRACTS, +} from '@metamask/delegation-deployments'; -import { createPermissionRulesForContracts } from './index'; +import { createPermissionRulesForContracts } from '.'; describe('native-token-periodic rule', () => { const chainId = CHAIN_ID.sepolia; @@ -13,8 +16,11 @@ describe('native-token-periodic rule', () => { const { TimestampEnforcer, NativeTokenPeriodTransferEnforcer } = contracts; const permissionRules = createPermissionRulesForContracts(contracts); const rule = permissionRules.find( - (r) => r.permissionType === 'native-token-periodic', - )!; + (candidate) => candidate.permissionType === 'native-token-periodic', + ); + if (!rule) { + throw new Error('Rule not found'); + } const expiryCaveat = { enforcer: TimestampEnforcer, @@ -59,7 +65,7 @@ describe('native-token-periodic rule', () => { }); it('rejects truncated terms', () => { - const truncatedTerms = `0x${'00'.repeat(40)}` as Hex; // 40 bytes, need 96 + const truncatedTerms = `0x${'00'.repeat(40)}`; // 40 bytes, need 96 const caveats = [ expiryCaveat, { @@ -144,10 +150,11 @@ describe('native-token-periodic rule', () => { }); it('rejects when periodDuration is 0', () => { - const periodAmountHex = (100n).toString(16).padStart(64, '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 terms = + `0x${periodAmountHex}${periodDurationZero}${startDateHex}` as Hex; const caveats = [ expiryCaveat, { @@ -170,10 +177,11 @@ describe('native-token-periodic rule', () => { }); it('rejects when startTime is 0', () => { - const periodAmountHex = (100n).toString(16).padStart(64, '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 terms = + `0x${periodAmountHex}${periodDurationHex}${startTimeZero}` as Hex; const caveats = [ expiryCaveat, { diff --git a/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenPeriodic.ts b/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenPeriodic.ts index ffc3578305f..f92b13755cf 100644 --- a/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenPeriodic.ts +++ b/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenPeriodic.ts @@ -1,6 +1,7 @@ import { hexToNumber } from '@metamask/utils'; import type { Hex } from '@metamask/utils'; +import { makePermissionRule } from './makePermissionRule'; import type { ChecksumCaveat, ChecksumEnforcersByChainId, @@ -8,10 +9,12 @@ import type { PermissionRule, } from '../types'; import { getByteLength, getTermsByEnforcer, splitHex } from '../utils'; -import { makePermissionRule } from './makePermissionRule'; /** * Creates the native-token-periodic permission rule. + * + * @param enforcers - Checksummed enforcer addresses for the chain. + * @returns The native-token-periodic permission rule. */ export function makeNativeTokenPeriodicRule( enforcers: ChecksumEnforcersByChainId, @@ -38,6 +41,10 @@ export function makeNativeTokenPeriodicRule( /** * Decodes native-token-periodic permission data from caveats; throws on invalid. + * + * @param caveats - Caveats from the permission context (checksummed). + * @param enforcer - Address of the NativeTokenPeriodTransferEnforcer. + * @returns Decoded periodic terms (periodAmount, periodDuration, startTime). */ export function decodeNativePeriodic( caveats: ChecksumCaveat[], @@ -48,14 +55,13 @@ export function decodeNativePeriodic( 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', - ); + throw new Error('Invalid native-token-periodic terms: expected 96 bytes'); } - const [periodAmount, periodDurationRaw, startTimeRaw] = splitHex(terms, [ - 32, 32, 32, - ]); + const [periodAmount, periodDurationRaw, startTimeRaw] = splitHex( + terms, + [32, 32, 32], + ); const periodDuration = hexToNumber(periodDurationRaw); const startTime = hexToNumber(startTimeRaw); diff --git a/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenStream.test.ts b/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenStream.test.ts index 1760d378ec6..0cdc5b0b9ae 100644 --- a/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenStream.test.ts +++ b/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenStream.test.ts @@ -3,16 +3,24 @@ import { createTimestampTerms, } from '@metamask/delegation-core'; import type { Hex } from '@metamask/delegation-core'; -import { CHAIN_ID, DELEGATOR_CONTRACTS } from '@metamask/delegation-deployments'; +import { + CHAIN_ID, + DELEGATOR_CONTRACTS, +} from '@metamask/delegation-deployments'; -import { createPermissionRulesForContracts } from './index'; +import { createPermissionRulesForContracts } from '.'; describe('native-token-stream rule', () => { const chainId = CHAIN_ID.sepolia; const contracts = DELEGATOR_CONTRACTS['1.3.0'][chainId]; const { TimestampEnforcer, NativeTokenStreamingEnforcer } = contracts; const permissionRules = createPermissionRulesForContracts(contracts); - const rule = permissionRules.find((r) => r.permissionType === 'native-token-stream')!; + const rule = permissionRules.find( + (candidate) => candidate.permissionType === 'native-token-stream', + ); + if (!rule) { + throw new Error('Rule not found'); + } const expiryCaveat = { enforcer: TimestampEnforcer, @@ -92,7 +100,7 @@ describe('native-token-stream rule', () => { }); it('rejects native-token-stream terms shorter than expected', () => { - const truncatedTerms = `0x${'00'.repeat(50)}` as Hex; + const truncatedTerms = `0x${'00'.repeat(50)}`; const caveats = [ expiryCaveat, { @@ -180,9 +188,13 @@ describe('native-token-stream rule', () => { }); it('rejects TimestampEnforcer terms with wrong length (66 required)', () => { - const badLengthTerms = `0x${'0'.repeat(65)}` as Hex; + const badLengthTerms = `0x${'0'.repeat(65)}`; const caveats = [ - { enforcer: TimestampEnforcer, terms: badLengthTerms, args: '0x' as const }, + { + enforcer: TimestampEnforcer, + terms: badLengthTerms, + args: '0x' as const, + }, { enforcer: NativeTokenStreamingEnforcer, terms: createNativeTokenStreamingTerms( @@ -206,7 +218,9 @@ describe('native-token-stream rule', () => { throw new Error('Expected invalid result'); } - expect(result.error.message).toContain('Invalid TimestampEnforcer terms length'); + expect(result.error.message).toContain( + 'Invalid TimestampEnforcer terms length', + ); }); it('rejects expiry timestampBeforeThreshold zero', () => { @@ -309,11 +323,12 @@ describe('native-token-stream rule', () => { }); it('rejects native-token-stream when maxAmount is zero', () => { - const initialAmountHex = (1n).toString(16).padStart(64, '0'); + const initialAmountHex = 1n.toString(16).padStart(64, '0'); const maxAmountZero = '0'.repeat(64); - const amountPerSecondHex = (1n).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}${maxAmountZero}${amountPerSecondHex}${startTimeHex}` as Hex; + const terms = + `0x${initialAmountHex}${maxAmountZero}${amountPerSecondHex}${startTimeHex}` as Hex; const caveats = [ expiryCaveat, { enforcer: NativeTokenStreamingEnforcer, terms, args: '0x' as const }, @@ -332,11 +347,12 @@ describe('native-token-stream rule', () => { }); 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 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 terms = + `0x${initialAmountHex}${maxAmountHex}${amountPerSecondZero}${startTimeHex}` as Hex; const caveats = [ expiryCaveat, { enforcer: NativeTokenStreamingEnforcer, terms, args: '0x' as const }, @@ -355,8 +371,8 @@ describe('native-token-stream rule', () => { }); 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 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; diff --git a/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenStream.ts b/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenStream.ts index b6dfde20491..14a9ba656d4 100644 --- a/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenStream.ts +++ b/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenStream.ts @@ -1,6 +1,7 @@ import { hexToBigInt, hexToNumber } from '@metamask/utils'; import type { Hex } from '@metamask/utils'; +import { makePermissionRule } from './makePermissionRule'; import type { ChecksumCaveat, ChecksumEnforcersByChainId, @@ -8,10 +9,12 @@ import type { PermissionRule, } from '../types'; import { getByteLength, getTermsByEnforcer, splitHex } from '../utils'; -import { makePermissionRule } from './makePermissionRule'; /** * Creates the native-token-stream permission rule. + * + * @param enforcers - Checksummed enforcer addresses for the chain. + * @returns The native-token-stream permission rule. */ export function makeNativeTokenStreamRule( enforcers: ChecksumEnforcersByChainId, @@ -38,6 +41,10 @@ export function makeNativeTokenStreamRule( /** * Decodes native-token-stream permission data from caveats; throws on invalid. + * + * @param caveats - Caveats from the permission context (checksummed). + * @param enforcer - Address of the NativeTokenStreamingEnforcer. + * @returns Decoded stream terms (amounts, startTime). */ export function decodeNativeStream( caveats: ChecksumCaveat[], @@ -48,9 +55,7 @@ export function decodeNativeStream( 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', - ); + throw new Error('Invalid native-token-stream terms: expected 128 bytes'); } const [initialAmount, maxAmount, amountPerSecond, startTimeRaw] = splitHex( diff --git a/packages/gator-permissions-controller/src/decodePermission/types.ts b/packages/gator-permissions-controller/src/decodePermission/types.ts index af83104953f..0010e6a041e 100644 --- a/packages/gator-permissions-controller/src/decodePermission/types.ts +++ b/packages/gator-permissions-controller/src/decodePermission/types.ts @@ -1,8 +1,8 @@ -import type { Caveat } from '@metamask/delegation-core'; import type { PermissionRequest, PermissionTypes, } from '@metamask/7715-permission-types'; +import type { Caveat } from '@metamask/delegation-core'; import type { DELEGATOR_CONTRACTS } from '@metamask/delegation-deployments'; import type { Hex } from '@metamask/utils'; @@ -99,4 +99,3 @@ export type PermissionRule = { caveats: Caveat[], ) => ValidateAndDecodeResult; }; - diff --git a/packages/gator-permissions-controller/src/decodePermission/utils.test.ts b/packages/gator-permissions-controller/src/decodePermission/utils.test.ts index 3d640bea277..933fa07b580 100644 --- a/packages/gator-permissions-controller/src/decodePermission/utils.test.ts +++ b/packages/gator-permissions-controller/src/decodePermission/utils.test.ts @@ -2,8 +2,8 @@ import type { Caveat } from '@metamask/delegation-core'; import { getChecksumAddress } from '@metamask/utils'; import type { Hex } from '@metamask/utils'; -import type { DeployedContractsByName } from './types'; import { createPermissionRulesForContracts } from './rules'; +import type { DeployedContractsByName } from './types'; import { getChecksumEnforcersByChainId, getTermsByEnforcer, @@ -209,9 +209,12 @@ describe('createPermissionRulesForChainId', () => { } const nativeStreamRule = rules.find( - (r) => r.permissionType === 'native-token-stream', + (candidate) => candidate.permissionType === 'native-token-stream', ); expect(nativeStreamRule).toBeDefined(); + if (!nativeStreamRule) { + throw new Error('Rule not found'); + } const matchingCaveatAddresses: Hex[] = [ nativeTokenStreamingEnforcer, @@ -219,7 +222,7 @@ describe('createPermissionRulesForChainId', () => { nonceEnforcer, timestampEnforcer, ]; - expect(nativeStreamRule!.caveatAddressesMatch(matchingCaveatAddresses)).toBe( + expect(nativeStreamRule.caveatAddressesMatch(matchingCaveatAddresses)).toBe( true, ); }); diff --git a/packages/gator-permissions-controller/src/decodePermission/utils.ts b/packages/gator-permissions-controller/src/decodePermission/utils.ts index 10b38825002..6d344d739a4 100644 --- a/packages/gator-permissions-controller/src/decodePermission/utils.ts +++ b/packages/gator-permissions-controller/src/decodePermission/utils.ts @@ -24,11 +24,12 @@ const ENFORCER_CONTRACT_NAMES = { /** * Get the byte length of a hex string. - * @param hex - The hex string to get the byte length of. + * + * @param hexString - The hex string to get the byte length of. * @returns The byte length of the hex string. */ -export const getByteLength = (hex: Hex): number => { - return (hex.length - 2) / 2; +export const getByteLength = (hexString: Hex): number => { + return (hexString.length - 2) / 2; }; /** @@ -128,6 +129,9 @@ export const extractExpiryFromCaveatTerms = (terms: Hex): number => { /** * Builds enforcer counts and set from caveat addresses (checksummed). * Used by caveatAddressesMatch. + * + * @param caveatAddresses - List of enforcer contract addresses (hex). + * @returns Counts per enforcer and set of unique enforcers. */ export function buildEnforcerCountsAndSet(caveatAddresses: Hex[]): { counts: Map; @@ -143,6 +147,12 @@ export function buildEnforcerCountsAndSet(caveatAddresses: Hex[]): { /** * Returns true if the given counts/set match the rule (required counts exact, * no enforcer outside required + optional). + * + * @param counts - Map of enforcer address to occurrence count. + * @param enforcersSet - Set of unique enforcer addresses present. + * @param requiredEnforcers - Map of required enforcer to required count. + * @param optionalEnforcers - Set of optional enforcer addresses. + * @returns True if the counts match the rule. */ export function enforcersMatchRule( counts: Map, @@ -155,10 +165,14 @@ export function enforcersMatchRule( ...requiredEnforcers.keys(), ]); for (const addr of enforcersSet) { - if (!allowedEnforcers.has(addr)) return false; + if (!allowedEnforcers.has(addr)) { + return false; + } } for (const [addr, requiredCount] of requiredEnforcers.entries()) { - if ((counts.get(addr) ?? 0) !== requiredCount) return false; + if ((counts.get(addr) ?? 0) !== requiredCount) { + return false; + } } return true; } From 42672d1309060fbe3079fac1cc076963d4ae29ee Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Tue, 3 Mar 2026 09:33:11 +1300 Subject: [PATCH 04/13] Adds validation for valueLte and exactCalldata caveats Plus minor changes: - Remove redundant amendment to ChecksumCaveat type - Remove unused ValidateDecodedPermission type - Fixes controller tests that expected the controller to self-report GatorPermissionsSnap id - Make decode functions internal, and rename to align with public interface --- .../src/GatorPermissionsController.test.ts | 5 +- .../src/decodePermission/index.ts | 1 - .../rules/erc20TokenPeriodic.test.ts | 55 +++++++++++++++- .../rules/erc20TokenPeriodic.ts | 49 +++++++++++---- .../rules/erc20TokenRevocation.ts | 44 +++++++------ .../rules/erc20TokenStream.test.ts | 56 ++++++++++++++++- .../rules/erc20TokenStream.ts | 48 ++++++++++---- .../rules/makePermissionRule.test.ts | 15 ++--- .../rules/makePermissionRule.ts | 27 ++++---- .../rules/nativeTokenPeriodic.test.ts | 56 ++++++++++++++++- .../rules/nativeTokenPeriodic.ts | 43 +++++++++---- .../rules/nativeTokenStream.test.ts | 63 ++++++++++++++++++- .../rules/nativeTokenStream.ts | 43 +++++++++---- .../src/decodePermission/types.ts | 11 +--- .../src/decodePermission/utils.ts | 14 +++++ 15 files changed, 414 insertions(+), 116 deletions(-) diff --git a/packages/gator-permissions-controller/src/GatorPermissionsController.test.ts b/packages/gator-permissions-controller/src/GatorPermissionsController.test.ts index 15d6a971f2b..d4cac97d640 100644 --- a/packages/gator-permissions-controller/src/GatorPermissionsController.test.ts +++ b/packages/gator-permissions-controller/src/GatorPermissionsController.test.ts @@ -43,6 +43,7 @@ const MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID = 'local:http://localhost:8082' as SnapId; const DEFAULT_TEST_CONFIG = { + gatorPermissionsProviderSnapId: MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, supportedPermissionTypes: [ 'native-token-stream', 'native-token-periodic', @@ -735,7 +736,7 @@ describe('GatorPermissionsController', () => { ); // Enforcers match native-token-stream but stream terms are truncated (invalid) - const truncatedStreamTerms = `0x${'00'.repeat(50)}`; + const truncatedStreamTerms: Hex = `0x${'00'.repeat(50)}`; const caveats = [ { enforcer: TimestampEnforcer, @@ -753,7 +754,7 @@ describe('GatorPermissionsController', () => { expect(() => controller.decodePermissionFromPermissionContextForOrigin({ - origin: controller.permissionsProviderSnapId, + origin: MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, chainId, delegation: { delegate: delegatorAddressA, diff --git a/packages/gator-permissions-controller/src/decodePermission/index.ts b/packages/gator-permissions-controller/src/decodePermission/index.ts index 4b60d0722eb..0088d560f3f 100644 --- a/packages/gator-permissions-controller/src/decodePermission/index.ts +++ b/packages/gator-permissions-controller/src/decodePermission/index.ts @@ -8,5 +8,4 @@ export type { DecodedPermission, PermissionRule, ValidateAndDecodeResult, - ValidateDecodedPermission, } from './types'; 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 5f9f3d4eb2f..ad35f506351 100644 --- a/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenPeriodic.test.ts +++ b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenPeriodic.test.ts @@ -9,11 +9,13 @@ import { } from '@metamask/delegation-deployments'; import { createPermissionRulesForContracts } from '.'; +import { ZERO_32_BYTES } from '../utils'; describe('erc20-token-periodic rule', () => { const chainId = CHAIN_ID.sepolia; const contracts = DELEGATOR_CONTRACTS['1.3.0'][chainId]; - const { TimestampEnforcer, ERC20PeriodTransferEnforcer } = contracts; + const { TimestampEnforcer, ERC20PeriodTransferEnforcer, ValueLteEnforcer } = + contracts; const permissionRules = createPermissionRulesForContracts(contracts); const rule = permissionRules.find( (candidate) => candidate.permissionType === 'erc20-token-periodic', @@ -31,6 +33,12 @@ describe('erc20-token-periodic rule', () => { 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( @@ -44,6 +52,7 @@ describe('erc20-token-periodic rule', () => { ); const caveats = [ expiryCaveat, + valueLteCaveat, { enforcer: ERC20PeriodTransferEnforcer, terms, @@ -67,9 +76,10 @@ describe('erc20-token-periodic rule', () => { }); it('rejects truncated terms', () => { - const truncatedTerms = `0x${'a'.repeat(100)}`; // 50 bytes, need 116 + const truncatedTerms: Hex = `0x${'a'.repeat(100)}`; // 50 bytes, need 116 const caveats = [ expiryCaveat, + valueLteCaveat, { enforcer: ERC20PeriodTransferEnforcer, terms: truncatedTerms, @@ -89,10 +99,48 @@ describe('erc20-token-periodic rule', () => { ); }); + 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 = 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 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( @@ -132,6 +180,7 @@ describe('erc20-token-periodic rule', () => { const caveats = [ expiryCaveat, + valueLteCaveat, { enforcer: ERC20PeriodTransferEnforcer, terms, @@ -166,6 +215,7 @@ describe('erc20-token-periodic rule', () => { const termsWithTrailing = `${validTerms}deadbeef` as Hex; const caveats = [ expiryCaveat, + valueLteCaveat, { enforcer: ERC20PeriodTransferEnforcer, terms: termsWithTrailing, @@ -194,6 +244,7 @@ describe('erc20-token-periodic rule', () => { `0x${tokenAddress.slice(2)}${periodAmountHex}${periodDurationHex}${startTimeZero}` as Hex; const caveats = [ expiryCaveat, + valueLteCaveat, { enforcer: ERC20PeriodTransferEnforcer, terms, diff --git a/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenPeriodic.ts b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenPeriodic.ts index 3ca58d71044..84f4bdeb55d 100644 --- a/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenPeriodic.ts +++ b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenPeriodic.ts @@ -8,7 +8,12 @@ import type { DecodedPermission, PermissionRule, } from '../types'; -import { getByteLength, getTermsByEnforcer, splitHex } from '../utils'; +import { + getByteLength, + getTermsByEnforcer, + splitHex, + ZERO_32_BYTES, +} from '../utils'; /** * Creates the erc20-token-periodic permission rule. @@ -29,13 +34,16 @@ export function makeErc20TokenPeriodicRule( permissionType: 'erc20-token-periodic', optionalEnforcers: [timestampEnforcer], timestampEnforcer, - requiredEnforcers: new Map([ - [erc20PeriodicEnforcer, 1], - [valueLteEnforcer, 1], - [nonceEnforcer, 1], - ]), - decodeData: (caveats) => - decodeErc20Periodic(caveats, erc20PeriodicEnforcer), + requiredEnforcers: { + [erc20PeriodicEnforcer]: 1, + [valueLteEnforcer]: 1, + [nonceEnforcer]: 1, + }, + validateAndDecodeData: (caveats) => + validateAndDecodeData(caveats, { + erc20PeriodicEnforcer, + valueLteEnforcer, + }), }); } @@ -43,14 +51,29 @@ export function makeErc20TokenPeriodicRule( * Decodes erc20-token-periodic permission data from caveats; throws on invalid. * * @param caveats - Caveats from the permission context (checksummed). - * @param enforcer - Address of the ERC20PeriodTransferEnforcer. - * @returns Decoded periodic terms (tokenAddress, periodAmount, periodDuration, startTime). + * @param enforcers - Addresses of the enforcers. + * @param enforcers.erc20PeriodicEnforcer - Address of the ERC20PeriodicEnforcer. + * @param enforcers.valueLteEnforcer - Address of the ValueLteEnforcer. + * @returns Decoded periodic terms. */ -export function decodeErc20Periodic( +function validateAndDecodeData( caveats: ChecksumCaveat[], - enforcer: Hex, + enforcers: { erc20PeriodicEnforcer: Hex; valueLteEnforcer: Hex }, ): DecodedPermission['permission']['data'] { - const terms = getTermsByEnforcer({ caveats, enforcer }); + const { erc20PeriodicEnforcer, valueLteEnforcer } = enforcers; + + 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 diff --git a/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenRevocation.ts b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenRevocation.ts index 739f6997284..767ed3544de 100644 --- a/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenRevocation.ts +++ b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenRevocation.ts @@ -7,7 +7,13 @@ import type { DecodedPermission, PermissionRule, } from '../types'; -import { getByteLength, getTermsByEnforcer } from '../utils'; +import { + ERC20_APPROVE_SELECTOR_TERMS, + ERC20_APPROVE_ZERO_AMOUNT_TERMS, + getByteLength, + getTermsByEnforcer, + ZERO_32_BYTES, +} from '../utils'; /** * Creates the erc20-token-revocation permission rule. @@ -28,36 +34,34 @@ export function makeErc20TokenRevocationRule( permissionType: 'erc20-token-revocation', optionalEnforcers: [timestampEnforcer], timestampEnforcer, - requiredEnforcers: new Map([ - [allowedCalldataEnforcer, 2], - [valueLteEnforcer, 1], - [nonceEnforcer, 1], - ]), - decodeData: (caveats) => - decodeErc20Revocation(caveats, allowedCalldataEnforcer, valueLteEnforcer), + requiredEnforcers: { + [allowedCalldataEnforcer]: 2, + [valueLteEnforcer]: 1, + [nonceEnforcer]: 1, + }, + validateAndDecodeData: (caveats) => + validateAndDecodeData(caveats, { + allowedCalldataEnforcer, + valueLteEnforcer, + }), }); } -const ZERO_32_BYTES = - '0x0000000000000000000000000000000000000000000000000000000000000000' as const; -const ERC20_APPROVE_SELECTOR_TERMS = - '0x0000000000000000000000000000000000000000000000000000000000000000095ea7b3' as const; -const ERC20_APPROVE_ZERO_AMOUNT_TERMS = - '0x00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000' as const; - /** * Decodes erc20-token-revocation permission data from caveats; throws on invalid. * * @param caveats - Caveats from the permission context (checksummed). - * @param allowedCalldataEnforcer - Address of the AllowedCalldataEnforcer. - * @param valueLteEnforcer - Address of the ValueLteEnforcer. + * @param enforcers - Addresses of the enforcers. + * @param enforcers.allowedCalldataEnforcer - Address of the AllowedCalldataEnforcer. + * @param enforcers.valueLteEnforcer - Address of the ValueLteEnforcer. * @returns Empty object (revocation has no decoded data payload). */ -export function decodeErc20Revocation( +function validateAndDecodeData( caveats: ChecksumCaveat[], - allowedCalldataEnforcer: Hex, - valueLteEnforcer: Hex, + enforcers: { allowedCalldataEnforcer: Hex; valueLteEnforcer: Hex }, ): DecodedPermission['permission']['data'] { + const { allowedCalldataEnforcer, valueLteEnforcer } = enforcers; + const allowedCalldataCaveats = caveats.filter( (caveat) => caveat.enforcer === allowedCalldataEnforcer, ); 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 1f37330971e..c961de0f4ca 100644 --- a/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenStream.test.ts +++ b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenStream.test.ts @@ -9,11 +9,13 @@ import { } from '@metamask/delegation-deployments'; import { createPermissionRulesForContracts } from '.'; +import { ZERO_32_BYTES } from '../utils'; describe('erc20-token-stream rule', () => { const chainId = CHAIN_ID.sepolia; const contracts = DELEGATOR_CONTRACTS['1.3.0'][chainId]; - const { TimestampEnforcer, ERC20StreamingEnforcer } = contracts; + const { TimestampEnforcer, ERC20StreamingEnforcer, ValueLteEnforcer } = + contracts; const permissionRules = createPermissionRulesForContracts(contracts); const rule = permissionRules.find( (candidate) => candidate.permissionType === 'erc20-token-stream', @@ -31,6 +33,12 @@ describe('erc20-token-stream rule', () => { 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( @@ -45,6 +53,7 @@ describe('erc20-token-stream rule', () => { ); const caveats = [ expiryCaveat, + valueLteCaveat, { enforcer: ERC20StreamingEnforcer, terms, args: '0x' as const }, { enforcer: ERC20StreamingEnforcer, terms, args: '0x' as const }, ]; @@ -60,9 +69,10 @@ describe('erc20-token-stream rule', () => { }); it('rejects truncated terms', () => { - const truncatedTerms = `0x${'a'.repeat(100)}`; + const truncatedTerms: Hex = `0x${'a'.repeat(100)}`; const caveats = [ expiryCaveat, + valueLteCaveat, { enforcer: ERC20StreamingEnforcer, terms: truncatedTerms, @@ -82,10 +92,49 @@ describe('erc20-token-stream rule', () => { ); }); + 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 = 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 value-lte terms'); + expect(result.error.message).toContain('must be'); + }); + it('decodes zero token address', () => { const zeroAddress = '0x0000000000000000000000000000000000000000' as Hex; const caveats = [ expiryCaveat, + valueLteCaveat, { enforcer: ERC20StreamingEnforcer, terms: createERC20StreamingTerms( @@ -123,6 +172,7 @@ describe('erc20-token-stream rule', () => { `0x${tokenAddress.slice(2)}${initialAmountHex}${maxAmountHex}${amountPerSecondHex}${startTimeHex}` as Hex; const caveats = [ expiryCaveat, + valueLteCaveat, { enforcer: ERC20StreamingEnforcer, terms, args: '0x' as const }, ]; const result = rule.validateAndDecodePermission(caveats); @@ -153,6 +203,7 @@ describe('erc20-token-stream rule', () => { const termsWithTrailing = `${validTerms}deadbeef` as Hex; const caveats = [ expiryCaveat, + valueLteCaveat, { enforcer: ERC20StreamingEnforcer, terms: termsWithTrailing, @@ -182,6 +233,7 @@ describe('erc20-token-stream rule', () => { `0x${tokenAddress.slice(2)}${initialAmountHex}${maxAmountHex}${amountPerSecondHex}${startTimeZero}` as Hex; const caveats = [ expiryCaveat, + valueLteCaveat, { enforcer: ERC20StreamingEnforcer, terms, args: '0x' as const }, ]; const result = rule.validateAndDecodePermission(caveats); diff --git a/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenStream.ts b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenStream.ts index d4f950ad015..d84bc398c9d 100644 --- a/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenStream.ts +++ b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenStream.ts @@ -8,7 +8,12 @@ import type { DecodedPermission, PermissionRule, } from '../types'; -import { getByteLength, getTermsByEnforcer, splitHex } from '../utils'; +import { + getByteLength, + getTermsByEnforcer, + splitHex, + ZERO_32_BYTES, +} from '../utils'; /** * Creates the erc20-token-stream permission rule. @@ -29,12 +34,16 @@ export function makeErc20TokenStreamRule( permissionType: 'erc20-token-stream', optionalEnforcers: [timestampEnforcer], timestampEnforcer, - requiredEnforcers: new Map([ - [erc20StreamingEnforcer, 1], - [valueLteEnforcer, 1], - [nonceEnforcer, 1], - ]), - decodeData: (caveats) => decodeErc20Stream(caveats, erc20StreamingEnforcer), + requiredEnforcers: { + [erc20StreamingEnforcer]: 1, + [valueLteEnforcer]: 1, + [nonceEnforcer]: 1, + }, + validateAndDecodeData: (caveats) => + validateAndDecodeData(caveats, { + erc20StreamingEnforcer, + valueLteEnforcer, + }), }); } @@ -42,14 +51,29 @@ export function makeErc20TokenStreamRule( * Decodes erc20-token-stream permission data from caveats; throws on invalid. * * @param caveats - Caveats from the permission context (checksummed). - * @param enforcer - Address of the ERC20StreamingEnforcer. - * @returns Decoded stream terms (tokenAddress, amounts, startTime). + * @param enforcers - Addresses of the enforcers. + * @param enforcers.erc20StreamingEnforcer - Address of the ERC20StreamingEnforcer. + * @param enforcers.valueLteEnforcer - Address of the ValueLteEnforcer. + * @returns Decoded stream terms. */ -export function decodeErc20Stream( +function validateAndDecodeData( caveats: ChecksumCaveat[], - enforcer: Hex, + enforcers: { erc20StreamingEnforcer: Hex; valueLteEnforcer: Hex }, ): DecodedPermission['permission']['data'] { - const terms = getTermsByEnforcer({ caveats, enforcer }); + const { erc20StreamingEnforcer, valueLteEnforcer } = enforcers; + 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; 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 7f0923ec533..22fc901bc01 100644 --- a/packages/gator-permissions-controller/src/decodePermission/rules/makePermissionRule.test.ts +++ b/packages/gator-permissions-controller/src/decodePermission/rules/makePermissionRule.test.ts @@ -6,7 +6,6 @@ import { import type { Hex } from '@metamask/utils'; import { makePermissionRule } from './makePermissionRule'; -import type { DecodedPermission } from '../types'; describe('makePermissionRule', () => { const contracts = DELEGATOR_CONTRACTS['1.3.0'][CHAIN_ID.sepolia]; @@ -14,19 +13,14 @@ describe('makePermissionRule', () => { const requiredEnforcer = contracts.NonceEnforcer; it('calls optional validate callback when provided and decoding succeeds', () => { - const validate = jest.fn< - void, - [DecodedPermission['permission']['data'], number | null] - >(); - const decodeData = jest.fn().mockReturnValue({}); + const validateAndDecodeData = jest.fn().mockReturnValue({}); const rule = makePermissionRule({ permissionType: 'native-token-stream', timestampEnforcer, optionalEnforcers: [], - requiredEnforcers: new Map([[requiredEnforcer, 1]]), - decodeData, - validate, + requiredEnforcers: { [requiredEnforcer]: 1 }, + validateAndDecodeData, }); const caveats = [ @@ -53,7 +47,6 @@ describe('makePermissionRule', () => { } expect(result.expiry).toBe(1720000); expect(result.data).toStrictEqual({}); - expect(decodeData).toHaveBeenCalled(); - expect(validate).toHaveBeenCalledWith({}, 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 4ca80a5ac14..fb7c48485ed 100644 --- a/packages/gator-permissions-controller/src/decodePermission/rules/makePermissionRule.ts +++ b/packages/gator-permissions-controller/src/decodePermission/rules/makePermissionRule.ts @@ -8,7 +8,6 @@ import type { PermissionRule, PermissionType, ValidateAndDecodeResult, - ValidateDecodedPermission, } from '../types'; import { buildEnforcerCountsAndSet, @@ -26,8 +25,7 @@ import { * @param args.timestampEnforcer - Address of the TimestampEnforcer used to extract expiry. * @param args.permissionType - The permission type identifier. * @param args.requiredEnforcers - Map of required enforcer address to required count. - * @param args.decodeData - Callback to decode caveats into permission data; may throw. - * @param args.validate - Optional callback to validate decoded data and expiry; throw to reject. + * @param args.validateAndDecodeData - Callback to decode caveats into permission data; may throw. * @returns A permission rule with caveatAddressesMatch and validateAndDecodePermission. */ export function makePermissionRule({ @@ -35,22 +33,24 @@ export function makePermissionRule({ timestampEnforcer, permissionType, requiredEnforcers, - decodeData, - validate, + validateAndDecodeData, }: { optionalEnforcers: Hex[]; timestampEnforcer: Hex; permissionType: PermissionType; - requiredEnforcers: Map; - decodeData: ( + requiredEnforcers: Record; + validateAndDecodeData: ( caveats: ChecksumCaveat[], ) => DecodedPermission['permission']['data']; - validate?: ValidateDecodedPermission; }): PermissionRule { const optionalEnforcersSet = new Set(optionalEnforcers); + const requiredEnforcersMap = new Map( + Object.entries(requiredEnforcers), + ) as Map; + return { permissionType, - requiredEnforcers, + requiredEnforcers: requiredEnforcersMap, optionalEnforcers: optionalEnforcersSet, caveatAddressesMatch(caveatAddresses: Hex[]): boolean { const { counts, enforcersSet } = @@ -59,7 +59,7 @@ export function makePermissionRule({ return enforcersMatchRule( counts, enforcersSet, - requiredEnforcers, + requiredEnforcersMap, optionalEnforcersSet, ); }, @@ -82,10 +82,9 @@ export function makePermissionRule({ if (expiryTerms) { expiry = extractExpiryFromCaveatTerms(expiryTerms); } - const data = decodeData(checksumCaveats); - if (validate) { - validate(data, expiry); - } + + const data = validateAndDecodeData(checksumCaveats); + return { isValid: true, expiry, data }; } catch (caughtError) { return { isValid: false, error: caughtError as Error }; diff --git a/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenPeriodic.test.ts b/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenPeriodic.test.ts index 5f560c71a1f..b69fb8574cf 100644 --- a/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenPeriodic.test.ts +++ b/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenPeriodic.test.ts @@ -13,7 +13,11 @@ import { createPermissionRulesForContracts } from '.'; describe('native-token-periodic rule', () => { const chainId = CHAIN_ID.sepolia; const contracts = DELEGATOR_CONTRACTS['1.3.0'][chainId]; - const { TimestampEnforcer, NativeTokenPeriodTransferEnforcer } = contracts; + const { + TimestampEnforcer, + NativeTokenPeriodTransferEnforcer, + ExactCalldataEnforcer, + } = contracts; const permissionRules = createPermissionRulesForContracts(contracts); const rule = permissionRules.find( (candidate) => candidate.permissionType === 'native-token-periodic', @@ -31,6 +35,12 @@ describe('native-token-periodic rule', () => { args: '0x' as const, }; + const exactCalldataCaveat = { + enforcer: ExactCalldataEnforcer, + terms: '0x' as Hex, + args: '0x' as const, +}; + it('rejects duplicate NativeTokenPeriodTransferEnforcer caveats', () => { const terms = createNativeTokenPeriodTransferTerms( { @@ -42,6 +52,7 @@ describe('native-token-periodic rule', () => { ); const caveats = [ expiryCaveat, + exactCalldataCaveat, { enforcer: NativeTokenPeriodTransferEnforcer, terms, @@ -65,15 +76,18 @@ describe('native-token-periodic rule', () => { }); it('rejects truncated terms', () => { - const truncatedTerms = `0x${'00'.repeat(40)}`; // 40 bytes, need 96 + 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 = rule.validateAndDecodePermission(caveats); expect(result.isValid).toBe(false); @@ -99,6 +113,7 @@ describe('native-token-periodic rule', () => { const termsWithTrailing = `${validTerms}deadbeef` as Hex; const caveats = [ expiryCaveat, + exactCalldataCaveat, { enforcer: NativeTokenPeriodTransferEnforcer, terms: termsWithTrailing, @@ -118,9 +133,44 @@ describe('native-token-periodic rule', () => { ); }); + 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 = 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 exact-calldata terms: must be 0x', + ); + }); + it('successfully decodes valid native-token-periodic caveats', () => { const caveats = [ expiryCaveat, + exactCalldataCaveat, { enforcer: NativeTokenPeriodTransferEnforcer, terms: createNativeTokenPeriodTransferTerms( @@ -157,6 +207,7 @@ describe('native-token-periodic rule', () => { `0x${periodAmountHex}${periodDurationZero}${startDateHex}` as Hex; const caveats = [ expiryCaveat, + exactCalldataCaveat, { enforcer: NativeTokenPeriodTransferEnforcer, terms, @@ -184,6 +235,7 @@ describe('native-token-periodic rule', () => { `0x${periodAmountHex}${periodDurationHex}${startTimeZero}` as Hex; const caveats = [ expiryCaveat, + exactCalldataCaveat, { enforcer: NativeTokenPeriodTransferEnforcer, terms, diff --git a/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenPeriodic.ts b/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenPeriodic.ts index f92b13755cf..6a34e55ed25 100644 --- a/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenPeriodic.ts +++ b/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenPeriodic.ts @@ -29,13 +29,16 @@ export function makeNativeTokenPeriodicRule( permissionType: 'native-token-periodic', optionalEnforcers: [timestampEnforcer], timestampEnforcer, - requiredEnforcers: new Map([ - [nativeTokenPeriodicEnforcer, 1], - [exactCalldataEnforcer, 1], - [nonceEnforcer, 1], - ]), - decodeData: (caveats) => - decodeNativePeriodic(caveats, nativeTokenPeriodicEnforcer), + requiredEnforcers: { + [nativeTokenPeriodicEnforcer]: 1, + [exactCalldataEnforcer]: 1, + [nonceEnforcer]: 1, + }, + validateAndDecodeData: (caveats) => + validateAndDecodeData(caveats, { + nativeTokenPeriodicEnforcer, + exactCalldataEnforcer, + }), }); } @@ -43,14 +46,30 @@ export function makeNativeTokenPeriodicRule( * Decodes native-token-periodic permission data from caveats; throws on invalid. * * @param caveats - Caveats from the permission context (checksummed). - * @param enforcer - Address of the NativeTokenPeriodTransferEnforcer. - * @returns Decoded periodic terms (periodAmount, periodDuration, startTime). + * @param enforcers - Addresses of the enforcers. + * @param enforcers.nativeTokenPeriodicEnforcer - Address of the NativeTokenPeriodicEnforcer. + * @param enforcers.exactCalldataEnforcer - Address of the ExactCalldataEnforcer. + * @returns Decoded periodic terms. */ -export function decodeNativePeriodic( +function validateAndDecodeData( caveats: ChecksumCaveat[], - enforcer: Hex, + enforcers: { nativeTokenPeriodicEnforcer: Hex; exactCalldataEnforcer: Hex }, ): DecodedPermission['permission']['data'] { - const terms = getTermsByEnforcer({ caveats, enforcer }); + const { nativeTokenPeriodicEnforcer, exactCalldataEnforcer } = enforcers; + + 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 diff --git a/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenStream.test.ts b/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenStream.test.ts index 0cdc5b0b9ae..7b7f54c3bfe 100644 --- a/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenStream.test.ts +++ b/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenStream.test.ts @@ -13,7 +13,11 @@ import { createPermissionRulesForContracts } from '.'; describe('native-token-stream rule', () => { const chainId = CHAIN_ID.sepolia; const contracts = DELEGATOR_CONTRACTS['1.3.0'][chainId]; - const { TimestampEnforcer, NativeTokenStreamingEnforcer } = contracts; + const { + TimestampEnforcer, + NativeTokenStreamingEnforcer, + ExactCalldataEnforcer, + } = contracts; const permissionRules = createPermissionRulesForContracts(contracts); const rule = permissionRules.find( (candidate) => candidate.permissionType === 'native-token-stream', @@ -31,9 +35,16 @@ describe('native-token-stream rule', () => { 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({ @@ -72,6 +83,7 @@ describe('native-token-stream rule', () => { const invalidTerms = '0x00000000000000000000000000000000zz000000000000000000000000001a3b80' as Hex; const caveats = [ + exactCalldataCaveat, { enforcer: TimestampEnforcer, terms: invalidTerms, args: '0x' as const }, { enforcer: NativeTokenStreamingEnforcer, @@ -100,9 +112,10 @@ describe('native-token-stream rule', () => { }); it('rejects native-token-stream terms shorter than expected', () => { - const truncatedTerms = `0x${'00'.repeat(50)}`; + const truncatedTerms: Hex = `0x${'00'.repeat(50)}`; const caveats = [ expiryCaveat, + exactCalldataCaveat, { enforcer: NativeTokenStreamingEnforcer, terms: truncatedTerms, @@ -136,6 +149,7 @@ describe('native-token-stream rule', () => { const termsWithTrailing = `${validTerms}deadbeef` as Hex; const caveats = [ expiryCaveat, + exactCalldataCaveat, { enforcer: NativeTokenStreamingEnforcer, terms: termsWithTrailing, @@ -155,9 +169,45 @@ describe('native-token-stream rule', () => { ); }); + 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 = 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 exact-calldata terms: must be 0x', + ); + }); + it('successfully decodes valid native-token-stream caveats', () => { const caveats = [ expiryCaveat, + exactCalldataCaveat, { enforcer: NativeTokenStreamingEnforcer, terms: createNativeTokenStreamingTerms( @@ -188,8 +238,9 @@ describe('native-token-stream rule', () => { }); it('rejects TimestampEnforcer terms with wrong length (66 required)', () => { - const badLengthTerms = `0x${'0'.repeat(65)}`; + const badLengthTerms: Hex = `0x${'0'.repeat(65)}`; const caveats = [ + exactCalldataCaveat, { enforcer: TimestampEnforcer, terms: badLengthTerms, @@ -225,6 +276,7 @@ describe('native-token-stream rule', () => { it('rejects expiry timestampBeforeThreshold zero', () => { const caveats = [ + exactCalldataCaveat, { enforcer: TimestampEnforcer, terms: createTimestampTerms({ @@ -263,6 +315,7 @@ describe('native-token-stream rule', () => { it('rejects expiry timestampAfterThreshold non-zero', () => { const caveats = [ + exactCalldataCaveat, { enforcer: TimestampEnforcer, terms: createTimestampTerms({ @@ -306,6 +359,7 @@ describe('native-token-stream rule', () => { const caveats = [ expiryCaveat, + exactCalldataCaveat, { enforcer: NativeTokenStreamingEnforcer, terms, args: '0x' as const }, ]; @@ -331,6 +385,7 @@ describe('native-token-stream rule', () => { `0x${initialAmountHex}${maxAmountZero}${amountPerSecondHex}${startTimeHex}` as Hex; const caveats = [ expiryCaveat, + exactCalldataCaveat, { enforcer: NativeTokenStreamingEnforcer, terms, args: '0x' as const }, ]; const result = rule.validateAndDecodePermission(caveats); @@ -355,6 +410,7 @@ describe('native-token-stream rule', () => { `0x${initialAmountHex}${maxAmountHex}${amountPerSecondZero}${startTimeHex}` as Hex; const caveats = [ expiryCaveat, + exactCalldataCaveat, { enforcer: NativeTokenStreamingEnforcer, terms, args: '0x' as const }, ]; const result = rule.validateAndDecodePermission(caveats); @@ -378,6 +434,7 @@ describe('native-token-stream rule', () => { const caveats = [ expiryCaveat, + exactCalldataCaveat, { enforcer: NativeTokenStreamingEnforcer, terms, args: '0x' as const }, ]; diff --git a/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenStream.ts b/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenStream.ts index 14a9ba656d4..c382fb0c7b0 100644 --- a/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenStream.ts +++ b/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenStream.ts @@ -29,13 +29,16 @@ export function makeNativeTokenStreamRule( permissionType: 'native-token-stream', optionalEnforcers: [timestampEnforcer], timestampEnforcer, - requiredEnforcers: new Map([ - [nativeTokenStreamingEnforcer, 1], - [exactCalldataEnforcer, 1], - [nonceEnforcer, 1], - ]), - decodeData: (caveats) => - decodeNativeStream(caveats, nativeTokenStreamingEnforcer), + requiredEnforcers: { + [nativeTokenStreamingEnforcer]: 1, + [exactCalldataEnforcer]: 1, + [nonceEnforcer]: 1, + }, + validateAndDecodeData: (caveats) => + validateAndDecodeData(caveats, { + nativeTokenStreamingEnforcer, + exactCalldataEnforcer, + }), }); } @@ -43,14 +46,30 @@ export function makeNativeTokenStreamRule( * Decodes native-token-stream permission data from caveats; throws on invalid. * * @param caveats - Caveats from the permission context (checksummed). - * @param enforcer - Address of the NativeTokenStreamingEnforcer. - * @returns Decoded stream terms (amounts, startTime). + * @param enforcers - Addresses of the enforcers. + * @param enforcers.nativeTokenStreamingEnforcer - Address of the NativeTokenStreamingEnforcer. + * @param enforcers.exactCalldataEnforcer - Address of the ExactCalldataEnforcer. + * @returns Decoded stream terms. */ -export function decodeNativeStream( +function validateAndDecodeData( caveats: ChecksumCaveat[], - enforcer: Hex, + enforcers: { nativeTokenStreamingEnforcer: Hex; exactCalldataEnforcer: Hex }, ): DecodedPermission['permission']['data'] { - const terms = getTermsByEnforcer({ caveats, enforcer }); + const { nativeTokenStreamingEnforcer, exactCalldataEnforcer } = enforcers; + + 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 diff --git a/packages/gator-permissions-controller/src/decodePermission/types.ts b/packages/gator-permissions-controller/src/decodePermission/types.ts index 0010e6a041e..d6a6e8ac0dd 100644 --- a/packages/gator-permissions-controller/src/decodePermission/types.ts +++ b/packages/gator-permissions-controller/src/decodePermission/types.ts @@ -54,7 +54,7 @@ export type ChecksumEnforcersByChainId = { }; /** Caveat with checksummed enforcer address; used by rule decode functions. */ -export type ChecksumCaveat = Caveat & { enforcer: Hex }; +export type ChecksumCaveat = Caveat; /** * Result of validating and decoding permission terms from caveats. @@ -68,15 +68,6 @@ export type ValidateAndDecodeResult = } | { isValid: false; error: Error }; -/** - * Optional post-decode validation for a permission rule. Runs after decoding - * with the decoded data and expiry; throw to reject. - */ -export type ValidateDecodedPermission = ( - data: DecodedPermission['permission']['data'], - expiry: number | null, -) => void; - /** * A rule that defines the required and optional enforcers for a permission type, * and provides methods to test whether caveat addresses match the rule and to diff --git a/packages/gator-permissions-controller/src/decodePermission/utils.ts b/packages/gator-permissions-controller/src/decodePermission/utils.ts index 6d344d739a4..2f570ad5e4e 100644 --- a/packages/gator-permissions-controller/src/decodePermission/utils.ts +++ b/packages/gator-permissions-controller/src/decodePermission/utils.ts @@ -22,6 +22,20 @@ const ENFORCER_CONTRACT_NAMES = { AllowedCalldataEnforcer: 'AllowedCalldataEnforcer', }; +/** + * 32 bytes of zero (0x + 64 hex chars). + */ +export const ZERO_32_BYTES = + '0x0000000000000000000000000000000000000000000000000000000000000000' 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; + /** * Get the byte length of a hex string. * From 926429e1d923605fb3539528ebc2b628096af15f Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Tue, 3 Mar 2026 10:39:36 +1300 Subject: [PATCH 05/13] Each permission rule now derives it's internal enforcers type from the ChecksumEnforcersByChainId type rather than explicitly declaring a new type --- .../src/decodePermission/rules/erc20TokenPeriodic.ts | 6 ++++-- .../src/decodePermission/rules/erc20TokenRevocation.ts | 7 ++++--- .../src/decodePermission/rules/erc20TokenStream.ts | 6 ++++-- .../src/decodePermission/rules/nativeTokenPeriodic.test.ts | 2 +- .../src/decodePermission/rules/nativeTokenPeriodic.ts | 6 ++++-- .../src/decodePermission/rules/nativeTokenStream.ts | 6 ++++-- 6 files changed, 21 insertions(+), 12 deletions(-) diff --git a/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenPeriodic.ts b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenPeriodic.ts index 84f4bdeb55d..704644e8f47 100644 --- a/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenPeriodic.ts +++ b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenPeriodic.ts @@ -1,5 +1,4 @@ import { hexToNumber } from '@metamask/utils'; -import type { Hex } from '@metamask/utils'; import { makePermissionRule } from './makePermissionRule'; import type { @@ -58,7 +57,10 @@ export function makeErc20TokenPeriodicRule( */ function validateAndDecodeData( caveats: ChecksumCaveat[], - enforcers: { erc20PeriodicEnforcer: Hex; valueLteEnforcer: Hex }, + enforcers: Pick< + ChecksumEnforcersByChainId, + 'erc20PeriodicEnforcer' | 'valueLteEnforcer' + >, ): DecodedPermission['permission']['data'] { const { erc20PeriodicEnforcer, valueLteEnforcer } = enforcers; diff --git a/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenRevocation.ts b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenRevocation.ts index 767ed3544de..2456b7ca386 100644 --- a/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenRevocation.ts +++ b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenRevocation.ts @@ -1,5 +1,3 @@ -import type { Hex } from '@metamask/utils'; - import { makePermissionRule } from './makePermissionRule'; import type { ChecksumCaveat, @@ -58,7 +56,10 @@ export function makeErc20TokenRevocationRule( */ function validateAndDecodeData( caveats: ChecksumCaveat[], - enforcers: { allowedCalldataEnforcer: Hex; valueLteEnforcer: Hex }, + enforcers: Pick< + ChecksumEnforcersByChainId, + 'allowedCalldataEnforcer' | 'valueLteEnforcer' + >, ): DecodedPermission['permission']['data'] { const { allowedCalldataEnforcer, valueLteEnforcer } = enforcers; diff --git a/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenStream.ts b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenStream.ts index d84bc398c9d..3acca06c5fd 100644 --- a/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenStream.ts +++ b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenStream.ts @@ -1,5 +1,4 @@ import { hexToBigInt, hexToNumber } from '@metamask/utils'; -import type { Hex } from '@metamask/utils'; import { makePermissionRule } from './makePermissionRule'; import type { @@ -58,7 +57,10 @@ export function makeErc20TokenStreamRule( */ function validateAndDecodeData( caveats: ChecksumCaveat[], - enforcers: { erc20StreamingEnforcer: Hex; valueLteEnforcer: Hex }, + enforcers: Pick< + ChecksumEnforcersByChainId, + 'erc20StreamingEnforcer' | 'valueLteEnforcer' + >, ): DecodedPermission['permission']['data'] { const { erc20StreamingEnforcer, valueLteEnforcer } = enforcers; const valueLteTerms = getTermsByEnforcer({ diff --git a/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenPeriodic.test.ts b/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenPeriodic.test.ts index b69fb8574cf..13d99eb1ab4 100644 --- a/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenPeriodic.test.ts +++ b/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenPeriodic.test.ts @@ -39,7 +39,7 @@ describe('native-token-periodic rule', () => { enforcer: ExactCalldataEnforcer, terms: '0x' as Hex, args: '0x' as const, -}; + }; it('rejects duplicate NativeTokenPeriodTransferEnforcer caveats', () => { const terms = createNativeTokenPeriodTransferTerms( diff --git a/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenPeriodic.ts b/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenPeriodic.ts index 6a34e55ed25..1df9f6fdba4 100644 --- a/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenPeriodic.ts +++ b/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenPeriodic.ts @@ -1,5 +1,4 @@ import { hexToNumber } from '@metamask/utils'; -import type { Hex } from '@metamask/utils'; import { makePermissionRule } from './makePermissionRule'; import type { @@ -53,7 +52,10 @@ export function makeNativeTokenPeriodicRule( */ function validateAndDecodeData( caveats: ChecksumCaveat[], - enforcers: { nativeTokenPeriodicEnforcer: Hex; exactCalldataEnforcer: Hex }, + enforcers: Pick< + ChecksumEnforcersByChainId, + 'nativeTokenPeriodicEnforcer' | 'exactCalldataEnforcer' + >, ): DecodedPermission['permission']['data'] { const { nativeTokenPeriodicEnforcer, exactCalldataEnforcer } = enforcers; diff --git a/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenStream.ts b/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenStream.ts index c382fb0c7b0..7fc4d7c8284 100644 --- a/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenStream.ts +++ b/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenStream.ts @@ -1,5 +1,4 @@ import { hexToBigInt, hexToNumber } from '@metamask/utils'; -import type { Hex } from '@metamask/utils'; import { makePermissionRule } from './makePermissionRule'; import type { @@ -53,7 +52,10 @@ export function makeNativeTokenStreamRule( */ function validateAndDecodeData( caveats: ChecksumCaveat[], - enforcers: { nativeTokenStreamingEnforcer: Hex; exactCalldataEnforcer: Hex }, + enforcers: Pick< + ChecksumEnforcersByChainId, + 'nativeTokenStreamingEnforcer' | 'exactCalldataEnforcer' + >, ): DecodedPermission['permission']['data'] { const { nativeTokenStreamingEnforcer, exactCalldataEnforcer } = enforcers; From af21adb6d5be739909220318e182da901ededf3a Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Tue, 3 Mar 2026 13:16:41 +1300 Subject: [PATCH 06/13] Add test to ensure that the GatorPermissionsController instantiates without specifying snapId --- .../src/GatorPermissionsController.test.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/gator-permissions-controller/src/GatorPermissionsController.test.ts b/packages/gator-permissions-controller/src/GatorPermissionsController.test.ts index d4cac97d640..c3beef84e68 100644 --- a/packages/gator-permissions-controller/src/GatorPermissionsController.test.ts +++ b/packages/gator-permissions-controller/src/GatorPermissionsController.test.ts @@ -142,6 +142,26 @@ describe('GatorPermissionsController', () => { expect(controller.state.isFetchingGatorPermissions).toBe(false); }); + + it('instantiates successfully without gatorPermissionsProviderSnapId', () => { + const configWithoutSnapId = { + supportedPermissionTypes: DEFAULT_TEST_CONFIG.supportedPermissionTypes, + }; + + expect(() => { + new GatorPermissionsController({ + messenger: getGatorPermissionsControllerMessenger(), + config: configWithoutSnapId, + }); + }).not.toThrow(); + + const controller = new GatorPermissionsController({ + messenger: getGatorPermissionsControllerMessenger(), + config: configWithoutSnapId, + }); + expect(controller).toBeDefined(); + expect(controller.state.grantedPermissions).toStrictEqual([]); + }); }); describe('fetchAndUpdateGatorPermissions', () => { From 5b8f5d8d1ac82572abdff93b5f2264ad7377535b Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Tue, 3 Mar 2026 13:45:24 +1300 Subject: [PATCH 07/13] Update GatorPermissionsController test to not instantiate controller twice --- .../gator-permissions-controller/CHANGELOG.md | 4 ++ .../src/GatorPermissionsController.test.ts | 10 ++- .../rules/erc20TokenPeriodic.test.ts | 62 +++++++++++++++++++ .../rules/erc20TokenPeriodic.ts | 12 ++++ .../rules/erc20TokenStream.test.ts | 28 +++++++++ .../rules/erc20TokenStream.ts | 6 ++ .../rules/nativeTokenPeriodic.test.ts | 30 +++++++++ .../rules/nativeTokenPeriodic.ts | 6 ++ 8 files changed, 152 insertions(+), 6 deletions(-) diff --git a/packages/gator-permissions-controller/CHANGELOG.md b/packages/gator-permissions-controller/CHANGELOG.md index b3dd351a336..4eda942dfae 100644 --- a/packages/gator-permissions-controller/CHANGELOG.md +++ b/packages/gator-permissions-controller/CHANGELOG.md @@ -10,6 +10,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Bump `@metamask/transaction-controller` from `^62.17.0` to `^62.20.0` ([#7996](https://github.com/MetaMask/core/pull/7996), [#8005](https://github.com/MetaMask/core/pull/8005), [#8031](https://github.com/MetaMask/core/pull/8031) [#8104](https://github.com/MetaMask/core/pull/8104)) +- Improves permission validation during decoding ([#7844](https://github.com/MetaMask/core/pull/7844)) + - Validates `ExactCalldataEnforcer` and `ValueLteEnforcer` caveat terms + - Validates that `periodAmount` is positive in `erc20-token-periodic` and `native-token-periodic` permission types + - Validates that `tokenAddress` is a valid hex string in `erc20-token-periodic` and `erc20-token-stream` permission types ## [2.0.0] diff --git a/packages/gator-permissions-controller/src/GatorPermissionsController.test.ts b/packages/gator-permissions-controller/src/GatorPermissionsController.test.ts index c3beef84e68..8d443e19a19 100644 --- a/packages/gator-permissions-controller/src/GatorPermissionsController.test.ts +++ b/packages/gator-permissions-controller/src/GatorPermissionsController.test.ts @@ -148,19 +148,17 @@ describe('GatorPermissionsController', () => { supportedPermissionTypes: DEFAULT_TEST_CONFIG.supportedPermissionTypes, }; + let controller: GatorPermissionsController | undefined; + expect(() => { - new GatorPermissionsController({ + controller = new GatorPermissionsController({ messenger: getGatorPermissionsControllerMessenger(), config: configWithoutSnapId, }); }).not.toThrow(); - const controller = new GatorPermissionsController({ - messenger: getGatorPermissionsControllerMessenger(), - config: configWithoutSnapId, - }); expect(controller).toBeDefined(); - expect(controller.state.grantedPermissions).toStrictEqual([]); + expect(controller?.state.grantedPermissions).toStrictEqual([]); }); }); 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 ad35f506351..5b608be4d73 100644 --- a/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenPeriodic.test.ts +++ b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenPeriodic.test.ts @@ -263,4 +263,66 @@ describe('erc20-token-periodic rule', () => { '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 = 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: 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 704644e8f47..5cc51751f4e 100644 --- a/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenPeriodic.ts +++ b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenPeriodic.ts @@ -88,6 +88,18 @@ function validateAndDecodeData( const periodDuration = hexToNumber(periodDurationRaw); const startTime = hexToNumber(startTimeRaw); + if (!/^0x[0-9a-fA-F]{40}$/u.test(tokenAddress)) { + throw new Error( + 'Invalid erc20-token-periodic terms: tokenAddress must be a valid hex string', + ); + } + + if (hexToNumber(periodAmount) <= 0) { + 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', 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 c961de0f4ca..f1793063cfb 100644 --- a/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenStream.test.ts +++ b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenStream.test.ts @@ -248,4 +248,32 @@ 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 3acca06c5fd..f89f8ea1bf2 100644 --- a/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenStream.ts +++ b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenStream.ts @@ -95,6 +95,12 @@ function validateAndDecodeData( const initialAmountBigInt = hexToBigInt(initialAmount); const maxAmountBigInt = hexToBigInt(maxAmount); + if (!/^0x[0-9a-fA-F]{40}$/u.test(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/nativeTokenPeriodic.test.ts b/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenPeriodic.test.ts index 13d99eb1ab4..0fa484ba2f1 100644 --- a/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenPeriodic.test.ts +++ b/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenPeriodic.test.ts @@ -254,4 +254,34 @@ describe('native-token-periodic rule', () => { '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 = 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 native-token-periodic terms: periodAmount must be a positive number', + ); + }); }); diff --git a/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenPeriodic.ts b/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenPeriodic.ts index 1df9f6fdba4..33b4be704cb 100644 --- a/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenPeriodic.ts +++ b/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenPeriodic.ts @@ -86,6 +86,12 @@ function validateAndDecodeData( const periodDuration = hexToNumber(periodDurationRaw); const startTime = hexToNumber(startTimeRaw); + if (hexToNumber(periodAmount) <= 0) { + 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', From 25a111695bacb194fb1d74aeea86740a3f6ab720 Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Wed, 4 Mar 2026 13:44:52 +1300 Subject: [PATCH 08/13] Minor fixups: - use metamask/utils isHexAddress instead of regex to validate addresses - use hexToBigInt to validate tokenPeriod instead of hexToNumber --- .../src/decodePermission/rules/erc20TokenPeriodic.ts | 6 +++--- .../src/decodePermission/rules/erc20TokenStream.ts | 4 ++-- .../src/decodePermission/rules/nativeTokenPeriodic.ts | 7 ++++--- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenPeriodic.ts b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenPeriodic.ts index 5cc51751f4e..dd973505c2f 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 { hexToNumber } from '@metamask/utils'; +import { hexToBigInt, hexToNumber, isHexAddress } from '@metamask/utils'; import { makePermissionRule } from './makePermissionRule'; import type { @@ -88,13 +88,13 @@ function validateAndDecodeData( const periodDuration = hexToNumber(periodDurationRaw); const startTime = hexToNumber(startTimeRaw); - if (!/^0x[0-9a-fA-F]{40}$/u.test(tokenAddress)) { + if (!isHexAddress(tokenAddress)) { throw new Error( 'Invalid erc20-token-periodic terms: tokenAddress must be a valid hex string', ); } - if (hexToNumber(periodAmount) <= 0) { + if (hexToBigInt(periodAmount) <= 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.ts b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenStream.ts index f89f8ea1bf2..fb5f0b9650e 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 } from '@metamask/utils'; +import { hexToBigInt, hexToNumber, isHexAddress } from '@metamask/utils'; import { makePermissionRule } from './makePermissionRule'; import type { @@ -95,7 +95,7 @@ function validateAndDecodeData( const initialAmountBigInt = hexToBigInt(initialAmount); const maxAmountBigInt = hexToBigInt(maxAmount); - if (!/^0x[0-9a-fA-F]{40}$/u.test(tokenAddress)) { + if (!isHexAddress(tokenAddress)) { throw new Error( 'Invalid erc20-token-stream terms: tokenAddress must be a valid hex string', ); diff --git a/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenPeriodic.ts b/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenPeriodic.ts index 33b4be704cb..96d93d70fde 100644 --- a/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenPeriodic.ts +++ b/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenPeriodic.ts @@ -1,4 +1,4 @@ -import { hexToNumber } from '@metamask/utils'; +import { hexToBigInt, hexToNumber } from '@metamask/utils'; import { makePermissionRule } from './makePermissionRule'; import type { @@ -85,8 +85,9 @@ function validateAndDecodeData( ); const periodDuration = hexToNumber(periodDurationRaw); const startTime = hexToNumber(startTimeRaw); - - if (hexToNumber(periodAmount) <= 0) { + const periodAmountBigInt = hexToBigInt(periodAmount); + + if (periodAmountBigInt <= 0n) { throw new Error( 'Invalid native-token-periodic terms: periodAmount must be a positive number', ); From b8886fe610a9540f86309573da1bc59bfce21529 Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Wed, 4 Mar 2026 14:14:02 +1300 Subject: [PATCH 09/13] Add amountPerSecond validation to erc20tokenstream --- .../rules/erc20TokenStream.test.ts | 26 +++++++++++++++++++ .../rules/erc20TokenStream.ts | 8 +++++- 2 files changed, 33 insertions(+), 1 deletion(-) 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 f1793063cfb..27df36ff60e 100644 --- a/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenStream.test.ts +++ b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenStream.test.ts @@ -223,6 +223,32 @@ describe('erc20-token-stream rule', () => { ); }); + 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 = 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: 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'); diff --git a/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenStream.ts b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenStream.ts index fb5f0b9650e..4449dd47333 100644 --- a/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenStream.ts +++ b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenStream.ts @@ -94,7 +94,7 @@ function validateAndDecodeData( const startTime = hexToNumber(startTimeRaw); const initialAmountBigInt = hexToBigInt(initialAmount); 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', @@ -107,6 +107,12 @@ function validateAndDecodeData( ); } + 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', From a175e39bd71841b58fef75c300b42c8a92d02dae Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Wed, 4 Mar 2026 14:42:53 +1300 Subject: [PATCH 10/13] Trim whitespace --- .../src/decodePermission/rules/nativeTokenPeriodic.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenPeriodic.ts b/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenPeriodic.ts index 96d93d70fde..63b5fc86349 100644 --- a/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenPeriodic.ts +++ b/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenPeriodic.ts @@ -86,7 +86,7 @@ function validateAndDecodeData( 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', From 92eba5ca3fd3553bdcc76b74bd8b82b66535da9f Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Wed, 4 Mar 2026 19:10:54 +1300 Subject: [PATCH 11/13] Add missing validation to nativeTokenStream --- .../rules/erc20TokenStream.ts | 1 + .../rules/nativeTokenStream.test.ts | 25 +++++++++++++++++++ .../rules/nativeTokenStream.ts | 6 +++++ 3 files changed, 32 insertions(+) diff --git a/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenStream.ts b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenStream.ts index 4449dd47333..ec4d78d4cb7 100644 --- a/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenStream.ts +++ b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenStream.ts @@ -95,6 +95,7 @@ function validateAndDecodeData( const initialAmountBigInt = hexToBigInt(initialAmount); 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', diff --git a/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenStream.test.ts b/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenStream.test.ts index 7b7f54c3bfe..33e2025a892 100644 --- a/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenStream.test.ts +++ b/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenStream.test.ts @@ -352,6 +352,31 @@ describe('native-token-stream rule', () => { ); }); + 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 = 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 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'); diff --git a/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenStream.ts b/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenStream.ts index 7fc4d7c8284..7f723d1e7a2 100644 --- a/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenStream.ts +++ b/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenStream.ts @@ -88,6 +88,12 @@ function validateAndDecodeData( 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 (initialAmountBigInt <= 0n) { throw new Error( 'Invalid native-token-stream terms: initialAmount must be a positive number', From 4b81a926c7356cb9970848e5e1eda115c0ffef34 Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Thu, 5 Mar 2026 12:51:54 +1300 Subject: [PATCH 12/13] Remove unnecessary maxAmount validation (already indirectly validated). Add missing initialAmount validation. --- .../rules/erc20TokenStream.test.ts | 26 +++++++++++++++++++ .../rules/erc20TokenStream.ts | 6 +++++ .../rules/nativeTokenStream.test.ts | 25 ------------------ .../rules/nativeTokenStream.ts | 6 ----- 4 files changed, 32 insertions(+), 31 deletions(-) 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 27df36ff60e..1590c6c8b9e 100644 --- a/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenStream.test.ts +++ b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenStream.test.ts @@ -188,6 +188,32 @@ describe('erc20-token-stream rule', () => { ); }); + it('rejects when initialAmount is 0', () => { + const tokenAddress = '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Hex; + const initialAmountZero = '0'.repeat(64); + 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${tokenAddress.slice(2)}${initialAmountZero}${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: initialAmount must be a positive number', + ); + }); + it('rejects when terms have trailing bytes', () => { const tokenAddress = '0xcccccccccccccccccccccccccccccccccccccccc' as Hex; const validTerms = createERC20StreamingTerms( diff --git a/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenStream.ts b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenStream.ts index ec4d78d4cb7..8e525fe7146 100644 --- a/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenStream.ts +++ b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenStream.ts @@ -108,6 +108,12 @@ function validateAndDecodeData( ); } + if (initialAmountBigInt <= 0n) { + throw new Error( + 'Invalid erc20-token-stream terms: initialAmount must be a positive number', + ); + } + if (amountPerSecondBigInt <= 0n) { throw new Error( 'Invalid erc20-token-stream terms: amountPerSecond must be a positive number', diff --git a/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenStream.test.ts b/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenStream.test.ts index 33e2025a892..c6e3193fb4d 100644 --- a/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenStream.test.ts +++ b/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenStream.test.ts @@ -401,31 +401,6 @@ describe('native-token-stream rule', () => { ); }); - it('rejects native-token-stream when maxAmount is zero', () => { - const initialAmountHex = 1n.toString(16).padStart(64, '0'); - const maxAmountZero = '0'.repeat(64); - const amountPerSecondHex = 1n.toString(16).padStart(64, '0'); - const startTimeHex = (1715664).toString(16).padStart(64, '0'); - const terms = - `0x${initialAmountHex}${maxAmountZero}${amountPerSecondHex}${startTimeHex}` as Hex; - const caveats = [ - expiryCaveat, - exactCalldataCaveat, - { enforcer: NativeTokenStreamingEnforcer, 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 native-token-stream terms: maxAmount must be a positive number', - ); - }); - 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'); diff --git a/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenStream.ts b/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenStream.ts index 7f723d1e7a2..9699fafa333 100644 --- a/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenStream.ts +++ b/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenStream.ts @@ -100,12 +100,6 @@ function validateAndDecodeData( ); } - if (maxAmountBigInt <= 0n) { - throw new Error( - 'Invalid native-token-stream terms: maxAmount must be a positive number', - ); - } - if (amountPerSecondBigInt <= 0n) { throw new Error( 'Invalid native-token-stream terms: amountPerSecond must be a positive number', From c7ba9899024ee5ea6195af779e3ba4eb6ace728f Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Thu, 5 Mar 2026 13:03:53 +1300 Subject: [PATCH 13/13] Don't inline the hexToBigInt when validating --- packages/gator-permissions-controller/CHANGELOG.md | 2 +- .../src/decodePermission/rules/erc20TokenPeriodic.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/gator-permissions-controller/CHANGELOG.md b/packages/gator-permissions-controller/CHANGELOG.md index 4eda942dfae..2fa3360aadf 100644 --- a/packages/gator-permissions-controller/CHANGELOG.md +++ b/packages/gator-permissions-controller/CHANGELOG.md @@ -9,11 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Bump `@metamask/transaction-controller` from `^62.17.0` to `^62.20.0` ([#7996](https://github.com/MetaMask/core/pull/7996), [#8005](https://github.com/MetaMask/core/pull/8005), [#8031](https://github.com/MetaMask/core/pull/8031) [#8104](https://github.com/MetaMask/core/pull/8104)) - Improves permission validation during decoding ([#7844](https://github.com/MetaMask/core/pull/7844)) - Validates `ExactCalldataEnforcer` and `ValueLteEnforcer` caveat terms - Validates that `periodAmount` is positive in `erc20-token-periodic` and `native-token-periodic` permission types - Validates that `tokenAddress` is a valid hex string in `erc20-token-periodic` and `erc20-token-stream` permission types +- Bump `@metamask/transaction-controller` from `^62.17.0` to `^62.20.0` ([#7996](https://github.com/MetaMask/core/pull/7996), [#8005](https://github.com/MetaMask/core/pull/8005), [#8031](https://github.com/MetaMask/core/pull/8031), [#8104](https://github.com/MetaMask/core/pull/8104)) ## [2.0.0] diff --git a/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenPeriodic.ts b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenPeriodic.ts index dd973505c2f..933f4991b4e 100644 --- a/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenPeriodic.ts +++ b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenPeriodic.ts @@ -86,6 +86,7 @@ function validateAndDecodeData( const [tokenAddress, periodAmount, periodDurationRaw, startTimeRaw] = splitHex(terms, [20, 32, 32, 32]); const periodDuration = hexToNumber(periodDurationRaw); + const periodAmountBigInt = hexToBigInt(periodAmount); const startTime = hexToNumber(startTimeRaw); if (!isHexAddress(tokenAddress)) { @@ -94,7 +95,7 @@ function validateAndDecodeData( ); } - if (hexToBigInt(periodAmount) <= 0n) { + if (periodAmountBigInt <= 0n) { throw new Error( 'Invalid erc20-token-periodic terms: periodAmount must be a positive number', );