diff --git a/packages/gator-permissions-controller/CHANGELOG.md b/packages/gator-permissions-controller/CHANGELOG.md index b3dd351a336..2fa3360aadf 100644 --- a/packages/gator-permissions-controller/CHANGELOG.md +++ b/packages/gator-permissions-controller/CHANGELOG.md @@ -9,7 +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/GatorPermissionsController.test.ts b/packages/gator-permissions-controller/src/GatorPermissionsController.test.ts index a3bb45ea4a9..8d443e19a19 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', @@ -141,6 +142,24 @@ describe('GatorPermissionsController', () => { expect(controller.state.isFetchingGatorPermissions).toBe(false); }); + + it('instantiates successfully without gatorPermissionsProviderSnapId', () => { + const configWithoutSnapId = { + supportedPermissionTypes: DEFAULT_TEST_CONFIG.supportedPermissionTypes, + }; + + let controller: GatorPermissionsController | undefined; + + expect(() => { + controller = new GatorPermissionsController({ + messenger: getGatorPermissionsControllerMessenger(), + config: configWithoutSnapId, + }); + }).not.toThrow(); + + expect(controller).toBeDefined(); + expect(controller?.state.grantedPermissions).toStrictEqual([]); + }); }); describe('fetchAndUpdateGatorPermissions', () => { @@ -721,6 +740,51 @@ 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: Hex = `0x${'00'.repeat(50)}`; + 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: MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, + 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..5a2489778fe 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 dacba8c1d6d..9fa8abf45c1 100644 --- a/packages/gator-permissions-controller/src/decodePermission/decodePermission.test.ts +++ b/packages/gator-permissions-controller/src/decodePermission/decodePermission.test.ts @@ -1,28 +1,17 @@ -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, 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 { - DecodedPermission, - DeployedContractsByName, - PermissionType, -} from './types'; +import { createPermissionRulesForContracts } from './rules'; +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. @@ -43,7 +32,7 @@ describe('decodePermission', () => { NonceEnforcer, } = contracts; - describe('identifyPermissionByEnforcers()', () => { + describe('getPermissionRuleMatchingCaveatTypes()', () => { const zeroAddress = '0x0000000000000000000000000000000000000000' as Hex; it('throws if multiple permission types match', () => { @@ -59,9 +48,11 @@ describe('decodePermission', () => { } as unknown as DeployedContractsByName; expect(() => { - identifyPermissionByEnforcers({ + findRuleWithMatchingCaveatAddresses({ enforcers, - contracts: contractsWithDuplicates, + permissionRules: createPermissionRulesForContracts( + contractsWithDuplicates, + ), }); }).toThrow('Multiple permission types match'); }); @@ -75,8 +66,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 +80,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 +96,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 +119,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 +138,11 @@ describe('decodePermission', () => { } as unknown as DeployedContractsByName; expect(() => - identifyPermissionByEnforcers({ + findRuleWithMatchingCaveatAddresses({ enforcers, - contracts: contractsWithoutTimestampEnforcer, + permissionRules: createPermissionRulesForContracts( + contractsWithoutTimestampEnforcer, + ), }), ).toThrow('Contract not found: TimestampEnforcer'); }); @@ -148,8 +156,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 +170,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 +186,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 +209,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 +228,11 @@ describe('decodePermission', () => { } as unknown as DeployedContractsByName; expect(() => - identifyPermissionByEnforcers({ + findRuleWithMatchingCaveatAddresses({ enforcers, - contracts: contractsWithoutTimestampEnforcer, + permissionRules: createPermissionRulesForContracts( + contractsWithoutTimestampEnforcer, + ), }), ).toThrow('Contract not found: TimestampEnforcer'); }); @@ -221,8 +246,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 +260,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 +276,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 +299,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 +318,11 @@ describe('decodePermission', () => { } as unknown as DeployedContractsByName; expect(() => - identifyPermissionByEnforcers({ + findRuleWithMatchingCaveatAddresses({ enforcers, - contracts: contractsWithoutTimestampEnforcer, + permissionRules: createPermissionRulesForContracts( + contractsWithoutTimestampEnforcer, + ), }), ).toThrow('Contract not found: TimestampEnforcer'); }); @@ -294,8 +336,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 +350,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 +366,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 +389,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 +408,11 @@ describe('decodePermission', () => { } as unknown as DeployedContractsByName; expect(() => - identifyPermissionByEnforcers({ + findRuleWithMatchingCaveatAddresses({ enforcers, - contracts: contractsWithoutTimestampEnforcer, + permissionRules: createPermissionRulesForContracts( + contractsWithoutTimestampEnforcer, + ), }), ).toThrow('Contract not found: TimestampEnforcer'); }); @@ -369,8 +428,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 +443,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 +457,10 @@ describe('decodePermission', () => { NonceEnforcer, ]; expect(() => - identifyPermissionByEnforcers({ enforcers, contracts }), + findRuleWithMatchingCaveatAddresses({ + enforcers, + permissionRules: createPermissionRulesForContracts(contracts), + }), ).toThrow('Unable to identify permission type'); }); @@ -405,7 +473,10 @@ describe('decodePermission', () => { NonceEnforcer, ]; expect(() => - identifyPermissionByEnforcers({ enforcers, contracts }), + findRuleWithMatchingCaveatAddresses({ + enforcers, + permissionRules: createPermissionRulesForContracts(contracts), + }), ).toThrow('Unable to identify permission type'); }); @@ -416,7 +487,10 @@ describe('decodePermission', () => { NonceEnforcer, ]; expect(() => - identifyPermissionByEnforcers({ enforcers, contracts }), + findRuleWithMatchingCaveatAddresses({ + enforcers, + permissionRules: createPermissionRulesForContracts(contracts), + }), ).toThrow('Unable to identify permission type'); }); @@ -430,7 +504,10 @@ describe('decodePermission', () => { ExactCalldataEnforcer, ]; expect(() => - identifyPermissionByEnforcers({ enforcers, contracts }), + findRuleWithMatchingCaveatAddresses({ + enforcers, + permissionRules: createPermissionRulesForContracts(contracts), + }), ).toThrow('Unable to identify permission type'); }); @@ -441,8 +518,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,804 +538,13 @@ describe('decodePermission', () => { } as unknown as DeployedContractsByName; expect(() => - identifyPermissionByEnforcers({ + findRuleWithMatchingCaveatAddresses({ enforcers, - contracts: 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' }, + permissionRules: createPermissionRulesForContracts( + contractsWithoutAllowedCalldataEnforcer, ), - 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', - ); - }); - }); - - 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.'); - }); - }); - - 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.'); - }); - }); - - 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.'); - }); - }); - - 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'); + ).toThrow('Contract not found: AllowedCalldataEnforcer'); }); }); }); @@ -1351,4 +640,136 @@ describe('decodePermission', () => { ).toThrow('Invalid authority'); }); }); + + describe('adversarial: attempts to violate decoder expectations', () => { + describe('getPermissionRuleMatchingCaveatTypes()', () => { + it('rejects empty enforcer list', () => { + expect(() => + 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(() => + findRuleWithMatchingCaveatAddresses({ + enforcers: [unknown], + permissionRules: createPermissionRulesForContracts(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(() => + findRuleWithMatchingCaveatAddresses({ + enforcers, + permissionRules: createPermissionRulesForContracts(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(() => + findRuleWithMatchingCaveatAddresses({ + enforcers, + permissionRules: createPermissionRulesForContracts(contracts), + }), + ).toThrow('Unable to identify permission type'); + }); + + it('rejects exactly one AllowedCalldataEnforcer for erc20-token-revocation (wrong multiplicity)', () => { + const enforcers = [ + AllowedCalldataEnforcer, + ValueLteEnforcer, + NonceEnforcer, + ]; + expect(() => + findRuleWithMatchingCaveatAddresses({ + enforcers, + permissionRules: createPermissionRulesForContracts(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(() => + findRuleWithMatchingCaveatAddresses({ + enforcers, + permissionRules: createPermissionRulesForContracts(contracts), + }), + ).toThrow('Unable to identify permission type'); + }); + }); + + 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'); + }); + }); + }); }); diff --git a/packages/gator-permissions-controller/src/decodePermission/decodePermission.ts b/packages/gator-permissions-controller/src/decodePermission/decodePermission.ts index ca486fc38c9..e8997009c66 100644 --- a/packages/gator-permissions-controller/src/decodePermission/decodePermission.ts +++ b/packages/gator-permissions-controller/src/decodePermission/decodePermission.ts @@ -1,325 +1,45 @@ -import type { Caveat, Hex } from '@metamask/delegation-core'; +import type { Hex } from '@metamask/delegation-core'; import { ROOT_AUTHORITY } from '@metamask/delegation-core'; -import { getChecksumAddress, 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 { DecodedPermission, PermissionType } from './types'; +import type { PermissionRule } from './types'; /** - * 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(), - ]); + permissionRules: PermissionRule[]; +}): PermissionRule => { + const matchingRules = permissionRules.filter((rule) => + rule.caveatAddressesMatch(enforcers), + ); - let hasForbiddenEnforcers = false; - - for (const caveat of enforcersSet) { - if (!allowedEnforcers.has(caveat)) { - hasForbiddenEnforcers = true; - break; - } - } - - // 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}`, - ); + if (matchingRules.length > 1) { + throw new Error('Multiple permission types match'); } - - 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; -}; - -/** - * 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]); - - data = { - tokenAddress, - initialAmount, - maxAmount, - amountPerSecond, - startTime: hexToNumber(startTimeRaw), - }; - break; - } - case 'erc20-token-periodic': { - const erc20PeriodicTerms = getTermsByEnforcer({ - caveats: checksumCaveats, - enforcer: erc20PeriodicEnforcer, - }); - - const [tokenAddress, periodAmount, periodDurationRaw, startTimeRaw] = - splitHex(erc20PeriodicTerms, [20, 32, 32, 32]); - - data = { - tokenAddress, - periodAmount, - periodDuration: hexToNumber(periodDurationRaw), - startTime: hexToNumber(startTimeRaw), - }; - break; - } - - case 'native-token-stream': { - const nativeTokenStreamingTerms = getTermsByEnforcer({ - caveats: checksumCaveats, - enforcer: nativeTokenStreamingEnforcer, - }); - - const [initialAmount, maxAmount, amountPerSecond, startTimeRaw] = - splitHex(nativeTokenStreamingTerms, [32, 32, 32, 32]); - - data = { - initialAmount, - maxAmount, - amountPerSecond, - startTime: hexToNumber(startTimeRaw), - }; - break; - } - case 'native-token-periodic': { - const nativeTokenPeriodicTerms = getTermsByEnforcer({ - caveats: checksumCaveats, - enforcer: nativeTokenPeriodicEnforcer, - }); - - const [periodAmount, periodDurationRaw, startTimeRaw] = splitHex( - nativeTokenPeriodicTerms, - [32, 32, 32], - ); - - data = { - periodAmount, - periodDuration: hexToNumber(periodDurationRaw), - startTime: hexToNumber(startTimeRaw), - }; - 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..0088d560f3f 100644 --- a/packages/gator-permissions-controller/src/decodePermission/index.ts +++ b/packages/gator-permissions-controller/src/decodePermission/index.ts @@ -1,7 +1,11 @@ export { - identifyPermissionByEnforcers, - getPermissionDataAndExpiry, + findRuleWithMatchingCaveatAddresses, reconstructDecodedPermission, } from './decodePermission'; +export { createPermissionRulesForContracts } from './rules'; -export type { DecodedPermission } from './types'; +export type { + DecodedPermission, + PermissionRule, + ValidateAndDecodeResult, +} 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..5b608be4d73 --- /dev/null +++ b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenPeriodic.test.ts @@ -0,0 +1,328 @@ +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 '.'; +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, ValueLteEnforcer } = + contracts; + const permissionRules = createPermissionRulesForContracts(contracts); + const rule = permissionRules.find( + (candidate) => candidate.permissionType === 'erc20-token-periodic', + ); + 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, + }; + + it('rejects duplicate ERC20PeriodTransferEnforcer caveats', () => { + const tokenAddress = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex; + const terms = createERC20TokenPeriodTransferTerms( + { + tokenAddress, + periodAmount: 100n, + periodDuration: 86400, + startDate: 1715664, + }, + { out: 'hex' }, + ); + const caveats = [ + expiryCaveat, + valueLteCaveat, + { + enforcer: ERC20PeriodTransferEnforcer, + terms, + args: '0x' as const, + }, + { + enforcer: ERC20PeriodTransferEnforcer, + terms, + args: '0x' as const, + }, + ]; + const result = 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${'a'.repeat(100)}`; // 50 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); + + // this is here as a type guard + if (result.isValid) { + throw new Error('Expected invalid result'); + } + + expect(result.error.message).toContain( + 'Invalid erc20-token-periodic terms: expected 116 bytes', + ); + }); + + it('rejects when ValueLteEnforcer terms are not zero (native token value must be zero)', () => { + const nonZeroValueLteTerms = + '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex; + const tokenAddress = '0xcccccccccccccccccccccccccccccccccccccccc' as Hex; + const caveats = [ + expiryCaveat, + { + enforcer: ValueLteEnforcer, + terms: nonZeroValueLteTerms, + args: '0x' as const, + }, + { + enforcer: ERC20PeriodTransferEnforcer, + terms: createERC20TokenPeriodTransferTerms( + { + tokenAddress, + periodAmount: 200n, + periodDuration: 86400, + startDate: 1715664, + }, + { out: 'hex' }, + ), + args: '0x' as const, + }, + ]; + const result = 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( + { + 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, + 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 a positive number', + ); + }); + + it('rejects when terms have trailing bytes', () => { + const tokenAddress = '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Hex; + const validTerms = createERC20TokenPeriodTransferTerms( + { + tokenAddress, + periodAmount: 100n, + periodDuration: 86400, + startDate: 1715664, + }, + { out: 'hex' }, + ); + const termsWithTrailing = `${validTerms}deadbeef` as Hex; + const caveats = [ + expiryCaveat, + valueLteCaveat, + { + enforcer: ERC20PeriodTransferEnforcer, + terms: termsWithTrailing, + args: '0x' as const, + }, + ]; + const result = 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, + 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: 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 new file mode 100644 index 00000000000..7c6131ed95c --- /dev/null +++ b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenPeriodic.ts @@ -0,0 +1,117 @@ +import { hexToBigInt, hexToNumber, isHexAddress } from '@metamask/utils'; + +import { makePermissionRule } from './makePermissionRule'; +import type { + ChecksumCaveat, + ChecksumEnforcersByChainId, + DecodedPermission, + PermissionRule, +} from '../types'; +import { + getByteLength, + getTermsByEnforcer, + splitHex, + ZERO_32_BYTES, +} from '../utils'; + +/** + * 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, +): PermissionRule { + const { + timestampEnforcer, + erc20PeriodicEnforcer, + valueLteEnforcer, + nonceEnforcer, + } = enforcers; + return makePermissionRule({ + permissionType: 'erc20-token-periodic', + optionalEnforcers: [timestampEnforcer], + timestampEnforcer, + requiredEnforcers: { + [erc20PeriodicEnforcer]: 1, + [valueLteEnforcer]: 1, + [nonceEnforcer]: 1, + }, + validateAndDecodeData: (caveats) => + validateAndDecodeData(caveats, { + erc20PeriodicEnforcer, + valueLteEnforcer, + }), + }); +} + +/** + * Decodes erc20-token-periodic 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 periodic 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-periodic terms: expected 116 bytes'); + } + + const [tokenAddress, periodAmount, periodDurationRaw, startTimeRaw] = + splitHex(terms, [20, 32, 32, 32]); + const periodDuration = hexToNumber(periodDurationRaw); + const periodAmountBigInt = hexToBigInt(periodAmount); + const startTime = hexToNumber(startTimeRaw); + + if (!isHexAddress(tokenAddress)) { + throw new Error( + 'Invalid erc20-token-periodic terms: tokenAddress must be a valid hex string', + ); + } + + if (periodAmountBigInt === 0n) { + throw new Error( + 'Invalid erc20-token-periodic terms: periodAmount must be a positive number', + ); + } + + 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..3e27a482c7d --- /dev/null +++ b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenRevocation.test.ts @@ -0,0 +1,221 @@ +import { createTimestampTerms } from '@metamask/delegation-core'; +import { + CHAIN_ID, + DELEGATOR_CONTRACTS, +} from '@metamask/delegation-deployments'; + +import { createPermissionRulesForContracts } from '.'; + +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( + (candidate) => candidate.permissionType === 'erc20-token-revocation', + ); + if (!rule) { + throw new Error('Rule not found'); + } + + 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('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).toStrictEqual({}); + }); +}); 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..5691e5dc507 --- /dev/null +++ b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenRevocation.ts @@ -0,0 +1,96 @@ +import { makePermissionRule } from './makePermissionRule'; +import type { + ChecksumCaveat, + ChecksumEnforcersByChainId, + DecodedPermission, + PermissionRule, +} from '../types'; +import { + ERC20_APPROVE_SELECTOR_TERMS, + ERC20_APPROVE_ZERO_AMOUNT_TERMS, + getTermsByEnforcer, + ZERO_32_BYTES, +} from '../utils'; + +/** + * 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, +): PermissionRule { + const { + timestampEnforcer, + allowedCalldataEnforcer, + valueLteEnforcer, + nonceEnforcer, + } = enforcers; + return makePermissionRule({ + permissionType: 'erc20-token-revocation', + optionalEnforcers: [timestampEnforcer], + timestampEnforcer, + requiredEnforcers: { + [allowedCalldataEnforcer]: 2, + [valueLteEnforcer]: 1, + [nonceEnforcer]: 1, + }, + validateAndDecodeData: (caveats) => + validateAndDecodeData(caveats, { + allowedCalldataEnforcer, + valueLteEnforcer, + }), + }); +} + +/** + * Decodes erc20-token-revocation permission data from caveats; throws on invalid. + * + * @param caveats - Caveats from the permission context (checksummed). + * @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). + */ +function validateAndDecodeData( + caveats: ChecksumCaveat[], + enforcers: Pick< + ChecksumEnforcersByChainId, + 'allowedCalldataEnforcer' | 'valueLteEnforcer' + >, +): DecodedPermission['permission']['data'] { + const { allowedCalldataEnforcer, valueLteEnforcer } = enforcers; + + const allowedCalldataCaveats = caveats.filter( + (caveat) => caveat.enforcer === allowedCalldataEnforcer, + ); + const allowedCalldataTerms = allowedCalldataCaveats.map((caveat) => + caveat.terms.toLowerCase(), + ); + + const hasApproveSelector = allowedCalldataTerms.includes( + ERC20_APPROVE_SELECTOR_TERMS, + ); + + const hasZeroAmount = allowedCalldataTerms.includes( + ERC20_APPROVE_ZERO_AMOUNT_TERMS, + ); + + if (!hasApproveSelector || !hasZeroAmount) { + throw new Error( + 'Invalid erc20-token-revocation terms: expected approve selector and zero amount constraints', + ); + } + + const valueLteTerms = getTermsByEnforcer({ + caveats, + enforcer: valueLteEnforcer, + }); + + if (valueLteTerms !== ZERO_32_BYTES) { + throw new Error('Invalid ValueLteEnforcer terms: maxValue must be 0'); + } + + return {}; +} diff --git a/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenStream.test.ts b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenStream.test.ts new file mode 100644 index 00000000000..1590c6c8b9e --- /dev/null +++ b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenStream.test.ts @@ -0,0 +1,331 @@ +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 '.'; +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, ValueLteEnforcer } = + contracts; + const permissionRules = createPermissionRulesForContracts(contracts); + const rule = permissionRules.find( + (candidate) => candidate.permissionType === 'erc20-token-stream', + ); + 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, + }; + + it('rejects duplicate ERC20StreamingEnforcer caveats', () => { + const tokenAddress = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex; + const terms = createERC20StreamingTerms( + { + tokenAddress, + initialAmount: 1n, + maxAmount: 2n, + amountPerSecond: 1n, + startTime: 1715664, + }, + { out: 'hex' }, + ); + const caveats = [ + expiryCaveat, + valueLteCaveat, + { enforcer: ERC20StreamingEnforcer, terms, args: '0x' as const }, + { enforcer: ERC20StreamingEnforcer, terms, args: '0x' as const }, + ]; + const result = 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${'a'.repeat(100)}`; + const caveats = [ + expiryCaveat, + valueLteCaveat, + { + 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('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( + { + 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, + 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( + 'maxAmount must be greater than initialAmount', + ); + }); + + 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( + { + tokenAddress, + initialAmount: 42n, + maxAmount: 100n, + amountPerSecond: 1n, + startTime: 1715664, + }, + { out: 'hex' }, + ); + const termsWithTrailing = `${validTerms}deadbeef` as Hex; + const caveats = [ + expiryCaveat, + valueLteCaveat, + { + enforcer: ERC20StreamingEnforcer, + terms: termsWithTrailing, + args: '0x' as const, + }, + ]; + const result = 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 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'); + const maxAmountHex = 2n.toString(16).padStart(64, '0'); + const amountPerSecondHex = 1n.toString(16).padStart(64, '0'); + const startTimeZero = '0'.repeat(64); + const terms = + `0x${tokenAddress.slice(2)}${initialAmountHex}${maxAmountHex}${amountPerSecondHex}${startTimeZero}` as Hex; + const caveats = [ + expiryCaveat, + valueLteCaveat, + { enforcer: ERC20StreamingEnforcer, terms, args: '0x' as const }, + ]; + const result = 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', + ); + }); + + 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 new file mode 100644 index 00000000000..7fb6f7b1740 --- /dev/null +++ b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenStream.ts @@ -0,0 +1,136 @@ +import { hexToBigInt, hexToNumber, isHexAddress } from '@metamask/utils'; + +import { makePermissionRule } from './makePermissionRule'; +import type { + ChecksumCaveat, + ChecksumEnforcersByChainId, + DecodedPermission, + PermissionRule, +} from '../types'; +import { + getByteLength, + getTermsByEnforcer, + splitHex, + ZERO_32_BYTES, +} from '../utils'; + +/** + * 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, +): PermissionRule { + const { + timestampEnforcer, + erc20StreamingEnforcer, + valueLteEnforcer, + nonceEnforcer, + } = enforcers; + return makePermissionRule({ + permissionType: 'erc20-token-stream', + optionalEnforcers: [timestampEnforcer], + timestampEnforcer, + requiredEnforcers: { + [erc20StreamingEnforcer]: 1, + [valueLteEnforcer]: 1, + [nonceEnforcer]: 1, + }, + validateAndDecodeData: (caveats) => + validateAndDecodeData(caveats, { + erc20StreamingEnforcer, + valueLteEnforcer, + }), + }); +} + +/** + * Decodes erc20-token-stream permission data from caveats; throws on invalid. + * + * @param caveats - Caveats from the permission context (checksummed). + * @param enforcers - Addresses of the enforcers. + * @param enforcers.erc20StreamingEnforcer - Address of the ERC20StreamingEnforcer. + * @param enforcers.valueLteEnforcer - Address of the ValueLteEnforcer. + * @returns Decoded stream terms. + */ +function validateAndDecodeData( + caveats: ChecksumCaveat[], + enforcers: Pick< + ChecksumEnforcersByChainId, + 'erc20StreamingEnforcer' | 'valueLteEnforcer' + >, +): DecodedPermission['permission']['data'] { + 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; + + if (getByteLength(terms) !== EXPECTED_TERMS_BYTELENGTH) { + throw new Error('Invalid erc20-token-stream terms: expected 148 bytes'); + } + + const [ + tokenAddress, + initialAmount, + maxAmount, + amountPerSecond, + startTimeRaw, + ] = splitHex(terms, [20, 32, 32, 32, 32]); + + const startTime = hexToNumber(startTimeRaw); + const initialAmountBigInt = hexToBigInt(initialAmount); + const maxAmountBigInt = hexToBigInt(maxAmount); + const amountPerSecondBigInt = hexToBigInt(amountPerSecond); + + if (!isHexAddress(tokenAddress)) { + throw new Error( + 'Invalid erc20-token-stream terms: tokenAddress must be a valid hex string', + ); + } + + if (maxAmountBigInt < initialAmountBigInt) { + throw new Error( + 'Invalid erc20-token-stream terms: maxAmount must be greater than initialAmount', + ); + } + + 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', + ); + } + + 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..b395e54fd51 --- /dev/null +++ b/packages/gator-permissions-controller/src/decodePermission/rules/index.ts @@ -0,0 +1,31 @@ +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. + * + * 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..22fc901bc01 --- /dev/null +++ b/packages/gator-permissions-controller/src/decodePermission/rules/makePermissionRule.test.ts @@ -0,0 +1,52 @@ +import { createTimestampTerms } from '@metamask/delegation-core'; +import { + CHAIN_ID, + DELEGATOR_CONTRACTS, +} from '@metamask/delegation-deployments'; +import type { Hex } from '@metamask/utils'; + +import { makePermissionRule } from './makePermissionRule'; + +describe('makePermissionRule', () => { + const contracts = DELEGATOR_CONTRACTS['1.3.0'][CHAIN_ID.sepolia]; + const timestampEnforcer = contracts.TimestampEnforcer; + const requiredEnforcer = contracts.NonceEnforcer; + + it('calls optional validate callback when provided and decoding succeeds', () => { + const validateAndDecodeData = jest.fn().mockReturnValue({}); + + const rule = makePermissionRule({ + permissionType: 'native-token-stream', + timestampEnforcer, + optionalEnforcers: [], + requiredEnforcers: { [requiredEnforcer]: 1 }, + validateAndDecodeData, + }); + + const caveats = [ + { + enforcer: timestampEnforcer, + terms: createTimestampTerms({ + timestampAfterThreshold: 0, + timestampBeforeThreshold: 1720000, + }), + args: '0x' as Hex, + }, + { + enforcer: requiredEnforcer, + terms: '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).toStrictEqual({}); + 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 new file mode 100644 index 00000000000..fb7c48485ed --- /dev/null +++ b/packages/gator-permissions-controller/src/decodePermission/rules/makePermissionRule.ts @@ -0,0 +1,94 @@ +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, +} 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.validateAndDecodeData - Callback to decode caveats into permission data; may throw. + * @returns A permission rule with caveatAddressesMatch and validateAndDecodePermission. + */ +export function makePermissionRule({ + optionalEnforcers, + timestampEnforcer, + permissionType, + requiredEnforcers, + validateAndDecodeData, +}: { + optionalEnforcers: Hex[]; + timestampEnforcer: Hex; + permissionType: PermissionType; + requiredEnforcers: Record; + validateAndDecodeData: ( + caveats: ChecksumCaveat[], + ) => DecodedPermission['permission']['data']; +}): PermissionRule { + const optionalEnforcersSet = new Set(optionalEnforcers); + const requiredEnforcersMap = new Map( + Object.entries(requiredEnforcers), + ) as Map; + + return { + permissionType, + requiredEnforcers: requiredEnforcersMap, + optionalEnforcers: optionalEnforcersSet, + caveatAddressesMatch(caveatAddresses: Hex[]): boolean { + const { counts, enforcersSet } = + buildEnforcerCountsAndSet(caveatAddresses); + + return enforcersMatchRule( + counts, + enforcersSet, + requiredEnforcersMap, + optionalEnforcersSet, + ); + }, + 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); + } + + 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 new file mode 100644 index 00000000000..0fa484ba2f1 --- /dev/null +++ b/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenPeriodic.test.ts @@ -0,0 +1,287 @@ +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 '.'; + +describe('native-token-periodic 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-periodic', + ); + 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, + }; + + it('rejects duplicate NativeTokenPeriodTransferEnforcer caveats', () => { + const terms = createNativeTokenPeriodTransferTerms( + { + periodAmount: 100n, + periodDuration: 86400, + startDate: 1715664, + }, + { out: 'hex' }, + ); + const caveats = [ + expiryCaveat, + exactCalldataCaveat, + { + enforcer: NativeTokenPeriodTransferEnforcer, + terms, + args: '0x' as const, + }, + { + enforcer: NativeTokenPeriodTransferEnforcer, + terms, + args: '0x' as const, + }, + ]; + const result = 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); + + // this is here as a type guard + if (result.isValid) { + throw new Error('Expected invalid result'); + } + + expect(result.error.message).toContain( + 'Invalid native-token-periodic terms: expected 96 bytes', + ); + }); + + it('rejects when terms have trailing bytes', () => { + const validTerms = createNativeTokenPeriodTransferTerms( + { + periodAmount: 100n, + periodDuration: 86400, + startDate: 1715664, + }, + { out: 'hex' }, + ); + const termsWithTrailing = `${validTerms}deadbeef` as Hex; + const caveats = [ + expiryCaveat, + exactCalldataCaveat, + { + enforcer: NativeTokenPeriodTransferEnforcer, + terms: termsWithTrailing, + args: '0x' as const, + }, + ]; + const result = 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 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( + { + 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, + 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 a positive number', + ); + }); + + it('rejects when startTime is 0', () => { + const periodAmountHex = 100n.toString(16).padStart(64, '0'); + const periodDurationHex = (86400).toString(16).padStart(64, '0'); + const startTimeZero = '0'.repeat(64); + const terms = + `0x${periodAmountHex}${periodDurationHex}${startTimeZero}` as Hex; + const caveats = [ + expiryCaveat, + exactCalldataCaveat, + { + enforcer: NativeTokenPeriodTransferEnforcer, + terms, + args: '0x' as const, + }, + ]; + const result = 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', + ); + }); + + 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 new file mode 100644 index 00000000000..4a4fea453f3 --- /dev/null +++ b/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenPeriodic.ts @@ -0,0 +1,109 @@ +import { hexToBigInt, hexToNumber } from '@metamask/utils'; + +import { makePermissionRule } from './makePermissionRule'; +import type { + ChecksumCaveat, + ChecksumEnforcersByChainId, + DecodedPermission, + PermissionRule, +} from '../types'; +import { getByteLength, getTermsByEnforcer, splitHex } from '../utils'; + +/** + * 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, +): PermissionRule { + const { + timestampEnforcer, + nativeTokenPeriodicEnforcer, + exactCalldataEnforcer, + nonceEnforcer, + } = enforcers; + return makePermissionRule({ + permissionType: 'native-token-periodic', + optionalEnforcers: [timestampEnforcer], + timestampEnforcer, + requiredEnforcers: { + [nativeTokenPeriodicEnforcer]: 1, + [exactCalldataEnforcer]: 1, + [nonceEnforcer]: 1, + }, + validateAndDecodeData: (caveats) => + validateAndDecodeData(caveats, { + nativeTokenPeriodicEnforcer, + exactCalldataEnforcer, + }), + }); +} + +/** + * Decodes native-token-periodic 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 periodic 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-periodic terms: expected 96 bytes'); + } + + const [periodAmount, periodDurationRaw, startTimeRaw] = splitHex( + terms, + [32, 32, 32], + ); + const periodDuration = hexToNumber(periodDurationRaw); + const startTime = hexToNumber(startTimeRaw); + const periodAmountBigInt = hexToBigInt(periodAmount); + + if (periodAmountBigInt === 0n) { + throw new Error( + 'Invalid native-token-periodic terms: periodAmount must be a positive number', + ); + } + + if (periodDuration === 0) { + throw new Error( + 'Invalid native-token-periodic terms: periodDuration must be a positive number', + ); + } + + if (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..c6e3193fb4d --- /dev/null +++ b/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenStream.test.ts @@ -0,0 +1,453 @@ +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 '.'; + +describe('native-token-stream rule', () => { + const chainId = CHAIN_ID.sepolia; + const contracts = DELEGATOR_CONTRACTS['1.3.0'][chainId]; + const { + TimestampEnforcer, + NativeTokenStreamingEnforcer, + ExactCalldataEnforcer, + } = contracts; + const permissionRules = createPermissionRulesForContracts(contracts); + const rule = permissionRules.find( + (candidate) => candidate.permissionType === 'native-token-stream', + ); + 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, + }; + + it('rejects duplicate caveats for same enforcer (e.g. two TimestampEnforcer)', () => { + const caveats = [ + expiryCaveat, + exactCalldataCaveat, + { + 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 = [ + exactCalldataCaveat, + { enforcer: TimestampEnforcer, terms: invalidTerms, args: '0x' as const }, + { + enforcer: NativeTokenStreamingEnforcer, + terms: createNativeTokenStreamingTerms( + { + initialAmount: 1n, + maxAmount: 2n, + amountPerSecond: 1n, + startTime: 1715664, + }, + { out: 'hex' }, + ), + args: '0x' as const, + }, + ]; + + const result = 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: Hex = `0x${'00'.repeat(50)}`; + const caveats = [ + expiryCaveat, + exactCalldataCaveat, + { + 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, + exactCalldataCaveat, + { + 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('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( + { + 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: Hex = `0x${'0'.repeat(65)}`; + const caveats = [ + exactCalldataCaveat, + { + enforcer: TimestampEnforcer, + terms: badLengthTerms, + args: '0x' as const, + }, + { + enforcer: NativeTokenStreamingEnforcer, + terms: createNativeTokenStreamingTerms( + { + initialAmount: 1n, + maxAmount: 2n, + amountPerSecond: 1n, + startTime: 1715664, + }, + { out: 'hex' }, + ), + args: '0x' as const, + }, + ]; + + const result = 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 = [ + exactCalldataCaveat, + { + 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 = [ + exactCalldataCaveat, + { + 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 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'); + const terms = `0x${ZERO_32}${ZERO_32}${ZERO_32}${startTimeHex}` as Hex; + + const caveats = [ + expiryCaveat, + exactCalldataCaveat, + { enforcer: NativeTokenStreamingEnforcer, terms, args: '0x' as const }, + ]; + + const result = 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 amountPerSecond is zero', () => { + const initialAmountHex = 1n.toString(16).padStart(64, '0'); + const maxAmountHex = 2n.toString(16).padStart(64, '0'); + const amountPerSecondZero = '0'.repeat(64); + const startTimeHex = (1715664).toString(16).padStart(64, '0'); + const terms = + `0x${initialAmountHex}${maxAmountHex}${amountPerSecondZero}${startTimeHex}` as Hex; + const caveats = [ + expiryCaveat, + exactCalldataCaveat, + { enforcer: NativeTokenStreamingEnforcer, terms, args: '0x' as const }, + ]; + const result = 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, + 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: 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..228ca98ecb7 --- /dev/null +++ b/packages/gator-permissions-controller/src/decodePermission/rules/nativeTokenStream.ts @@ -0,0 +1,116 @@ +import { hexToBigInt, hexToNumber } from '@metamask/utils'; + +import { makePermissionRule } from './makePermissionRule'; +import type { + ChecksumCaveat, + ChecksumEnforcersByChainId, + DecodedPermission, + PermissionRule, +} from '../types'; +import { getByteLength, getTermsByEnforcer, splitHex } from '../utils'; + +/** + * 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, +): PermissionRule { + const { + timestampEnforcer, + nativeTokenStreamingEnforcer, + exactCalldataEnforcer, + nonceEnforcer, + } = enforcers; + return makePermissionRule({ + permissionType: 'native-token-stream', + optionalEnforcers: [timestampEnforcer], + timestampEnforcer, + requiredEnforcers: { + [nativeTokenStreamingEnforcer]: 1, + [exactCalldataEnforcer]: 1, + [nonceEnforcer]: 1, + }, + validateAndDecodeData: (caveats) => + validateAndDecodeData(caveats, { + nativeTokenStreamingEnforcer, + exactCalldataEnforcer, + }), + }); +} + +/** + * Decodes native-token-stream permission data from caveats; throws on invalid. + * + * @param caveats - Caveats from the permission context (checksummed). + * @param enforcers - Addresses of the enforcers. + * @param enforcers.nativeTokenStreamingEnforcer - Address of the NativeTokenStreamingEnforcer. + * @param enforcers.exactCalldataEnforcer - Address of the ExactCalldataEnforcer. + * @returns Decoded stream terms. + */ +function validateAndDecodeData( + caveats: ChecksumCaveat[], + enforcers: Pick< + ChecksumEnforcersByChainId, + 'nativeTokenStreamingEnforcer' | 'exactCalldataEnforcer' + >, +): DecodedPermission['permission']['data'] { + 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 + + if (getByteLength(terms) !== EXPECTED_TERMS_BYTELENGTH) { + throw new Error('Invalid native-token-stream terms: expected 128 bytes'); + } + + const [initialAmount, maxAmount, amountPerSecond, startTimeRaw] = splitHex( + terms, + [32, 32, 32, 32], + ); + const initialAmountBigInt = hexToBigInt(initialAmount); + const maxAmountBigInt = hexToBigInt(maxAmount); + const amountPerSecondBigInt = hexToBigInt(amountPerSecond); + const startTime = hexToNumber(startTimeRaw); + + if (maxAmountBigInt < initialAmountBigInt) { + throw new Error( + 'Invalid native-token-stream terms: maxAmount must be greater than initialAmount', + ); + } + + if (initialAmountBigInt === 0n) { + throw new Error( + 'Invalid native-token-stream terms: initialAmount 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..d6a6e8ac0dd 100644 --- a/packages/gator-permissions-controller/src/decodePermission/types.ts +++ b/packages/gator-permissions-controller/src/decodePermission/types.ts @@ -2,7 +2,9 @@ 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'; export type DeployedContractsByName = (typeof DELEGATOR_CONTRACTS)[number][number]; @@ -35,3 +37,56 @@ 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; + +/** + * 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 }; + +/** + * 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..78992f5494c 100644 --- a/packages/gator-permissions-controller/src/decodePermission/utils.test.ts +++ b/packages/gator-permissions-controller/src/decodePermission/utils.test.ts @@ -2,12 +2,11 @@ import type { Caveat } from '@metamask/delegation-core'; import { getChecksumAddress } from '@metamask/utils'; import type { Hex } from '@metamask/utils'; +import { createPermissionRulesForContracts } from './rules'; import type { DeployedContractsByName } from './types'; import { - createPermissionRulesForChainId, getChecksumEnforcersByChainId, getTermsByEnforcer, - isSubset, splitHex, } from './utils'; @@ -85,7 +84,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,19 +191,39 @@ describe('createPermissionRulesForChainId', () => { ]), ); }); -}); -describe('isSubset', () => { - it('returns true when subset is contained', () => { - expect(isSubset(new Set([1, 2]), new Set([1, 2, 3]))).toBe(true); - }); + it('each rule has caveatAddressesMatch and validateAndDecodePermission', () => { + const contracts = buildContracts(); + const rules = createPermissionRulesForContracts(contracts); + const { + nativeTokenStreamingEnforcer, + exactCalldataEnforcer, + nonceEnforcer, + timestampEnforcer, + } = getChecksumEnforcersByChainId(contracts); - it('returns false when subset has an extra element', () => { - expect(isSubset(new Set([1, 4]), new Set([1, 2, 3]))).toBe(false); - }); + for (const rule of rules) { + expect(typeof rule.caveatAddressesMatch).toBe('function'); + expect(typeof rule.validateAndDecodePermission).toBe('function'); + } + + const nativeStreamRule = rules.find( + (candidate) => candidate.permissionType === 'native-token-stream', + ); + expect(nativeStreamRule).toBeDefined(); + if (!nativeStreamRule) { + throw new Error('Rule not found'); + } - it('returns true for empty subset', () => { - expect(isSubset(new Set(), new Set([1, 2, 3]))).toBe(true); + const matchingCaveatAddresses: Hex[] = [ + nativeTokenStreamingEnforcer, + exactCalldataEnforcer, + nonceEnforcer, + timestampEnforcer, + ]; + 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 720a9b027b2..4a40671cbd5 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,30 @@ 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. + * + * @param hexString - The hex string to get the byte length of. + * @returns The byte length of the hex string. + */ +export const getByteLength = (hexString: Hex): number => { + return (hexString.length - 2) / 2; +}; + /** * Resolves and returns checksummed addresses of all known enforcer contracts * for a given `chainId` under the current delegation framework version. @@ -38,17 +56,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,103 +113,83 @@ export const getChecksumEnforcersByChainId = ( }; /** - * Builds the canonical set of permission matching rules for a chain. + * Extracts the expiry timestamp from TimestampEnforcer caveat terms. + * Terms are 32 bytes: first 16 bytes timestampAfterThreshold (must be 0), + * last 16 bytes timestampBeforeThreshold (expiry). * - * 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. - * - * @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]); - - 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', - }, - ]; - - return permissionRules; +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; }; /** - * Determines whether all elements of `subset` are contained within `superset`. + * Builds enforcer counts and set from caveat addresses (checksummed). + * Used by caveatAddressesMatch. * - * @param subset - The candidate subset to test. - * @param superset - The set expected to contain all elements of `subset`. - * @returns `true` if `subset` ⊆ `superset`, otherwise `false`. + * @param caveatAddresses - List of enforcer contract addresses (hex). + * @returns Counts per enforcer and set of unique enforcers. */ -export const isSubset = ( - subset: Set, - superset: Set, -): boolean => { - for (const element of subset) { - if (!superset.has(element)) { +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()) }; +} + +/** + * 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, + 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; -}; +} /** * Gets the terms for a given enforcer from a list of caveats.