From 2ffe69b3002833a2876e4911b0ff36ab0aaff7a1 Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:56:02 +1200 Subject: [PATCH 1/7] Allow multiple permission types with matching caveats. Require only one to pass validation. --- .../src/GatorPermissionsController.ts | 27 ++- .../decodePermission/decodePermission.test.ts | 166 +++++++++++++++++- .../src/decodePermission/decodePermission.ts | 109 +++++++++++- .../src/decodePermission/index.ts | 2 + 4 files changed, 282 insertions(+), 22 deletions(-) diff --git a/packages/gator-permissions-controller/src/GatorPermissionsController.ts b/packages/gator-permissions-controller/src/GatorPermissionsController.ts index 05f59263093..fb41ad78e80 100644 --- a/packages/gator-permissions-controller/src/GatorPermissionsController.ts +++ b/packages/gator-permissions-controller/src/GatorPermissionsController.ts @@ -31,8 +31,9 @@ import { DELEGATION_FRAMEWORK_VERSION } from './constants'; import type { DecodedPermission } from './decodePermission'; import { createPermissionRulesForContracts, - findRuleWithMatchingCaveatAddresses, + findRulesWithMatchingCaveatAddresses, reconstructDecodedPermission, + selectUniqueRuleAndDecodedPermission, } from './decodePermission'; import { GatorPermissionsFetchError, @@ -561,7 +562,9 @@ export class GatorPermissionsController extends BaseController< * * @returns A decoded permission object suitable for UI consumption and follow-up actions. * @throws If the origin is not allowed, the context cannot be decoded into exactly one delegation, - * or the enforcers/terms do not match a supported permission type. + * the enforcers do not match any supported permission type, no candidate type validates + * the caveat terms, or more than one permission type successfully validates + * (ambiguous delegation). */ public decodePermissionFromPermissionContextForOrigin({ origin, @@ -591,24 +594,18 @@ export 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 - // and no forbidden enforcers - const matchingRule = findRuleWithMatchingCaveatAddresses({ + // Every rule where enforcer addresses match; multiple types may share the same + // caveat pattern and are disambiguated by validateAndDecodePermission. + const matchingRules = findRulesWithMatchingCaveatAddresses({ enforcers, permissionRules, }); - // 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 { rule: matchingRule, expiry, data } = + selectUniqueRuleAndDecodedPermission({ + candidateRules: matchingRules, + caveats, }); - } - - const { expiry, data } = decodeResult; const permission = reconstructDecodedPermission({ chainId, diff --git a/packages/gator-permissions-controller/src/decodePermission/decodePermission.test.ts b/packages/gator-permissions-controller/src/decodePermission/decodePermission.test.ts index 9fa8abf45c1..12481ccf386 100644 --- a/packages/gator-permissions-controller/src/decodePermission/decodePermission.test.ts +++ b/packages/gator-permissions-controller/src/decodePermission/decodePermission.test.ts @@ -8,10 +8,16 @@ import { numberToHex } from '@metamask/utils'; import { findRuleWithMatchingCaveatAddresses, + findRulesWithMatchingCaveatAddresses, reconstructDecodedPermission, + selectUniqueRuleAndDecodedPermission, } from './decodePermission'; import { createPermissionRulesForContracts } from './rules'; -import type { DecodedPermission, DeployedContractsByName } from './types'; +import type { + DecodedPermission, + DeployedContractsByName, + PermissionRule, +} from './types'; // These tests use the live deployments table for version 1.3.0 to // construct deterministic caveat address sets for a known chain. @@ -57,6 +63,27 @@ describe('decodePermission', () => { }).toThrow('Multiple permission types match'); }); + it('returns all matching rules from findRulesWithMatchingCaveatAddresses', () => { + const enforcers = [ExactCalldataEnforcer, NonceEnforcer, zeroAddress]; + const contractsWithDuplicates = { + ...contracts, + NativeTokenStreamingEnforcer: zeroAddress, + NativeTokenPeriodTransferEnforcer: zeroAddress, + } as unknown as DeployedContractsByName; + + const rules = findRulesWithMatchingCaveatAddresses({ + enforcers, + permissionRules: createPermissionRulesForContracts( + contractsWithDuplicates, + ), + }); + + expect(rules).toHaveLength(2); + expect(rules.map((r) => r.permissionType).sort()).toStrictEqual( + ['native-token-periodic', 'native-token-stream'].sort(), + ); + }); + describe('native-token-stream', () => { const expectedPermissionType = 'native-token-stream'; @@ -641,6 +668,143 @@ describe('decodePermission', () => { }); }); + describe('selectUniqueRuleAndDecodedPermission', () => { + const emptyCaveats: Parameters< + PermissionRule['validateAndDecodePermission'] + >[0] = []; + + const dummyRuleFields = { + requiredEnforcers: new Map(), + optionalEnforcers: new Set(), + caveatAddressesMatch: () => true, + } as const; + + it('returns the unique rule when exactly one candidate validates', () => { + const data = { + initialAmount: '0x1', + maxAmount: '0x2', + amountPerSecond: '0x3', + startTime: 1, + } as DecodedPermission['permission']['data']; + + const rules: PermissionRule[] = [ + { + ...dummyRuleFields, + permissionType: 'native-token-stream', + validateAndDecodePermission: () => ({ isValid: true, expiry: 9, data }), + }, + { + ...dummyRuleFields, + permissionType: 'native-token-periodic', + validateAndDecodePermission: () => ({ + isValid: false, + error: new Error('bad terms for periodic'), + }), + }, + ]; + + const result = selectUniqueRuleAndDecodedPermission({ + candidateRules: rules, + caveats: emptyCaveats, + }); + + expect(result.rule.permissionType).toBe('native-token-stream'); + expect(result.expiry).toBe(9); + expect(result.data).toStrictEqual(data); + }); + + it('throws when no candidate rules are provided', () => { + expect(() => + selectUniqueRuleAndDecodedPermission({ + candidateRules: [], + caveats: emptyCaveats, + }), + ).toThrow('Unable to identify permission type'); + }); + + it('rethrows the validation error when only one candidate exists and it fails', () => { + const originalError = new Error('stream validation failed'); + const rules: PermissionRule[] = [ + { + ...dummyRuleFields, + permissionType: 'native-token-stream', + validateAndDecodePermission: () => ({ + isValid: false, + error: originalError, + }), + }, + ]; + + expect(() => + selectUniqueRuleAndDecodedPermission({ + candidateRules: rules, + caveats: emptyCaveats, + }), + ).toThrow(originalError); + }); + + it('throws when more than one candidate validates', () => { + const data = { + initialAmount: '0x1', + maxAmount: '0x2', + amountPerSecond: '0x3', + startTime: 1, + } as DecodedPermission['permission']['data']; + + const rules: PermissionRule[] = [ + { + ...dummyRuleFields, + permissionType: 'native-token-stream', + validateAndDecodePermission: () => ({ isValid: true, expiry: 1, data }), + }, + { + ...dummyRuleFields, + permissionType: 'native-token-periodic', + validateAndDecodePermission: () => ({ isValid: true, expiry: 1, data }), + }, + ]; + + expect(() => + selectUniqueRuleAndDecodedPermission({ + candidateRules: rules, + caveats: emptyCaveats, + }), + ).toThrow( + 'Multiple permission types validate the same delegation caveats: native-token-stream, native-token-periodic', + ); + }); + + it('throws with attempt details when no candidate validates', () => { + const rules: PermissionRule[] = [ + { + ...dummyRuleFields, + permissionType: 'native-token-stream', + validateAndDecodePermission: () => ({ + isValid: false, + error: new Error('stream failed'), + }), + }, + { + ...dummyRuleFields, + permissionType: 'native-token-periodic', + validateAndDecodePermission: () => ({ + isValid: false, + error: new Error('periodic failed'), + }), + }, + ]; + + expect(() => + selectUniqueRuleAndDecodedPermission({ + candidateRules: rules, + caveats: emptyCaveats, + }), + ).toThrow( + 'No permission type could validate the delegation caveats. Attempts: native-token-stream: stream failed; native-token-periodic: periodic failed', + ); + }); + }); + describe('adversarial: attempts to violate decoder expectations', () => { describe('getPermissionRuleMatchingCaveatTypes()', () => { it('rejects empty enforcer list', () => { diff --git a/packages/gator-permissions-controller/src/decodePermission/decodePermission.ts b/packages/gator-permissions-controller/src/decodePermission/decodePermission.ts index e8997009c66..7ca067cf606 100644 --- a/packages/gator-permissions-controller/src/decodePermission/decodePermission.ts +++ b/packages/gator-permissions-controller/src/decodePermission/decodePermission.ts @@ -1,9 +1,34 @@ -import type { Hex } from '@metamask/delegation-core'; +import type { Caveat, 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 { PermissionRule } from './types'; +import type { + DecodedPermission, + PermissionType, + PermissionRule, + ValidateAndDecodeResult, +} from './types'; + +/** + * Returns every permission rule whose caveat-address pattern matches the given + * enforcer list for the chain. Used when more than one permission type can + * share the same enforcer set; the caller must disambiguate by validating + * caveat terms (see {@link selectUniqueRuleAndDecodedPermission}). + * + * @param args - The arguments to this function. + * @param args.enforcers - List of enforcer contract addresses (hex strings). + * @param args.permissionRules - The permission rules for the chain. + * @returns All rules that match, possibly empty. + */ +export const findRulesWithMatchingCaveatAddresses = ({ + enforcers, + permissionRules, +}: { + enforcers: Hex[]; + permissionRules: PermissionRule[]; +}): PermissionRule[] => { + return permissionRules.filter((rule) => rule.caveatAddressesMatch(enforcers)); +}; /** * Returns the unique permission rule that matches a given set of enforcer @@ -29,9 +54,10 @@ export const findRuleWithMatchingCaveatAddresses = ({ enforcers: Hex[]; permissionRules: PermissionRule[]; }): PermissionRule => { - const matchingRules = permissionRules.filter((rule) => - rule.caveatAddressesMatch(enforcers), - ); + const matchingRules = findRulesWithMatchingCaveatAddresses({ + enforcers, + permissionRules, + }); if (matchingRules.length === 0) { throw new Error('Unable to identify permission type'); @@ -42,6 +68,77 @@ export const findRuleWithMatchingCaveatAddresses = ({ return matchingRules[0]; }; +type SuccessfulValidateAndDecodeResult = Extract; + +/** + * Runs {@link PermissionRule.validateAndDecodePermission} on each candidate + * rule. Use when several rules share the same caveat addresses. + * + * @param args - The arguments to this function. + * @param args.candidateRules - Rules whose addresses already match the caveats. + * @param args.caveats - Caveats from the delegation. + * @returns The unique rule and decoded expiry/data when exactly one rule validates. + * @throws If `candidateRules` is empty, if no rule validates, or if more than one rule validates. + */ +export const selectUniqueRuleAndDecodedPermission = ({ + candidateRules, + caveats, +}: { + candidateRules: PermissionRule[]; + caveats: Caveat[]; +}): { rule: PermissionRule; expiry: number | null; data: SuccessfulValidateAndDecodeResult['data'] } => { + if (candidateRules.length === 0) { + throw new Error('Unable to identify permission type'); + } + + const successfulDecodingResult: { + rule: PermissionRule; + result: SuccessfulValidateAndDecodeResult; + }[] = []; + + const failedAttempts: { permissionType: PermissionType; error: Error }[] = + []; + + for (const rule of candidateRules) { + const decodeResult = rule.validateAndDecodePermission(caveats); + if (decodeResult.isValid) { + successfulDecodingResult.push({ rule, result: decodeResult }); + } else { + failedAttempts.push({ + permissionType: rule.permissionType, + error: decodeResult.error, + }); + } + } + + if (successfulDecodingResult.length === 1) { + const [{ rule, result }] = successfulDecodingResult; + return { rule, ...result }; + } + + if (successfulDecodingResult.length > 1) { + const types = successfulDecodingResult.map((result) => result.rule.permissionType).join(', '); + throw new Error( + `Multiple permission types validate the same delegation caveats: ${types}`, + ); + } + + if (failedAttempts.length === 1) { + throw failedAttempts[0].error; + } + + const details = failedAttempts + .map( + (attempt) => + `${String(attempt.permissionType)}: ${attempt.error.message}`, + ) + .join('; '); + + throw new Error( + `No permission type could validate the delegation caveats. Attempts: ${details}`, + ); +}; + /** * Reconstructs a {@link DecodedPermission} object from primitive values * obtained while decoding a permission context. diff --git a/packages/gator-permissions-controller/src/decodePermission/index.ts b/packages/gator-permissions-controller/src/decodePermission/index.ts index 0088d560f3f..7555a45a632 100644 --- a/packages/gator-permissions-controller/src/decodePermission/index.ts +++ b/packages/gator-permissions-controller/src/decodePermission/index.ts @@ -1,6 +1,8 @@ export { findRuleWithMatchingCaveatAddresses, + findRulesWithMatchingCaveatAddresses, reconstructDecodedPermission, + selectUniqueRuleAndDecodedPermission, } from './decodePermission'; export { createPermissionRulesForContracts } from './rules'; From acc0054081a51fc213cebcc001368958f6332e4b Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Thu, 23 Apr 2026 16:09:41 +1200 Subject: [PATCH 2/7] Disallow periodDuration values of uint256 maximum --- .../rules/erc20TokenPeriodic.test.ts | 67 ++++++++++++++++++- .../rules/erc20TokenPeriodic.ts | 8 +++ .../rules/nativeTokenPeriodic.test.ts | 63 +++++++++++++++++ .../rules/nativeTokenPeriodic.ts | 14 +++- .../src/decodePermission/utils.ts | 9 +++ 5 files changed, 159 insertions(+), 2 deletions(-) diff --git a/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenPeriodic.test.ts b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenPeriodic.test.ts index a71d18d21af..6fa48e5bfe1 100644 --- a/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenPeriodic.test.ts +++ b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenPeriodic.test.ts @@ -9,7 +9,7 @@ import { } from '@metamask/delegation-deployments'; import { createPermissionRulesForContracts } from '.'; -import { ZERO_32_BYTES } from '../utils'; +import { MAX_PERIOD_DURATION, ZERO_32_BYTES } from '../utils'; describe('erc20-token-periodic rule', () => { const chainId = CHAIN_ID.sepolia; @@ -327,4 +327,69 @@ describe('erc20-token-periodic rule', () => { 'Invalid erc20-token-periodic terms: periodAmount must be a positive number', ); }); + + it('rejects when periodDuration exceeds MAX_PERIOD_DURATION', () => { + const tokenAddress = '0xffffffffffffffffffffffffffffffffffffffff' as Hex; + const terms = createERC20TokenPeriodTransferTerms( + { + tokenAddress, + periodAmount: 100n, + periodDuration: MAX_PERIOD_DURATION + 1, + startDate: 1715664, + }, + { out: 'hex' }, + ); + + const caveats = [ + expiryCaveat, + valueLteCaveat, + { + enforcer: ERC20PeriodTransferEnforcer, + terms, + args: '0x' as const, + }, + ]; + + const result = 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 less than or equal to MAX_PERIOD_DURATION', + ); + }); + + it('accepts when periodDuration equals MAX_PERIOD_DURATION', () => { + const tokenAddress = '0xcccccccccccccccccccccccccccccccccccccccc' as Hex; + const caveats = [ + expiryCaveat, + valueLteCaveat, + { + enforcer: ERC20PeriodTransferEnforcer, + terms: createERC20TokenPeriodTransferTerms( + { + tokenAddress, + periodAmount: 200n, + periodDuration: MAX_PERIOD_DURATION, + startDate: 1715664, + }, + { out: 'hex' }, + ), + args: '0x' as const, + }, + ]; + const result = 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.data.periodDuration).toBe(MAX_PERIOD_DURATION); + }); }); diff --git a/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenPeriodic.ts b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenPeriodic.ts index 81be534fdc8..799eb6f19d3 100644 --- a/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenPeriodic.ts +++ b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenPeriodic.ts @@ -9,6 +9,7 @@ import type { import { getByteLength, getTermsByEnforcer, + MAX_PERIOD_DURATION, splitHex, ZERO_32_BYTES, } from '../utils'; @@ -85,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); @@ -101,6 +103,12 @@ function validateAndDecodeData( ); } + if (periodDuration > MAX_PERIOD_DURATION) { + throw new Error( + 'Invalid erc20-token-periodic terms: periodDuration must be less than or equal to MAX_PERIOD_DURATION', + ); + } + if (startTime === 0) { throw new Error( 'Invalid erc20-token-periodic terms: startTime must be a positive number', 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 0fa484ba2f1..cfe1d755a01 100644 --- a/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenPeriodic.test.ts +++ b/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenPeriodic.test.ts @@ -9,6 +9,7 @@ import { } from '@metamask/delegation-deployments'; import { createPermissionRulesForContracts } from '.'; +import { MAX_PERIOD_DURATION } from '../utils'; describe('native-token-periodic rule', () => { const chainId = CHAIN_ID.sepolia; @@ -284,4 +285,66 @@ describe('native-token-periodic rule', () => { 'Invalid native-token-periodic terms: periodAmount must be a positive number', ); }); + + it('rejects when periodDuration exceeds MAX_PERIOD_DURATION', () => { + const terms = createNativeTokenPeriodTransferTerms( + { + periodAmount: 100n, + periodDuration: MAX_PERIOD_DURATION + 1, + startDate: 1715664, + }, + { out: 'hex' }, + ); + + const caveats = [ + expiryCaveat, + exactCalldataCaveat, + { + enforcer: NativeTokenPeriodTransferEnforcer, + terms, + args: '0x' as const, + }, + ]; + + const result = 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 less than or equal to MAX_PERIOD_DURATION', + ); + }); + + it('accepts when periodDuration equals MAX_PERIOD_DURATION', () => { + const caveats = [ + expiryCaveat, + exactCalldataCaveat, + { + enforcer: NativeTokenPeriodTransferEnforcer, + terms: createNativeTokenPeriodTransferTerms( + { + periodAmount: 100n, + periodDuration: MAX_PERIOD_DURATION, + startDate: 1715664, + }, + { out: 'hex' }, + ), + args: '0x' as const, + }, + ]; + + const result = 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.data.periodDuration).toBe(MAX_PERIOD_DURATION); + }); }); diff --git a/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenPeriodic.ts b/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenPeriodic.ts index 8309dc8c5ae..7243541d830 100644 --- a/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenPeriodic.ts +++ b/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenPeriodic.ts @@ -6,7 +6,12 @@ import type { DecodedPermission, PermissionRule, } from '../types'; -import { getByteLength, getTermsByEnforcer, splitHex } from '../utils'; +import { + getByteLength, + getTermsByEnforcer, + MAX_PERIOD_DURATION, + splitHex, +} from '../utils'; import { makePermissionRule } from './makePermissionRule'; /** @@ -83,6 +88,7 @@ function validateAndDecodeData( terms, [32, 32, 32], ); + const periodDuration = hexToNumber(periodDurationRaw); const startTime = hexToNumber(startTimeRaw); const periodAmountBigInt = hexToBigInt(periodAmount); @@ -99,6 +105,12 @@ function validateAndDecodeData( ); } + if (periodDuration > MAX_PERIOD_DURATION) { + throw new Error( + 'Invalid native-token-periodic terms: periodDuration must be less than or equal to MAX_PERIOD_DURATION', + ); + } + if (startTime === 0) { throw new Error( 'Invalid native-token-periodic terms: startTime must be a positive number', diff --git a/packages/gator-permissions-controller/src/decodePermission/utils.ts b/packages/gator-permissions-controller/src/decodePermission/utils.ts index 4a40671cbd5..5c329f70b5d 100644 --- a/packages/gator-permissions-controller/src/decodePermission/utils.ts +++ b/packages/gator-permissions-controller/src/decodePermission/utils.ts @@ -28,6 +28,12 @@ const ENFORCER_CONTRACT_NAMES = { export const ZERO_32_BYTES = '0x0000000000000000000000000000000000000000000000000000000000000000' as const; +/** + * Maximum unsigned 256-bit integer encoded as 32 bytes (0x + 64 hex chars). + */ +export const UINT256_MAX = + '0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff' as const; + /** AllowedCalldataEnforcer terms for ERC20 approve selector. */ export const ERC20_APPROVE_SELECTOR_TERMS = '0x0000000000000000000000000000000000000000000000000000000000000000095ea7b3' as const; @@ -36,6 +42,9 @@ export const ERC20_APPROVE_SELECTOR_TERMS = export const ERC20_APPROVE_ZERO_AMOUNT_TERMS = '0x00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000' as const; +/** Maximum period duration in seconds. */ +export const MAX_PERIOD_DURATION = 10 * 365 * 24 * 60 * 60; // 10 years in seconds + /** * Get the byte length of a hex string. * From 3000c1bd7fc264f7f653fb4cafc884b6d4d40621 Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Thu, 23 Apr 2026 16:31:45 +1200 Subject: [PATCH 3/7] Add decoding rules for new permissions: - erc-20-token-allowance - native-token-allowance --- .../decodePermission/decodePermission.test.ts | 50 ++-- .../rules/erc20TokenAllowance.test.ts | 269 ++++++++++++++++++ .../rules/erc20TokenAllowance.ts | 115 ++++++++ .../src/decodePermission/rules/index.ts | 4 + .../rules/nativeTokenAllowance.test.ts | 268 +++++++++++++++++ .../rules/nativeTokenAllowance.ts | 118 ++++++++ .../src/decodePermission/types.ts | 49 +++- .../src/decodePermission/utils.test.ts | 44 ++- 8 files changed, 897 insertions(+), 20 deletions(-) create mode 100644 packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenAllowance.test.ts create mode 100644 packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenAllowance.ts create mode 100644 packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenAllowance.test.ts create mode 100644 packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenAllowance.ts diff --git a/packages/gator-permissions-controller/src/decodePermission/decodePermission.test.ts b/packages/gator-permissions-controller/src/decodePermission/decodePermission.test.ts index 12481ccf386..3d39d4084c4 100644 --- a/packages/gator-permissions-controller/src/decodePermission/decodePermission.test.ts +++ b/packages/gator-permissions-controller/src/decodePermission/decodePermission.test.ts @@ -78,9 +78,13 @@ describe('decodePermission', () => { ), }); - expect(rules).toHaveLength(2); - expect(rules.map((r) => r.permissionType).sort()).toStrictEqual( - ['native-token-periodic', 'native-token-stream'].sort(), + expect(rules).toHaveLength(3); + expect(rules.map((matchingRule) => matchingRule.permissionType).sort()).toStrictEqual( + [ + 'native-token-periodic', + 'native-token-stream', + 'native-token-allowance', + ].sort(), ); }); @@ -177,17 +181,19 @@ describe('decodePermission', () => { describe('native-token-periodic', () => { const expectedPermissionType = 'native-token-periodic'; - it('matches with required caveats', () => { + it('matches with required caveats alongside native-token-allowance', () => { const enforcers = [ NativeTokenPeriodTransferEnforcer, ExactCalldataEnforcer, NonceEnforcer, ]; - const result = findRuleWithMatchingCaveatAddresses({ + const rules = findRulesWithMatchingCaveatAddresses({ enforcers, permissionRules: createPermissionRulesForContracts(contracts), }); - expect(result.permissionType).toBe(expectedPermissionType); + expect(rules.map((matchingRule) => matchingRule.permissionType).sort()).toStrictEqual( + [expectedPermissionType, 'native-token-allowance'].sort(), + ); }); it('allows TimestampEnforcer as extra', () => { @@ -197,11 +203,13 @@ describe('decodePermission', () => { NonceEnforcer, TimestampEnforcer, ]; - const result = findRuleWithMatchingCaveatAddresses({ + const rules = findRulesWithMatchingCaveatAddresses({ enforcers, permissionRules: createPermissionRulesForContracts(contracts), }); - expect(result.permissionType).toBe(expectedPermissionType); + expect(rules.map((matchingRule) => matchingRule.permissionType).sort()).toStrictEqual( + [expectedPermissionType, 'native-token-allowance'].sort(), + ); }); it('rejects forbidden extra caveat', () => { @@ -236,11 +244,13 @@ describe('decodePermission', () => { ExactCalldataEnforcer.toLowerCase() as unknown as Hex, NonceEnforcer.toLowerCase() as unknown as Hex, ]; - const result = findRuleWithMatchingCaveatAddresses({ + const rules = findRulesWithMatchingCaveatAddresses({ enforcers, permissionRules: createPermissionRulesForContracts(contracts), }); - expect(result.permissionType).toBe(expectedPermissionType); + expect(rules.map((matchingRule) => matchingRule.permissionType).sort()).toStrictEqual( + [expectedPermissionType, 'native-token-allowance'].sort(), + ); }); it('throws if a contract is not found', () => { @@ -357,17 +367,19 @@ describe('decodePermission', () => { describe('erc20-token-periodic', () => { const expectedPermissionType = 'erc20-token-periodic'; - it('matches with required caveats', () => { + it('matches with required caveats alongside erc20-token-allowance', () => { const enforcers = [ ERC20PeriodTransferEnforcer, ValueLteEnforcer, NonceEnforcer, ]; - const result = findRuleWithMatchingCaveatAddresses({ + const rules = findRulesWithMatchingCaveatAddresses({ enforcers, permissionRules: createPermissionRulesForContracts(contracts), }); - expect(result.permissionType).toBe(expectedPermissionType); + expect(rules.map((matchingRule) => matchingRule.permissionType).sort()).toStrictEqual( + [expectedPermissionType, 'erc20-token-allowance'].sort(), + ); }); it('allows TimestampEnforcer as extra', () => { @@ -377,11 +389,13 @@ describe('decodePermission', () => { NonceEnforcer, TimestampEnforcer, ]; - const result = findRuleWithMatchingCaveatAddresses({ + const rules = findRulesWithMatchingCaveatAddresses({ enforcers, permissionRules: createPermissionRulesForContracts(contracts), }); - expect(result.permissionType).toBe(expectedPermissionType); + expect(rules.map((matchingRule) => matchingRule.permissionType).sort()).toStrictEqual( + [expectedPermissionType, 'erc20-token-allowance'].sort(), + ); }); it('rejects forbidden extra caveat', () => { @@ -416,11 +430,13 @@ describe('decodePermission', () => { ValueLteEnforcer.toLowerCase() as unknown as Hex, NonceEnforcer.toLowerCase() as unknown as Hex, ]; - const result = findRuleWithMatchingCaveatAddresses({ + const rules = findRulesWithMatchingCaveatAddresses({ enforcers, permissionRules: createPermissionRulesForContracts(contracts), }); - expect(result.permissionType).toBe(expectedPermissionType); + expect(rules.map((matchingRule) => matchingRule.permissionType).sort()).toStrictEqual( + [expectedPermissionType, 'erc20-token-allowance'].sort(), + ); }); it('throws if a contract is not found', () => { diff --git a/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenAllowance.test.ts b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenAllowance.test.ts new file mode 100644 index 00000000000..e681f9f12bc --- /dev/null +++ b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenAllowance.test.ts @@ -0,0 +1,269 @@ +import { createTimestampTerms } from '@metamask/delegation-core'; +import type { Hex } from '@metamask/delegation-core'; +import { + CHAIN_ID, + DELEGATOR_CONTRACTS, +} from '@metamask/delegation-deployments'; + +import { createPermissionRulesForContracts } from '.'; +import { ZERO_32_BYTES } from '../utils'; + +describe('erc20-token-allowance rule', () => { + const chainId = CHAIN_ID.sepolia; + const contracts = DELEGATOR_CONTRACTS['1.3.0'][chainId]; + const { TimestampEnforcer, ERC20PeriodTransferEnforcer, ValueLteEnforcer } = + contracts; + const permissionRules = createPermissionRulesForContracts(contracts); + const rule = permissionRules.find( + (candidate) => candidate.permissionType === 'erc20-token-allowance', + ); + if (!rule) { + throw new Error('Rule not found'); + } + + const expiryCaveat = { + enforcer: TimestampEnforcer, + terms: createTimestampTerms({ + timestampAfterThreshold: 0, + timestampBeforeThreshold: 1720000, + }), + args: '0x' as const, + }; + + const valueLteCaveat = { + enforcer: ValueLteEnforcer, + terms: ZERO_32_BYTES, + args: '0x' as const, + }; + + const TOKEN_ADDRESS_HEX = 'aa'.repeat(20); + const TOKEN_ADDRESS: Hex = `0x${TOKEN_ADDRESS_HEX}`; + const ALLOWANCE_AMOUNT_HEX = 100n.toString(16).padStart(64, '0'); + const PERIOD_DURATION_MAX_HEX = 'f'.repeat(64); + const START_DATE_HEX = (1715664).toString(16).padStart(64, '0'); + const ALLOWANCE_TERMS = + `0x${TOKEN_ADDRESS_HEX}${ALLOWANCE_AMOUNT_HEX}${PERIOD_DURATION_MAX_HEX}${START_DATE_HEX}` as Hex; + + it('rejects duplicate ERC20PeriodTransferEnforcer caveats', () => { + const caveats = [ + expiryCaveat, + valueLteCaveat, + { + enforcer: ERC20PeriodTransferEnforcer, + terms: ALLOWANCE_TERMS, + args: '0x' as const, + }, + { + enforcer: ERC20PeriodTransferEnforcer, + terms: ALLOWANCE_TERMS, + args: '0x' as const, + }, + ]; + const result = rule.validateAndDecodePermission(caveats); + expect(result.isValid).toBe(false); + + if (result.isValid) { + throw new Error('Expected invalid result'); + } + + expect(result.error.message).toContain('Invalid caveats'); + }); + + it('rejects truncated terms', () => { + const truncatedTerms: Hex = `0x${'00'.repeat(40)}`; // 40 bytes, need 116 + + const caveats = [ + expiryCaveat, + valueLteCaveat, + { + enforcer: ERC20PeriodTransferEnforcer, + terms: truncatedTerms, + args: '0x' as const, + }, + ]; + + const result = rule.validateAndDecodePermission(caveats); + expect(result.isValid).toBe(false); + + if (result.isValid) { + throw new Error('Expected invalid result'); + } + + expect(result.error.message).toContain( + 'Invalid erc20-token-allowance terms: expected 116 bytes', + ); + }); + + it('rejects when terms have trailing bytes', () => { + const termsWithTrailing = `${ALLOWANCE_TERMS}deadbeef` as Hex; + const caveats = [ + expiryCaveat, + valueLteCaveat, + { + enforcer: ERC20PeriodTransferEnforcer, + terms: termsWithTrailing, + args: '0x' as const, + }, + ]; + const result = rule.validateAndDecodePermission(caveats); + expect(result.isValid).toBe(false); + + if (result.isValid) { + throw new Error('Expected invalid result'); + } + + expect(result.error.message).toContain( + 'Invalid erc20-token-allowance terms: expected 116 bytes', + ); + }); + + it('rejects when ValueLteEnforcer terms are not zero', () => { + const nonZeroValueLte: Hex = `0x${'0'.repeat(62)}01` as Hex; + const caveats = [ + expiryCaveat, + { + enforcer: ValueLteEnforcer, + terms: nonZeroValueLte, + args: '0x' as const, + }, + { + enforcer: ERC20PeriodTransferEnforcer, + terms: ALLOWANCE_TERMS, + args: '0x' as const, + }, + ]; + const result = rule.validateAndDecodePermission(caveats); + expect(result.isValid).toBe(false); + + if (result.isValid) { + throw new Error('Expected invalid result'); + } + + expect(result.error.message).toContain( + `Invalid value-lte terms: must be ${ZERO_32_BYTES}`, + ); + }); + + it('successfully decodes valid erc20-token-allowance caveats', () => { + const caveats = [ + expiryCaveat, + valueLteCaveat, + { + enforcer: ERC20PeriodTransferEnforcer, + terms: ALLOWANCE_TERMS, + args: '0x' as const, + }, + ]; + + const result = 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).toStrictEqual({ + tokenAddress: TOKEN_ADDRESS, + allowanceAmount: `0x${ALLOWANCE_AMOUNT_HEX}`, + startTime: 1715664, + }); + }); + + it('successfully decodes when periodDuration uses uppercase UINT256_MAX', () => { + const uppercaseMax = 'F'.repeat(64); + const terms = + `0x${TOKEN_ADDRESS_HEX}${ALLOWANCE_AMOUNT_HEX}${uppercaseMax}${START_DATE_HEX}` as Hex; + + const caveats = [ + expiryCaveat, + valueLteCaveat, + { + enforcer: ERC20PeriodTransferEnforcer, + terms, + args: '0x' as const, + }, + ]; + + const result = rule.validateAndDecodePermission(caveats); + expect(result.isValid).toBe(true); + }); + + it('rejects when periodDuration is not UINT256_MAX', () => { + const nonMaxDuration = (86400).toString(16).padStart(64, '0'); + const terms = + `0x${TOKEN_ADDRESS_HEX}${ALLOWANCE_AMOUNT_HEX}${nonMaxDuration}${START_DATE_HEX}` as Hex; + + const caveats = [ + expiryCaveat, + valueLteCaveat, + { + enforcer: ERC20PeriodTransferEnforcer, + terms, + args: '0x' as const, + }, + ]; + const result = rule.validateAndDecodePermission(caveats); + expect(result.isValid).toBe(false); + + if (result.isValid) { + throw new Error('Expected invalid result'); + } + + expect(result.error.message).toContain( + 'Invalid erc20-token-allowance terms: periodDuration must be UINT256_MAX', + ); + }); + + it('rejects when startTime is 0', () => { + const startTimeZero = '0'.repeat(64); + const terms = + `0x${TOKEN_ADDRESS_HEX}${ALLOWANCE_AMOUNT_HEX}${PERIOD_DURATION_MAX_HEX}${startTimeZero}` as Hex; + const caveats = [ + expiryCaveat, + valueLteCaveat, + { + enforcer: ERC20PeriodTransferEnforcer, + terms, + args: '0x' as const, + }, + ]; + const result = rule.validateAndDecodePermission(caveats); + expect(result.isValid).toBe(false); + + if (result.isValid) { + throw new Error('Expected invalid result'); + } + + expect(result.error.message).toContain( + 'Invalid erc20-token-allowance terms: startTime must be a positive number', + ); + }); + + it('rejects when allowanceAmount is 0', () => { + const allowanceZero = '0'.repeat(64); + const terms = + `0x${TOKEN_ADDRESS_HEX}${allowanceZero}${PERIOD_DURATION_MAX_HEX}${START_DATE_HEX}` as Hex; + + const caveats = [ + expiryCaveat, + valueLteCaveat, + { + enforcer: ERC20PeriodTransferEnforcer, + terms, + args: '0x' as const, + }, + ]; + + const result = rule.validateAndDecodePermission(caveats); + expect(result.isValid).toBe(false); + + if (result.isValid) { + throw new Error('Expected invalid result'); + } + + expect(result.error.message).toContain( + 'Invalid erc20-token-allowance terms: allowanceAmount must be a positive number', + ); + }); +}); diff --git a/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenAllowance.ts b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenAllowance.ts new file mode 100644 index 00000000000..8ebf7736889 --- /dev/null +++ b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenAllowance.ts @@ -0,0 +1,115 @@ +import { hexToNumber } from '@metamask/utils'; + +import type { + ChecksumCaveat, + ChecksumEnforcersByChainId, + DecodedPermission, + PermissionRule, +} from '../types'; +import { + getByteLength, + getTermsByEnforcer, + splitHex, + UINT256_MAX, + ZERO_32_BYTES, +} from '../utils'; +import { makePermissionRule } from './makePermissionRule'; + +/** + * Creates the erc20-token-allowance permission rule. + * + * This permission shares the same enforcer set as `erc20-token-periodic` but + * is distinguished by a `periodDuration` of `UINT256_MAX`, which effectively + * disables the periodic reset and turns the caveat into a one-off allowance. + * + * @param enforcers - Checksummed enforcer addresses for the chain. + * @returns The erc20-token-allowance permission rule. + */ +export function makeErc20TokenAllowanceRule( + enforcers: ChecksumEnforcersByChainId, +): PermissionRule { + const { + timestampEnforcer, + erc20PeriodicEnforcer, + valueLteEnforcer, + nonceEnforcer, + } = enforcers; + return makePermissionRule({ + permissionType: 'erc20-token-allowance', + optionalEnforcers: [timestampEnforcer], + timestampEnforcer, + requiredEnforcers: { + [erc20PeriodicEnforcer]: 1, + [valueLteEnforcer]: 1, + [nonceEnforcer]: 1, + }, + validateAndDecodeData: (caveats) => + validateAndDecodeData(caveats, { + erc20PeriodicEnforcer, + valueLteEnforcer, + }), + }); +} + +/** + * Decodes erc20-token-allowance permission data from caveats; throws on invalid. + * + * @param caveats - Caveats from the permission context (checksummed). + * @param enforcers - Addresses of the enforcers. + * @param enforcers.erc20PeriodicEnforcer - Address of the ERC20PeriodicEnforcer. + * @param enforcers.valueLteEnforcer - Address of the ValueLteEnforcer. + * @returns Decoded allowance terms. + */ +function validateAndDecodeData( + caveats: ChecksumCaveat[], + enforcers: Pick< + ChecksumEnforcersByChainId, + 'erc20PeriodicEnforcer' | 'valueLteEnforcer' + >, +): DecodedPermission['permission']['data'] { + 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 + + if (getByteLength(terms) !== EXPECTED_TERMS_BYTELENGTH) { + throw new Error('Invalid erc20-token-allowance terms: expected 116 bytes'); + } + + const [tokenAddress, allowanceAmount, periodDurationRaw, startTimeRaw] = + splitHex(terms, [20, 32, 32, 32]); + + if (periodDurationRaw.toLowerCase() !== UINT256_MAX) { + throw new Error( + 'Invalid erc20-token-allowance terms: periodDuration must be UINT256_MAX', + ); + } + + const startTime = hexToNumber(startTimeRaw); + + if (startTime === 0) { + throw new Error( + 'Invalid erc20-token-allowance terms: startTime must be a positive number', + ); + } + + if (allowanceAmount === ZERO_32_BYTES) { + throw new Error( + 'Invalid erc20-token-allowance terms: allowanceAmount must be a positive number', + ); + } + + return { tokenAddress, allowanceAmount, startTime }; +} diff --git a/packages/gator-permissions-controller/src/decodePermission/rules/index.ts b/packages/gator-permissions-controller/src/decodePermission/rules/index.ts index 29c4c5cc88e..f6499ec166b 100644 --- a/packages/gator-permissions-controller/src/decodePermission/rules/index.ts +++ b/packages/gator-permissions-controller/src/decodePermission/rules/index.ts @@ -1,8 +1,10 @@ import type { DeployedContractsByName, PermissionRule } from '../types'; import { getChecksumEnforcersByChainId } from '../utils'; +import { makeErc20TokenAllowanceRule } from './erc20TokenAllowance'; import { makeErc20TokenPeriodicRule } from './erc20TokenPeriodic'; import { makeErc20TokenRevocationRule } from './erc20TokenRevocation'; import { makeErc20TokenStreamRule } from './erc20TokenStream'; +import { makeNativeTokenAllowanceRule } from './nativeTokenAllowance'; import { makeNativeTokenPeriodicRule } from './nativeTokenPeriodic'; import { makeNativeTokenStreamRule } from './nativeTokenStream'; @@ -24,8 +26,10 @@ export const createPermissionRulesForContracts = ( return [ makeNativeTokenStreamRule(enforcers), makeNativeTokenPeriodicRule(enforcers), + makeNativeTokenAllowanceRule(enforcers), makeErc20TokenStreamRule(enforcers), makeErc20TokenPeriodicRule(enforcers), + makeErc20TokenAllowanceRule(enforcers), makeErc20TokenRevocationRule(enforcers), ]; }; diff --git a/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenAllowance.test.ts b/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenAllowance.test.ts new file mode 100644 index 00000000000..cb5952031f6 --- /dev/null +++ b/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenAllowance.test.ts @@ -0,0 +1,268 @@ +import { createTimestampTerms } from '@metamask/delegation-core'; +import type { Hex } from '@metamask/delegation-core'; +import { + CHAIN_ID, + DELEGATOR_CONTRACTS, +} from '@metamask/delegation-deployments'; + +import { createPermissionRulesForContracts } from '.'; + +describe('native-token-allowance rule', () => { + const chainId = CHAIN_ID.sepolia; + const contracts = DELEGATOR_CONTRACTS['1.3.0'][chainId]; + const { + TimestampEnforcer, + NativeTokenPeriodTransferEnforcer, + ExactCalldataEnforcer, + } = contracts; + const permissionRules = createPermissionRulesForContracts(contracts); + const rule = permissionRules.find( + (candidate) => candidate.permissionType === 'native-token-allowance', + ); + if (!rule) { + throw new Error('Rule not found'); + } + + const expiryCaveat = { + enforcer: TimestampEnforcer, + terms: createTimestampTerms({ + timestampAfterThreshold: 0, + timestampBeforeThreshold: 1720000, + }), + args: '0x' as const, + }; + + const exactCalldataCaveat = { + enforcer: ExactCalldataEnforcer, + terms: '0x' as Hex, + args: '0x' as const, + }; + + const PERIOD_AMOUNT_HEX = 100n.toString(16).padStart(64, '0'); + const PERIOD_DURATION_MAX_HEX = 'f'.repeat(64); + const START_DATE_HEX = (1715664).toString(16).padStart(64, '0'); + const ALLOWANCE_TERMS = + `0x${PERIOD_AMOUNT_HEX}${PERIOD_DURATION_MAX_HEX}${START_DATE_HEX}` as Hex; + + it('rejects duplicate NativeTokenPeriodTransferEnforcer caveats', () => { + const caveats = [ + expiryCaveat, + exactCalldataCaveat, + { + enforcer: NativeTokenPeriodTransferEnforcer, + terms: ALLOWANCE_TERMS, + args: '0x' as const, + }, + { + enforcer: NativeTokenPeriodTransferEnforcer, + terms: ALLOWANCE_TERMS, + args: '0x' as const, + }, + ]; + const result = 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: 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); + + if (result.isValid) { + throw new Error('Expected invalid result'); + } + + expect(result.error.message).toContain( + 'Invalid native-token-allowance terms: expected 96 bytes', + ); + }); + + it('rejects when terms have trailing bytes', () => { + const termsWithTrailing = `${ALLOWANCE_TERMS}deadbeef` as Hex; + const caveats = [ + expiryCaveat, + exactCalldataCaveat, + { + enforcer: NativeTokenPeriodTransferEnforcer, + terms: termsWithTrailing, + args: '0x' as const, + }, + ]; + const result = rule.validateAndDecodePermission(caveats); + expect(result.isValid).toBe(false); + + if (result.isValid) { + throw new Error('Expected invalid result'); + } + + expect(result.error.message).toContain( + 'Invalid native-token-allowance terms: expected 96 bytes', + ); + }); + + it('rejects when ExactCalldataEnforcer terms are not 0x', () => { + const caveats = [ + expiryCaveat, + { + enforcer: ExactCalldataEnforcer, + terms: '0x00' as Hex, + args: '0x' as const, + }, + { + enforcer: NativeTokenPeriodTransferEnforcer, + terms: ALLOWANCE_TERMS, + args: '0x' as const, + }, + ]; + const result = rule.validateAndDecodePermission(caveats); + expect(result.isValid).toBe(false); + + if (result.isValid) { + throw new Error('Expected invalid result'); + } + + expect(result.error.message).toContain( + 'Invalid exact-calldata terms: must be 0x', + ); + }); + + it('successfully decodes valid native-token-allowance caveats', () => { + const caveats = [ + expiryCaveat, + exactCalldataCaveat, + { + enforcer: NativeTokenPeriodTransferEnforcer, + terms: ALLOWANCE_TERMS, + args: '0x' as const, + }, + ]; + + const result = 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).toStrictEqual({ + allowanceAmount: `0x${PERIOD_AMOUNT_HEX}`, + startTime: 1715664, + }); + }); + + it('successfully decodes when periodDuration uses uppercase UINT256_MAX', () => { + const uppercaseMax = 'F'.repeat(64); + const terms = + `0x${PERIOD_AMOUNT_HEX}${uppercaseMax}${START_DATE_HEX}` as Hex; + + const caveats = [ + expiryCaveat, + exactCalldataCaveat, + { + enforcer: NativeTokenPeriodTransferEnforcer, + terms, + args: '0x' as const, + }, + ]; + + const result = rule.validateAndDecodePermission(caveats); + expect(result.isValid).toBe(true); + }); + + it('rejects when periodDuration is not UINT256_MAX', () => { + const nonMaxDuration = (86400).toString(16).padStart(64, '0'); + const terms = + `0x${PERIOD_AMOUNT_HEX}${nonMaxDuration}${START_DATE_HEX}` as Hex; + + const caveats = [ + expiryCaveat, + exactCalldataCaveat, + { + enforcer: NativeTokenPeriodTransferEnforcer, + terms, + args: '0x' as const, + }, + ]; + const result = rule.validateAndDecodePermission(caveats); + expect(result.isValid).toBe(false); + + if (result.isValid) { + throw new Error('Expected invalid result'); + } + + expect(result.error.message).toContain( + 'Invalid native-token-allowance terms: periodDuration must be UINT256_MAX', + ); + }); + + it('rejects when startTime is 0', () => { + const startTimeZero = '0'.repeat(64); + const terms = + `0x${PERIOD_AMOUNT_HEX}${PERIOD_DURATION_MAX_HEX}${startTimeZero}` as Hex; + const caveats = [ + expiryCaveat, + exactCalldataCaveat, + { + enforcer: NativeTokenPeriodTransferEnforcer, + terms, + args: '0x' as const, + }, + ]; + const result = rule.validateAndDecodePermission(caveats); + expect(result.isValid).toBe(false); + + if (result.isValid) { + throw new Error('Expected invalid result'); + } + + expect(result.error.message).toContain( + 'Invalid native-token-allowance terms: startTime must be a positive number', + ); + }); + + it('rejects when allowanceAmount is 0', () => { + const allowanceZero = '0'.repeat(64); + const terms = + `0x${allowanceZero}${PERIOD_DURATION_MAX_HEX}${START_DATE_HEX}` as Hex; + + const caveats = [ + expiryCaveat, + exactCalldataCaveat, + { + enforcer: NativeTokenPeriodTransferEnforcer, + terms, + args: '0x' as const, + }, + ]; + + const result = rule.validateAndDecodePermission(caveats); + expect(result.isValid).toBe(false); + + if (result.isValid) { + throw new Error('Expected invalid result'); + } + + expect(result.error.message).toContain( + 'Invalid native-token-allowance terms: allowanceAmount must be a positive number', + ); + }); +}); diff --git a/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenAllowance.ts b/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenAllowance.ts new file mode 100644 index 00000000000..f669dd5d5f9 --- /dev/null +++ b/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenAllowance.ts @@ -0,0 +1,118 @@ +import { hexToNumber } from '@metamask/utils'; + +import type { + ChecksumCaveat, + ChecksumEnforcersByChainId, + DecodedPermission, + PermissionRule, +} from '../types'; +import { + getByteLength, + getTermsByEnforcer, + splitHex, + UINT256_MAX, + ZERO_32_BYTES, +} from '../utils'; +import { makePermissionRule } from './makePermissionRule'; + +/** + * Creates the native-token-allowance permission rule. + * + * This permission shares the same enforcer set as `native-token-periodic` but + * is distinguished by a `periodDuration` of `UINT256_MAX`, which effectively + * disables the periodic reset and turns the caveat into a one-off allowance. + * + * @param enforcers - Checksummed enforcer addresses for the chain. + * @returns The native-token-allowance permission rule. + */ +export function makeNativeTokenAllowanceRule( + enforcers: ChecksumEnforcersByChainId, +): PermissionRule { + const { + timestampEnforcer, + nativeTokenPeriodicEnforcer, + exactCalldataEnforcer, + nonceEnforcer, + } = enforcers; + return makePermissionRule({ + permissionType: 'native-token-allowance', + optionalEnforcers: [timestampEnforcer], + timestampEnforcer, + requiredEnforcers: { + [nativeTokenPeriodicEnforcer]: 1, + [exactCalldataEnforcer]: 1, + [nonceEnforcer]: 1, + }, + validateAndDecodeData: (caveats) => + validateAndDecodeData(caveats, { + nativeTokenPeriodicEnforcer, + exactCalldataEnforcer, + }), + }); +} + +/** + * Decodes native-token-allowance permission data from caveats; throws on invalid. + * + * @param caveats - Caveats from the permission context (checksummed). + * @param enforcers - Addresses of the enforcers. + * @param enforcers.nativeTokenPeriodicEnforcer - Address of the NativeTokenPeriodicEnforcer. + * @param enforcers.exactCalldataEnforcer - Address of the ExactCalldataEnforcer. + * @returns Decoded allowance terms. + */ +function validateAndDecodeData( + caveats: ChecksumCaveat[], + enforcers: Pick< + ChecksumEnforcersByChainId, + 'nativeTokenPeriodicEnforcer' | 'exactCalldataEnforcer' + >, +): DecodedPermission['permission']['data'] { + 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 + + if (getByteLength(terms) !== EXPECTED_TERMS_BYTELENGTH) { + throw new Error('Invalid native-token-allowance terms: expected 96 bytes'); + } + + const [allowanceAmount, periodDurationRaw, startTimeRaw] = splitHex( + terms, + [32, 32, 32], + ); + + if (periodDurationRaw.toLowerCase() !== UINT256_MAX) { + throw new Error( + 'Invalid native-token-allowance terms: periodDuration must be UINT256_MAX', + ); + } + + const startTime = hexToNumber(startTimeRaw); + + if (startTime === 0) { + throw new Error( + 'Invalid native-token-allowance terms: startTime must be a positive number', + ); + } + + if (allowanceAmount === ZERO_32_BYTES) { + throw new Error( + 'Invalid native-token-allowance terms: allowanceAmount must be a positive number', + ); + } + + return { allowanceAmount, startTime }; +} diff --git a/packages/gator-permissions-controller/src/decodePermission/types.ts b/packages/gator-permissions-controller/src/decodePermission/types.ts index d6a6e8ac0dd..a6a872a38b1 100644 --- a/packages/gator-permissions-controller/src/decodePermission/types.ts +++ b/packages/gator-permissions-controller/src/decodePermission/types.ts @@ -1,4 +1,6 @@ import type { + BasePermission, + MetaMaskBasePermissionData, PermissionRequest, PermissionTypes, } from '@metamask/7715-permission-types'; @@ -9,6 +11,49 @@ import type { Hex } from '@metamask/utils'; export type DeployedContractsByName = (typeof DELEGATOR_CONTRACTS)[number][number]; +/** + * Permission type for an unbounded ERC-20 token allowance. + * + * Encoded on-chain as an ERC20PeriodTransferEnforcer caveat with + * `periodDuration` set to `UINT256_MAX` so that the allowance never resets + * within any realistic time horizon. + * + * Not yet defined in `@metamask/7715-permission-types`, so declared locally. + */ +type Erc20TokenAllowancePermission = BasePermission & { + type: 'erc20-token-allowance'; + data: MetaMaskBasePermissionData & { + allowanceAmount: Hex; + startTime?: number | null; + tokenAddress: Hex; + }; +}; + +/** + * Permission type for an unbounded native token allowance. + * + * Encoded on-chain as a NativeTokenPeriodTransferEnforcer caveat with + * `periodDuration` set to `UINT256_MAX`. + * + * Not yet defined in `@metamask/7715-permission-types`, so declared locally. + */ +type NativeTokenAllowancePermission = BasePermission & { + type: 'native-token-allowance'; + data: MetaMaskBasePermissionData & { + allowanceAmount: Hex; + startTime?: number | null; + }; +}; + +/** + * Extended permission union, including types not yet published in + * `@metamask/7715-permission-types` but supported by this package's decoder. + */ +type ExtendedPermissionTypes = + | PermissionTypes + | Erc20TokenAllowancePermission + | NativeTokenAllowancePermission; + // This is a somewhat convoluted type - it includes all of the fields that are decoded from the permission context. /** * A partially reconstructed permission object decoded from a permission context. @@ -19,11 +64,11 @@ export type DeployedContractsByName = * `TimestampEnforcer` terms, as well as the `origin` property. */ export type DecodedPermission = Pick< - PermissionRequest, + PermissionRequest, 'chainId' | 'from' | 'to' > & { permission: Omit< - PermissionRequest['permission'], + PermissionRequest['permission'], 'isAdjustmentAllowed' > & { // PermissionRequest type does not work well without the specific permission type, so we amend it here diff --git a/packages/gator-permissions-controller/src/decodePermission/utils.test.ts b/packages/gator-permissions-controller/src/decodePermission/utils.test.ts index 78992f5494c..4102bad1091 100644 --- a/packages/gator-permissions-controller/src/decodePermission/utils.test.ts +++ b/packages/gator-permissions-controller/src/decodePermission/utils.test.ts @@ -80,10 +80,12 @@ describe('createPermissionRulesForChainId', () => { // erc20-token-stream // erc20-token-periodic + // erc20-token-allowance // native-token-stream // native-token-periodic + // native-token-allowance // erc20-token-revocation - const permissionTypeCount = 5; + const permissionTypeCount = 7; const rules = createPermissionRulesForContracts(contracts); expect(rules).toHaveLength(permissionTypeCount); @@ -171,6 +173,46 @@ describe('createPermissionRulesForChainId', () => { ]), ); + // native-token-allowance + expect(byType['native-token-allowance']).toBeDefined(); + expect(byType['native-token-allowance'].permissionType).toBe( + 'native-token-allowance', + ); + expect(byType['native-token-allowance'].optionalEnforcers.size).toBe(1); + expect( + byType['native-token-allowance'].optionalEnforcers.has(timestampEnforcer), + ).toBe(true); + expect(byType['native-token-allowance'].requiredEnforcers.size).toBe(3); + expect( + Array.from(byType['native-token-allowance'].requiredEnforcers.entries()), + ).toStrictEqual( + expect.arrayContaining([ + [nativeTokenPeriodicEnforcer, 1], + [exactCalldataEnforcer, 1], + [nonceEnforcer, 1], + ]), + ); + + // erc20-token-allowance + expect(byType['erc20-token-allowance']).toBeDefined(); + expect(byType['erc20-token-allowance'].permissionType).toBe( + 'erc20-token-allowance', + ); + expect(byType['erc20-token-allowance'].optionalEnforcers.size).toBe(1); + expect( + byType['erc20-token-allowance'].optionalEnforcers.has(timestampEnforcer), + ).toBe(true); + expect(byType['erc20-token-allowance'].requiredEnforcers.size).toBe(3); + expect( + Array.from(byType['erc20-token-allowance'].requiredEnforcers.entries()), + ).toStrictEqual( + expect.arrayContaining([ + [erc20PeriodicEnforcer, 1], + [valueLteEnforcer, 1], + [nonceEnforcer, 1], + ]), + ); + // erc20-token-revocation expect(byType['erc20-token-revocation']).toBeDefined(); expect(byType['erc20-token-revocation'].permissionType).toBe( From 84b409f963d8a7e1286ee8be97ea217e66b8ab43 Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Thu, 23 Apr 2026 16:40:40 +1200 Subject: [PATCH 4/7] Changelog --- packages/gator-permissions-controller/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/gator-permissions-controller/CHANGELOG.md b/packages/gator-permissions-controller/CHANGELOG.md index 957d4fbeb07..cdcbd66b3ae 100644 --- a/packages/gator-permissions-controller/CHANGELOG.md +++ b/packages/gator-permissions-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- New decoding rules for `native-token-allowance` and `erc20-token-allowance` ([#8553](https://github.com/MetaMask/core/pull/8553)) + ### Changed - Bump `@metamask/transaction-controller` from `^64.2.0` to `^64.3.0` ([#8482](https://github.com/MetaMask/core/pull/8482)) From ad34e773e32cf79168f0baad28a05ca3c6b7da4d Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Fri, 24 Apr 2026 10:31:38 +1200 Subject: [PATCH 5/7] Bump @metamask/7715-permission-types from ^0.5.0 to ^0.6.0 --- packages/gator-permissions-controller/package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/gator-permissions-controller/package.json b/packages/gator-permissions-controller/package.json index dfcc6dc1d02..e2f47c2fb4a 100644 --- a/packages/gator-permissions-controller/package.json +++ b/packages/gator-permissions-controller/package.json @@ -53,7 +53,7 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { - "@metamask/7715-permission-types": "^0.5.0", + "@metamask/7715-permission-types": "^0.6.0", "@metamask/abi-utils": "^2.0.3", "@metamask/base-controller": "^9.1.0", "@metamask/delegation-core": "^0.2.0", diff --git a/yarn.lock b/yarn.lock index d50feba1e21..86fe44029f2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2485,10 +2485,10 @@ __metadata: languageName: node linkType: hard -"@metamask/7715-permission-types@npm:^0.5.0": - version: 0.5.0 - resolution: "@metamask/7715-permission-types@npm:0.5.0" - checksum: 10/f01dcf7ffc3e39536f7cc4d54c088ea659c392de5bdfcaafb9f4d67bbe6b56010358ed2a2ba3adba4e454af51412a2fd5be377cac5c7ab101b032d30711e0b37 +"@metamask/7715-permission-types@npm:^0.6.0": + version: 0.6.0 + resolution: "@metamask/7715-permission-types@npm:0.6.0" + checksum: 10/bac0741ed0d880d9f418a58ef5d1f165cff0171636cb4431bc42a05b471b92afd6593810ac805a427a76dc02abcba651cc3c1c05b67d5a4b6ad3923b057de039 languageName: node linkType: hard @@ -4100,7 +4100,7 @@ __metadata: dependencies: "@lavamoat/allow-scripts": "npm:^3.0.4" "@lavamoat/preinstall-always-fail": "npm:^2.1.0" - "@metamask/7715-permission-types": "npm:^0.5.0" + "@metamask/7715-permission-types": "npm:^0.6.0" "@metamask/abi-utils": "npm:^2.0.3" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" From 2b61b0a49a0223f454d700654c87bc1b45480c3d Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Fri, 24 Apr 2026 10:59:09 +1200 Subject: [PATCH 6/7] Just some whitespace changes --- .../src/GatorPermissionsController.ts | 13 ++++-- .../decodePermission/decodePermission.test.ts | 46 +++++++++++++++---- .../src/decodePermission/decodePermission.ts | 18 ++++++-- 3 files changed, 57 insertions(+), 20 deletions(-) diff --git a/packages/gator-permissions-controller/src/GatorPermissionsController.ts b/packages/gator-permissions-controller/src/GatorPermissionsController.ts index fb41ad78e80..b1d65c2530f 100644 --- a/packages/gator-permissions-controller/src/GatorPermissionsController.ts +++ b/packages/gator-permissions-controller/src/GatorPermissionsController.ts @@ -601,11 +601,14 @@ export class GatorPermissionsController extends BaseController< permissionRules, }); - const { rule: matchingRule, expiry, data } = - selectUniqueRuleAndDecodedPermission({ - candidateRules: matchingRules, - caveats, - }); + const { + rule: matchingRule, + expiry, + data, + } = selectUniqueRuleAndDecodedPermission({ + candidateRules: matchingRules, + caveats, + }); const permission = reconstructDecodedPermission({ chainId, diff --git a/packages/gator-permissions-controller/src/decodePermission/decodePermission.test.ts b/packages/gator-permissions-controller/src/decodePermission/decodePermission.test.ts index 3d39d4084c4..e4ab6e0136c 100644 --- a/packages/gator-permissions-controller/src/decodePermission/decodePermission.test.ts +++ b/packages/gator-permissions-controller/src/decodePermission/decodePermission.test.ts @@ -79,7 +79,9 @@ describe('decodePermission', () => { }); expect(rules).toHaveLength(3); - expect(rules.map((matchingRule) => matchingRule.permissionType).sort()).toStrictEqual( + expect( + rules.map((matchingRule) => matchingRule.permissionType).sort(), + ).toStrictEqual( [ 'native-token-periodic', 'native-token-stream', @@ -191,7 +193,9 @@ describe('decodePermission', () => { enforcers, permissionRules: createPermissionRulesForContracts(contracts), }); - expect(rules.map((matchingRule) => matchingRule.permissionType).sort()).toStrictEqual( + expect( + rules.map((matchingRule) => matchingRule.permissionType).sort(), + ).toStrictEqual( [expectedPermissionType, 'native-token-allowance'].sort(), ); }); @@ -207,7 +211,9 @@ describe('decodePermission', () => { enforcers, permissionRules: createPermissionRulesForContracts(contracts), }); - expect(rules.map((matchingRule) => matchingRule.permissionType).sort()).toStrictEqual( + expect( + rules.map((matchingRule) => matchingRule.permissionType).sort(), + ).toStrictEqual( [expectedPermissionType, 'native-token-allowance'].sort(), ); }); @@ -248,7 +254,9 @@ describe('decodePermission', () => { enforcers, permissionRules: createPermissionRulesForContracts(contracts), }); - expect(rules.map((matchingRule) => matchingRule.permissionType).sort()).toStrictEqual( + expect( + rules.map((matchingRule) => matchingRule.permissionType).sort(), + ).toStrictEqual( [expectedPermissionType, 'native-token-allowance'].sort(), ); }); @@ -377,7 +385,9 @@ describe('decodePermission', () => { enforcers, permissionRules: createPermissionRulesForContracts(contracts), }); - expect(rules.map((matchingRule) => matchingRule.permissionType).sort()).toStrictEqual( + expect( + rules.map((matchingRule) => matchingRule.permissionType).sort(), + ).toStrictEqual( [expectedPermissionType, 'erc20-token-allowance'].sort(), ); }); @@ -393,7 +403,9 @@ describe('decodePermission', () => { enforcers, permissionRules: createPermissionRulesForContracts(contracts), }); - expect(rules.map((matchingRule) => matchingRule.permissionType).sort()).toStrictEqual( + expect( + rules.map((matchingRule) => matchingRule.permissionType).sort(), + ).toStrictEqual( [expectedPermissionType, 'erc20-token-allowance'].sort(), ); }); @@ -434,7 +446,9 @@ describe('decodePermission', () => { enforcers, permissionRules: createPermissionRulesForContracts(contracts), }); - expect(rules.map((matchingRule) => matchingRule.permissionType).sort()).toStrictEqual( + expect( + rules.map((matchingRule) => matchingRule.permissionType).sort(), + ).toStrictEqual( [expectedPermissionType, 'erc20-token-allowance'].sort(), ); }); @@ -707,7 +721,11 @@ describe('decodePermission', () => { { ...dummyRuleFields, permissionType: 'native-token-stream', - validateAndDecodePermission: () => ({ isValid: true, expiry: 9, data }), + validateAndDecodePermission: () => ({ + isValid: true, + expiry: 9, + data, + }), }, { ...dummyRuleFields, @@ -771,12 +789,20 @@ describe('decodePermission', () => { { ...dummyRuleFields, permissionType: 'native-token-stream', - validateAndDecodePermission: () => ({ isValid: true, expiry: 1, data }), + validateAndDecodePermission: () => ({ + isValid: true, + expiry: 1, + data, + }), }, { ...dummyRuleFields, permissionType: 'native-token-periodic', - validateAndDecodePermission: () => ({ isValid: true, expiry: 1, data }), + validateAndDecodePermission: () => ({ + isValid: true, + expiry: 1, + data, + }), }, ]; diff --git a/packages/gator-permissions-controller/src/decodePermission/decodePermission.ts b/packages/gator-permissions-controller/src/decodePermission/decodePermission.ts index 7ca067cf606..5799883d98a 100644 --- a/packages/gator-permissions-controller/src/decodePermission/decodePermission.ts +++ b/packages/gator-permissions-controller/src/decodePermission/decodePermission.ts @@ -68,7 +68,10 @@ export const findRuleWithMatchingCaveatAddresses = ({ return matchingRules[0]; }; -type SuccessfulValidateAndDecodeResult = Extract; +type SuccessfulValidateAndDecodeResult = Extract< + ValidateAndDecodeResult, + { isValid: true } +>; /** * Runs {@link PermissionRule.validateAndDecodePermission} on each candidate @@ -86,7 +89,11 @@ export const selectUniqueRuleAndDecodedPermission = ({ }: { candidateRules: PermissionRule[]; caveats: Caveat[]; -}): { rule: PermissionRule; expiry: number | null; data: SuccessfulValidateAndDecodeResult['data'] } => { +}): { + rule: PermissionRule; + expiry: number | null; + data: SuccessfulValidateAndDecodeResult['data']; +} => { if (candidateRules.length === 0) { throw new Error('Unable to identify permission type'); } @@ -96,8 +103,7 @@ export const selectUniqueRuleAndDecodedPermission = ({ result: SuccessfulValidateAndDecodeResult; }[] = []; - const failedAttempts: { permissionType: PermissionType; error: Error }[] = - []; + const failedAttempts: { permissionType: PermissionType; error: Error }[] = []; for (const rule of candidateRules) { const decodeResult = rule.validateAndDecodePermission(caveats); @@ -117,7 +123,9 @@ export const selectUniqueRuleAndDecodedPermission = ({ } if (successfulDecodingResult.length > 1) { - const types = successfulDecodingResult.map((result) => result.rule.permissionType).join(', '); + const types = successfulDecodingResult + .map((result) => result.rule.permissionType) + .join(', '); throw new Error( `Multiple permission types validate the same delegation caveats: ${types}`, ); From c633a82aab6ff64c667784654bd64ae58fad3f8d Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Fri, 24 Apr 2026 11:17:16 +1200 Subject: [PATCH 7/7] Update messenger-action-types --- .../src/GatorPermissionsController-method-action-types.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/gator-permissions-controller/src/GatorPermissionsController-method-action-types.ts b/packages/gator-permissions-controller/src/GatorPermissionsController-method-action-types.ts index 53635691b3c..cd8e92e7ab2 100644 --- a/packages/gator-permissions-controller/src/GatorPermissionsController-method-action-types.ts +++ b/packages/gator-permissions-controller/src/GatorPermissionsController-method-action-types.ts @@ -47,7 +47,9 @@ export type GatorPermissionsControllerInitializeAction = { * * @returns A decoded permission object suitable for UI consumption and follow-up actions. * @throws If the origin is not allowed, the context cannot be decoded into exactly one delegation, - * or the enforcers/terms do not match a supported permission type. + * the enforcers do not match any supported permission type, no candidate type validates + * the caveat terms, or more than one permission type successfully validates + * (ambiguous delegation). */ export type GatorPermissionsControllerDecodePermissionFromPermissionContextForOriginAction = {