From c2c1b6863f1fe486c00d5776e21ed65812f471bc Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Wed, 3 Jun 2026 13:54:49 +1200 Subject: [PATCH 01/16] First pass at permission decoders --- .../7715-permission-types/eslint.config.mjs | 26 +- packages/7715-permission-types/package.json | 11 +- packages/7715-permission-types/src/index.ts | 40 ++ .../src/permissions/erc20PayeeRuleDecoder.ts | 76 +++ .../src/permissions/erc20TokenAllowance.ts | 118 +++++ .../src/permissions/erc20TokenPeriodic.ts | 123 +++++ .../src/permissions/erc20TokenRevocation.ts | 94 ++++ .../src/permissions/erc20TokenStream.ts | 135 +++++ .../src/permissions/expiryRule.ts | 42 ++ .../src/permissions/index.ts | 42 ++ .../src/permissions/nativePayeeRuleDecoder.ts | 56 ++ .../src/permissions/nativeTokenAllowance.ts | 119 +++++ .../src/permissions/nativeTokenPeriodic.ts | 126 +++++ .../src/permissions/nativeTokenStream.ts | 116 +++++ .../src/permissions/payeeRule.ts | 12 + .../src/permissions/redeemerRule.ts | 12 + .../src/permissions/redeemerRuleDecoder.ts | 40 ++ .../permissions/tokenApprovalRevocation.ts | 119 +++++ .../src/permissions/types.ts | 120 +++++ .../src/permissions/utils.ts | 184 +++++++ .../test/erc20PayeeRule.test.ts | 150 ++++++ .../test/erc20TokenAllowance.test.ts | 269 ++++++++++ .../test/erc20TokenPeriodic.test.ts | 395 +++++++++++++++ .../test/erc20TokenRevocation.test.ts | 286 +++++++++++ .../test/erc20TokenStream.test.ts | 336 ++++++++++++ .../test/expiryRule.test.ts | 70 +++ .../test/nativePayeeRule.test.ts | 129 +++++ .../test/nativeTokenAllowance.test.ts | 268 ++++++++++ .../test/nativeTokenPeriodic.test.ts | 350 +++++++++++++ .../test/nativeTokenStream.test.ts | 477 ++++++++++++++++++ .../test/redeemerRule.test.ts | 102 ++++ .../test/tokenApprovalRevocation.test.ts | 184 +++++++ .../tsconfig.eslint.json | 19 + packages/7715-permission-types/tsconfig.json | 2 +- .../7715-permission-types/vitest.config.ts | 7 + yarn.lock | 5 + 36 files changed, 4656 insertions(+), 4 deletions(-) create mode 100644 packages/7715-permission-types/src/permissions/erc20PayeeRuleDecoder.ts create mode 100644 packages/7715-permission-types/src/permissions/erc20TokenAllowance.ts create mode 100644 packages/7715-permission-types/src/permissions/erc20TokenPeriodic.ts create mode 100644 packages/7715-permission-types/src/permissions/erc20TokenRevocation.ts create mode 100644 packages/7715-permission-types/src/permissions/erc20TokenStream.ts create mode 100644 packages/7715-permission-types/src/permissions/expiryRule.ts create mode 100644 packages/7715-permission-types/src/permissions/index.ts create mode 100644 packages/7715-permission-types/src/permissions/nativePayeeRuleDecoder.ts create mode 100644 packages/7715-permission-types/src/permissions/nativeTokenAllowance.ts create mode 100644 packages/7715-permission-types/src/permissions/nativeTokenPeriodic.ts create mode 100644 packages/7715-permission-types/src/permissions/nativeTokenStream.ts create mode 100644 packages/7715-permission-types/src/permissions/payeeRule.ts create mode 100644 packages/7715-permission-types/src/permissions/redeemerRule.ts create mode 100644 packages/7715-permission-types/src/permissions/redeemerRuleDecoder.ts create mode 100644 packages/7715-permission-types/src/permissions/tokenApprovalRevocation.ts create mode 100644 packages/7715-permission-types/src/permissions/types.ts create mode 100644 packages/7715-permission-types/src/permissions/utils.ts create mode 100644 packages/7715-permission-types/test/erc20PayeeRule.test.ts create mode 100644 packages/7715-permission-types/test/erc20TokenAllowance.test.ts create mode 100644 packages/7715-permission-types/test/erc20TokenPeriodic.test.ts create mode 100644 packages/7715-permission-types/test/erc20TokenRevocation.test.ts create mode 100644 packages/7715-permission-types/test/erc20TokenStream.test.ts create mode 100644 packages/7715-permission-types/test/expiryRule.test.ts create mode 100644 packages/7715-permission-types/test/nativePayeeRule.test.ts create mode 100644 packages/7715-permission-types/test/nativeTokenAllowance.test.ts create mode 100644 packages/7715-permission-types/test/nativeTokenPeriodic.test.ts create mode 100644 packages/7715-permission-types/test/nativeTokenStream.test.ts create mode 100644 packages/7715-permission-types/test/redeemerRule.test.ts create mode 100644 packages/7715-permission-types/test/tokenApprovalRevocation.test.ts create mode 100644 packages/7715-permission-types/tsconfig.eslint.json create mode 100644 packages/7715-permission-types/vitest.config.ts diff --git a/packages/7715-permission-types/eslint.config.mjs b/packages/7715-permission-types/eslint.config.mjs index 253f98a3..2b613dda 100644 --- a/packages/7715-permission-types/eslint.config.mjs +++ b/packages/7715-permission-types/eslint.config.mjs @@ -1,2 +1,24 @@ -// eslint-disable-next-line -export { default } from '../../shared/config/base.eslint.mjs'; +// eslint-disable-next-line import-x/extensions +import baseConfig from '../../shared/config/base.eslint.mjs'; + +const config = [ + ...baseConfig, + { + files: ['test/**/*.ts'], + languageOptions: { + parserOptions: { + projectService: false, + project: ['./tsconfig.eslint.json'], + }, + }, + settings: { + 'import/resolver': { + typescript: { + project: './tsconfig.eslint.json', + }, + }, + }, + }, +]; + +export default config; diff --git a/packages/7715-permission-types/package.json b/packages/7715-permission-types/package.json index 8653be86..78e3176d 100644 --- a/packages/7715-permission-types/package.json +++ b/packages/7715-permission-types/package.json @@ -43,6 +43,8 @@ "scripts": { "build": "yarn typecheck && tsup", "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", "lint": "yarn lint:eslint", "lint:eslint": "eslint . --cache --ext js,ts", "lint:fix": "yarn lint:eslint --fix", @@ -55,10 +57,17 @@ }, "devDependencies": { "@metamask/auto-changelog": "^6.1.1", + "@types/node": "^20.19.0", "compare-versions": "^6.1.1", "eslint": "^9.39.2", "prettier": "^3.5.3", "tsup": "^8.5.0", - "typescript": "5.5.4" + "typescript": "5.5.4", + "vitest": "^3.2.4" + }, + "dependencies": { + "@metamask/delegation-core": "^2.2.1", + "@metamask/delegation-deployments": "^1.4.0", + "@metamask/utils": "^11.4.0" } } diff --git a/packages/7715-permission-types/src/index.ts b/packages/7715-permission-types/src/index.ts index cb4553a4..0593ccfb 100644 --- a/packages/7715-permission-types/src/index.ts +++ b/packages/7715-permission-types/src/index.ts @@ -18,3 +18,43 @@ export type { RevokeExecutionPermissionResponseResult, MetaMaskBasePermissionData, } from './types'; + +export type { + Caveat, + ChecksumCaveat, + ChecksumEnforcersByChainId, + DeployedContractsByName, + DecodedPermission, + MakePermissionDecoderConfig, + PermissionDecoder, + PermissionDecoderConfig, + PermissionDecoderSpec, + PermissionType, + RuleDecoder, + ValidateAndDecodeResult, + PayeeRule, + RedeemerRule, + ExpiryRule, +} from './permissions'; + +export { + EXECUTION_PERMISSION_EXPIRY_RULE_TYPE, + expiryRule, +} from './permissions'; +export { + EXECUTION_PERMISSION_PAYEE_RULE_TYPE, + erc20PayeeRuleDecoder, + nativePayeeRuleDecoder, + EXECUTION_PERMISSION_REDEEMER_RULE_TYPE, + redeemerRuleDecoder, + makePermissionDecoder, + makeNativeTokenStreamDecoderConfig, + makeNativeTokenPeriodicDecoderConfig, + makeNativeTokenAllowanceDecoderConfig, + makeErc20TokenStreamDecoderConfig, + makeErc20TokenPeriodicDecoderConfig, + makeErc20TokenAllowanceDecoderConfig, + makeErc20TokenRevocationDecoderConfig, + makeTokenApprovalRevocationDecoderConfig, + createPermissionDecodersForContracts, +} from './permissions'; diff --git a/packages/7715-permission-types/src/permissions/erc20PayeeRuleDecoder.ts b/packages/7715-permission-types/src/permissions/erc20PayeeRuleDecoder.ts new file mode 100644 index 00000000..d3805085 --- /dev/null +++ b/packages/7715-permission-types/src/permissions/erc20PayeeRuleDecoder.ts @@ -0,0 +1,76 @@ +import { decodeAllowedCalldataTerms } from '@metamask/delegation-core'; +import { getChecksumAddress } from '@metamask/utils'; + +import type { RuleDecoder } from './types'; +import type { Hex } from '../types'; +import { getByteLength } from './utils'; + +export const EXECUTION_PERMISSION_PAYEE_RULE_TYPE = 'payee' as const; + +const ERC20_TRANSFER_PAYEE_START_INDEX = 4; +const ERC20_PAYEE_VALUE_BYTE_LENGTH = 32; + +/** + * Rule decoder for ERC-20 style payees from AllowedCalldataEnforcer caveats. + * + * @param options0 - Rule decoder arguments. + * @param options0.contractAddresses - Checksummed enforcer addresses for the chain. + * @param options0.caveats - Checksummed caveats from the delegation. + * @param options0.requiredEnforcers - Required enforcer counts for this decoder. + * @returns The decoded payee rule when present, otherwise `null`. + */ +export const erc20PayeeRuleDecoder: RuleDecoder = ({ + contractAddresses, + caveats, + requiredEnforcers, +}) => { + const { allowedCalldataEnforcer } = contractAddresses; + + if (requiredEnforcers.has(allowedCalldataEnforcer)) { + throw new Error( + 'Invalid payee caveats: payee enforcer may not be a required caveat', + ); + } + + const matchingCaveats = caveats.filter( + (caveat) => caveat.enforcer === allowedCalldataEnforcer, + ); + + if (matchingCaveats.length === 0) { + return null; + } + + if (matchingCaveats.length > 1) { + throw new Error( + 'Invalid payee caveats: multiple AllowedCalldataEnforcer caveats', + ); + } + + const [caveat] = matchingCaveats; + if (!caveat) { + throw new Error( + 'Invalid payee caveats: multiple AllowedCalldataEnforcer caveats', + ); + } + + const decoded = decodeAllowedCalldataTerms(caveat.terms); + + if (decoded.startIndex !== ERC20_TRANSFER_PAYEE_START_INDEX) { + throw new Error( + `Invalid payee caveat: AllowedCalldataEnforcer startIndex must be ${ERC20_TRANSFER_PAYEE_START_INDEX}`, + ); + } + + if (getByteLength(decoded.value) !== ERC20_PAYEE_VALUE_BYTE_LENGTH) { + throw new Error( + `Invalid payee caveat: AllowedCalldataEnforcer value must be ${ERC20_PAYEE_VALUE_BYTE_LENGTH} bytes long`, + ); + } + + const address: Hex = `0x${decoded.value.slice(-40)}`; + + return { + type: EXECUTION_PERMISSION_PAYEE_RULE_TYPE, + data: { addresses: [getChecksumAddress(address)] }, + }; +}; diff --git a/packages/7715-permission-types/src/permissions/erc20TokenAllowance.ts b/packages/7715-permission-types/src/permissions/erc20TokenAllowance.ts new file mode 100644 index 00000000..d19d2416 --- /dev/null +++ b/packages/7715-permission-types/src/permissions/erc20TokenAllowance.ts @@ -0,0 +1,118 @@ +import { erc20PayeeRuleDecoder } from './erc20PayeeRuleDecoder'; +import { expiryRule } from './expiryRule'; +import { redeemerRuleDecoder } from './redeemerRuleDecoder'; +import type { + ChecksumCaveat, + ChecksumEnforcersByChainId, + DecodedPermission, + MakePermissionDecoderConfig, +} from './types'; +import { + getByteLength, + getTermsByEnforcer, + splitHex, + UINT256_MAX, + ZERO_32_BYTES, +} from './utils'; + +/** + * Builds the configuration for the erc20-token-allowance permission decoder. + * + * @param contractAddresses - Checksummed enforcer addresses for the chain. + * @returns The erc20-token-allowance permission decoder configuration. + */ +export function makeErc20TokenAllowanceDecoderConfig( + contractAddresses: ChecksumEnforcersByChainId, +): MakePermissionDecoderConfig { + const { + timestampEnforcer, + erc20PeriodicEnforcer, + valueLteEnforcer, + nonceEnforcer, + allowedCalldataEnforcer, + redeemerEnforcer, + } = contractAddresses; + + return { + permissionType: 'erc20-token-allowance', + contractAddresses, + optionalEnforcers: [ + timestampEnforcer, // expiry rule + redeemerEnforcer, // redeemer rule + allowedCalldataEnforcer, // payee rule + ], + requiredEnforcers: { + [erc20PeriodicEnforcer]: 1, + [valueLteEnforcer]: 1, + [nonceEnforcer]: 1, + }, + rules: [expiryRule, redeemerRuleDecoder, erc20PayeeRuleDecoder], + validateAndDecodeData, + }; +} + +/** + * Decodes erc20-token-allowance permission data from caveats; throws on invalid. + * + * @param caveats - Caveats from the permission context (checksummed). + * @param contractAddresses - Checksummed enforcer addresses for the chain. + * @returns Decoded allowance terms. + */ +function validateAndDecodeData( + caveats: ChecksumCaveat[], + contractAddresses: ChecksumEnforcersByChainId, +): DecodedPermission['permission']['data'] { + const { erc20PeriodicEnforcer, valueLteEnforcer } = contractAddresses; + + const valueLteTerms = getTermsByEnforcer({ + caveats, + enforcer: valueLteEnforcer, + }); + if (valueLteTerms !== ZERO_32_BYTES) { + throw new Error(`Invalid value-lte terms: must be ${ZERO_32_BYTES}`); + } + + const terms = getTermsByEnforcer({ + caveats, + enforcer: erc20PeriodicEnforcer, + }); + + const EXPECTED_TERMS_BYTELENGTH = 116; // 20 + 32 + 32 + 32 + + if (getByteLength(terms) !== EXPECTED_TERMS_BYTELENGTH) { + throw new Error('Invalid erc20-token-allowance terms: expected 116 bytes'); + } + + const [tokenAddress, allowanceAmount, periodDurationRaw, startTimeRaw] = + splitHex(terms, [20, 32, 32, 32]); + if ( + !tokenAddress || + !allowanceAmount || + !periodDurationRaw || + !startTimeRaw + ) { + throw new Error('Invalid erc20-token-allowance terms'); + } + + if (periodDurationRaw.toLowerCase() !== UINT256_MAX) { + throw new Error( + 'Invalid erc20-token-allowance terms: periodDuration must be UINT256_MAX', + ); + } + + const startTime = Number.parseInt(startTimeRaw, 16); + + if (startTime === 0) { + throw new Error( + 'Invalid erc20-token-allowance terms: startTime must be a positive number', + ); + } + + if (allowanceAmount === ZERO_32_BYTES) { + throw new Error( + 'Invalid erc20-token-allowance terms: allowanceAmount must be a positive number', + ); + } + + return { tokenAddress, allowanceAmount, startTime }; +} diff --git a/packages/7715-permission-types/src/permissions/erc20TokenPeriodic.ts b/packages/7715-permission-types/src/permissions/erc20TokenPeriodic.ts new file mode 100644 index 00000000..502dfe8f --- /dev/null +++ b/packages/7715-permission-types/src/permissions/erc20TokenPeriodic.ts @@ -0,0 +1,123 @@ +import { hexToBigInt, hexToNumber } from '@metamask/utils'; + +import { erc20PayeeRuleDecoder } from './erc20PayeeRuleDecoder'; +import { expiryRule } from './expiryRule'; +import { redeemerRuleDecoder } from './redeemerRuleDecoder'; +import type { + ChecksumCaveat, + ChecksumEnforcersByChainId, + DecodedPermission, + MakePermissionDecoderConfig, +} from './types'; +import { + getByteLength, + getTermsByEnforcer, + MAX_PERIOD_DURATION, + splitHex, + ZERO_32_BYTES, +} from './utils'; + +/** + * Builds the configuration for the erc20-token-periodic permission decoder. + * + * @param contractAddresses - Checksummed enforcer addresses for the chain. + * @returns The erc20-token-periodic permission decoder configuration. + */ +export function makeErc20TokenPeriodicDecoderConfig( + contractAddresses: ChecksumEnforcersByChainId, +): MakePermissionDecoderConfig { + const { + timestampEnforcer, + erc20PeriodicEnforcer, + valueLteEnforcer, + nonceEnforcer, + allowedCalldataEnforcer, + redeemerEnforcer, + } = contractAddresses; + + return { + permissionType: 'erc20-token-periodic', + contractAddresses, + optionalEnforcers: [ + timestampEnforcer, // expiry rule + redeemerEnforcer, // redeemer rule + allowedCalldataEnforcer, // payee rule + ], + requiredEnforcers: { + [erc20PeriodicEnforcer]: 1, + [valueLteEnforcer]: 1, + [nonceEnforcer]: 1, + }, + rules: [expiryRule, redeemerRuleDecoder, erc20PayeeRuleDecoder], + validateAndDecodeData, + }; +} + +/** + * Decodes erc20-token-periodic permission data from caveats; throws on invalid. + * + * @param caveats - Caveats from the permission context (checksummed). + * @param contractAddresses - Checksummed enforcer addresses for the chain. + * @returns Decoded periodic terms. + */ +function validateAndDecodeData( + caveats: ChecksumCaveat[], + contractAddresses: ChecksumEnforcersByChainId, +): DecodedPermission['permission']['data'] { + const { erc20PeriodicEnforcer, valueLteEnforcer } = contractAddresses; + + 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]); + if (!tokenAddress || !periodAmount || !periodDurationRaw || !startTimeRaw) { + throw new Error('Invalid erc20-token-periodic terms'); + } + + const periodDuration = hexToNumber(periodDurationRaw); + const periodAmountBigInt = hexToBigInt(periodAmount); + const startTime = hexToNumber(startTimeRaw); + + 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 (periodDuration > MAX_PERIOD_DURATION) { + throw new Error( + 'Invalid erc20-token-periodic terms: periodDuration must be less than or equal to MAX_PERIOD_DURATION', + ); + } + + if (startTime === 0) { + throw new Error( + 'Invalid erc20-token-periodic terms: startTime must be a positive number', + ); + } + + return { tokenAddress, periodAmount, periodDuration, startTime }; +} diff --git a/packages/7715-permission-types/src/permissions/erc20TokenRevocation.ts b/packages/7715-permission-types/src/permissions/erc20TokenRevocation.ts new file mode 100644 index 00000000..64f1bd9d --- /dev/null +++ b/packages/7715-permission-types/src/permissions/erc20TokenRevocation.ts @@ -0,0 +1,94 @@ +import { expiryRule } from './expiryRule'; +import { redeemerRuleDecoder } from './redeemerRuleDecoder'; +import type { + ChecksumCaveat, + ChecksumEnforcersByChainId, + DecodedPermission, + MakePermissionDecoderConfig, +} from './types'; +import { + ERC20_APPROVE_SELECTOR_TERMS, + ERC20_APPROVE_ZERO_AMOUNT_TERMS, + getTermsByEnforcer, + ZERO_32_BYTES, +} from './utils'; + +/** + * Builds the configuration for the erc20-token-revocation permission decoder. + * + * @param contractAddresses - Checksummed enforcer addresses for the chain. + * @returns The erc20-token-revocation permission decoder configuration. + */ +export function makeErc20TokenRevocationDecoderConfig( + contractAddresses: ChecksumEnforcersByChainId, +): MakePermissionDecoderConfig { + const { + timestampEnforcer, + allowedCalldataEnforcer, + valueLteEnforcer, + nonceEnforcer, + redeemerEnforcer, + } = contractAddresses; + + return { + permissionType: 'erc20-token-revocation', + contractAddresses, + optionalEnforcers: [ + timestampEnforcer, // expiry rule + redeemerEnforcer, // redeemer rule + ], + requiredEnforcers: { + [allowedCalldataEnforcer]: 2, + [valueLteEnforcer]: 1, + [nonceEnforcer]: 1, + }, + rules: [expiryRule, redeemerRuleDecoder], + validateAndDecodeData, + }; +} + +/** + * Decodes erc20-token-revocation permission data from caveats; throws on invalid. + * + * @param caveats - Caveats from the permission context (checksummed). + * @param contractAddresses - Checksummed enforcer addresses for the chain. + * @returns An empty object (revocation has no decoded data payload). + */ +function validateAndDecodeData( + caveats: ChecksumCaveat[], + contractAddresses: ChecksumEnforcersByChainId, +): DecodedPermission['permission']['data'] { + const { allowedCalldataEnforcer, valueLteEnforcer } = contractAddresses; + + 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/7715-permission-types/src/permissions/erc20TokenStream.ts b/packages/7715-permission-types/src/permissions/erc20TokenStream.ts new file mode 100644 index 00000000..9fa6935f --- /dev/null +++ b/packages/7715-permission-types/src/permissions/erc20TokenStream.ts @@ -0,0 +1,135 @@ +import { hexToBigInt, hexToNumber } from '@metamask/utils'; + +import { erc20PayeeRuleDecoder } from './erc20PayeeRuleDecoder'; +import { expiryRule } from './expiryRule'; +import { redeemerRuleDecoder } from './redeemerRuleDecoder'; +import type { + ChecksumCaveat, + ChecksumEnforcersByChainId, + DecodedPermission, + MakePermissionDecoderConfig, +} from './types'; +import { + getByteLength, + getTermsByEnforcer, + splitHex, + ZERO_32_BYTES, +} from './utils'; + +/** + * Builds the configuration for the erc20-token-stream permission decoder. + * + * @param contractAddresses - Checksummed enforcer addresses for the chain. + * @returns The erc20-token-stream permission decoder configuration. + */ +export function makeErc20TokenStreamDecoderConfig( + contractAddresses: ChecksumEnforcersByChainId, +): MakePermissionDecoderConfig { + const { + timestampEnforcer, + erc20StreamingEnforcer, + valueLteEnforcer, + nonceEnforcer, + allowedCalldataEnforcer, + redeemerEnforcer, + } = contractAddresses; + + return { + permissionType: 'erc20-token-stream', + contractAddresses, + optionalEnforcers: [ + timestampEnforcer, // expiry rule + redeemerEnforcer, // redeemer rule + allowedCalldataEnforcer, // payee rule + ], + requiredEnforcers: { + [erc20StreamingEnforcer]: 1, + [valueLteEnforcer]: 1, + [nonceEnforcer]: 1, + }, + rules: [expiryRule, redeemerRuleDecoder, erc20PayeeRuleDecoder], + validateAndDecodeData, + }; +} + +/** + * Decodes erc20-token-stream permission data from caveats; throws on invalid. + * + * @param caveats - Caveats from the permission context (checksummed). + * @param contractAddresses - Checksummed enforcer addresses for the chain. + * @returns Decoded stream terms. + */ +function validateAndDecodeData( + caveats: ChecksumCaveat[], + contractAddresses: ChecksumEnforcersByChainId, +): DecodedPermission['permission']['data'] { + const { erc20StreamingEnforcer, valueLteEnforcer } = contractAddresses; + + 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]); + if ( + !tokenAddress || + !initialAmount || + !maxAmount || + !amountPerSecond || + !startTimeRaw + ) { + throw new Error('Invalid erc20-token-stream terms'); + } + + const startTime = hexToNumber(startTimeRaw); + const initialAmountBigInt = hexToBigInt(initialAmount); + const maxAmountBigInt = hexToBigInt(maxAmount); + const amountPerSecondBigInt = hexToBigInt(amountPerSecond); + + if (maxAmountBigInt <= initialAmountBigInt) { + throw new Error( + 'Invalid erc20-token-stream terms: maxAmount must be greater than initialAmount', + ); + } + + 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/7715-permission-types/src/permissions/expiryRule.ts b/packages/7715-permission-types/src/permissions/expiryRule.ts new file mode 100644 index 00000000..6ab03231 --- /dev/null +++ b/packages/7715-permission-types/src/permissions/expiryRule.ts @@ -0,0 +1,42 @@ +import type { RuleDecoder } from './types'; +import { extractExpiryFromCaveatTerms, getTermsByEnforcer } from './utils'; + +export const EXECUTION_PERMISSION_EXPIRY_RULE_TYPE = 'expiry' as const; + +/** + * Execution permission rule derived from TimestampEnforcer caveats. + */ +export type ExpiryRule = { + type: 'expiry'; + data: { + timestamp: number; + }; +}; + +/** + * Rule decoder that extracts the expiry timestamp from a TimestampEnforcer + * caveat, when present. + * + * @param options0 - Rule decoder arguments. + * @param options0.contractAddresses - Checksummed enforcer addresses for the chain. + * @param options0.caveats - Checksummed caveats from the delegation. + * @returns The decoded expiry rule when present, otherwise `null`. + */ +export const expiryRule: RuleDecoder = ({ contractAddresses, caveats }) => { + const { timestampEnforcer } = contractAddresses; + + const expiryTerms = getTermsByEnforcer({ + caveats, + enforcer: timestampEnforcer, + throwIfNotFound: false, + }); + + if (!expiryTerms) { + return null; + } + + return { + type: EXECUTION_PERMISSION_EXPIRY_RULE_TYPE, + data: { timestamp: extractExpiryFromCaveatTerms(expiryTerms) }, + }; +}; diff --git a/packages/7715-permission-types/src/permissions/index.ts b/packages/7715-permission-types/src/permissions/index.ts new file mode 100644 index 00000000..d16a79a8 --- /dev/null +++ b/packages/7715-permission-types/src/permissions/index.ts @@ -0,0 +1,42 @@ +import { makeErc20TokenAllowanceDecoderConfig } from './erc20TokenAllowance'; +import { makeErc20TokenPeriodicDecoderConfig } from './erc20TokenPeriodic'; +import { makeErc20TokenRevocationDecoderConfig } from './erc20TokenRevocation'; +import { makeErc20TokenStreamDecoderConfig } from './erc20TokenStream'; +import { makeNativeTokenAllowanceDecoderConfig } from './nativeTokenAllowance'; +import { makeNativeTokenPeriodicDecoderConfig } from './nativeTokenPeriodic'; +import { makeNativeTokenStreamDecoderConfig } from './nativeTokenStream'; +import { makeTokenApprovalRevocationDecoderConfig } from './tokenApprovalRevocation'; +import type { DeployedContractsByName, PermissionDecoderConfig } from './types'; +import { getChecksumEnforcersByChainId } from './utils'; + +export type { + Caveat, + ChecksumCaveat, + ChecksumEnforcersByChainId, + DeployedContractsByName, + DecodedPermission, + MakePermissionDecoderConfig, +} from './types'; + +/** + * Builds the canonical set of permission decoders for a chain. + * + * @param contracts - Deployed delegation framework contracts for one chain. + * @returns The full set of permission decoders for the chain. + */ +export const makePermissionDecoderConfigs = ( + contracts: DeployedContractsByName, +): PermissionDecoderConfig[] => { + const contractAddresses = getChecksumEnforcersByChainId(contracts); + + return [ + makeNativeTokenStreamDecoderConfig(contractAddresses), + makeNativeTokenPeriodicDecoderConfig(contractAddresses), + makeNativeTokenAllowanceDecoderConfig(contractAddresses), + makeErc20TokenStreamDecoderConfig(contractAddresses), + makeErc20TokenPeriodicDecoderConfig(contractAddresses), + makeErc20TokenAllowanceDecoderConfig(contractAddresses), + makeErc20TokenRevocationDecoderConfig(contractAddresses), + makeTokenApprovalRevocationDecoderConfig(contractAddresses), + ]; +}; diff --git a/packages/7715-permission-types/src/permissions/nativePayeeRuleDecoder.ts b/packages/7715-permission-types/src/permissions/nativePayeeRuleDecoder.ts new file mode 100644 index 00000000..3f8c146f --- /dev/null +++ b/packages/7715-permission-types/src/permissions/nativePayeeRuleDecoder.ts @@ -0,0 +1,56 @@ +import { decodeAllowedTargetsTerms } from '@metamask/delegation-core'; +import { getChecksumAddress } from '@metamask/utils'; + +import { EXECUTION_PERMISSION_PAYEE_RULE_TYPE } from './erc20PayeeRuleDecoder'; +import type { RuleDecoder } from './types'; + +/** + * Rule decoder for native-token style payees from AllowedTargetsEnforcer caveats. + * + * @param options0 - Rule decoder arguments. + * @param options0.contractAddresses - Checksummed enforcer addresses for the chain. + * @param options0.caveats - Checksummed caveats from the delegation. + * @param options0.requiredEnforcers - Required enforcer counts for this decoder. + * @returns The decoded payee rule when present, otherwise `null`. + */ +export const nativePayeeRuleDecoder: RuleDecoder = ({ + contractAddresses, + caveats, + requiredEnforcers, +}) => { + const { allowedTargetsEnforcer } = contractAddresses; + + if (requiredEnforcers.has(allowedTargetsEnforcer)) { + throw new Error( + 'Invalid payee caveats: payee enforcer may not be a required caveat', + ); + } + + const matchingCaveats = caveats.filter( + (caveat) => caveat.enforcer === allowedTargetsEnforcer, + ); + + if (matchingCaveats.length === 0) { + return null; + } + + if (matchingCaveats.length > 1) { + throw new Error( + 'Invalid payee caveats: multiple AllowedTargetsEnforcer caveats', + ); + } + + const [caveat] = matchingCaveats; + if (!caveat) { + throw new Error( + 'Invalid payee caveats: multiple AllowedTargetsEnforcer caveats', + ); + } + + const decoded = decodeAllowedTargetsTerms(caveat.terms); + + return { + type: EXECUTION_PERMISSION_PAYEE_RULE_TYPE, + data: { addresses: decoded.targets.map(getChecksumAddress) }, + }; +}; diff --git a/packages/7715-permission-types/src/permissions/nativeTokenAllowance.ts b/packages/7715-permission-types/src/permissions/nativeTokenAllowance.ts new file mode 100644 index 00000000..f8ef9f17 --- /dev/null +++ b/packages/7715-permission-types/src/permissions/nativeTokenAllowance.ts @@ -0,0 +1,119 @@ +import { hexToNumber } from '@metamask/utils'; + +import { expiryRule } from './expiryRule'; +import { nativePayeeRuleDecoder } from './nativePayeeRuleDecoder'; +import { redeemerRuleDecoder } from './redeemerRuleDecoder'; +import type { + ChecksumCaveat, + ChecksumEnforcersByChainId, + DecodedPermission, + MakePermissionDecoderConfig, +} from './types'; +import { + getByteLength, + getTermsByEnforcer, + splitHex, + UINT256_MAX, + ZERO_32_BYTES, +} from './utils'; + +/** + * Builds the configuration for the native-token-allowance permission decoder. + * + * @param contractAddresses - Checksummed enforcer addresses for the chain. + * @returns The native-token-allowance permission decoder configuration. + */ +export function makeNativeTokenAllowanceDecoderConfig( + contractAddresses: ChecksumEnforcersByChainId, +): MakePermissionDecoderConfig { + const { + timestampEnforcer, + nativeTokenPeriodicEnforcer, + exactCalldataEnforcer, + nonceEnforcer, + allowedTargetsEnforcer, + redeemerEnforcer, + } = contractAddresses; + + return { + permissionType: 'native-token-allowance', + contractAddresses, + optionalEnforcers: [ + timestampEnforcer, // expiry rule + redeemerEnforcer, // redeemer rule + allowedTargetsEnforcer, // payee rule + ], + requiredEnforcers: { + [nativeTokenPeriodicEnforcer]: 1, + [exactCalldataEnforcer]: 1, + [nonceEnforcer]: 1, + }, + rules: [expiryRule, redeemerRuleDecoder, nativePayeeRuleDecoder], + validateAndDecodeData, + }; +} + +/** + * Decodes native-token-allowance permission data from caveats; throws on invalid. + * + * @param caveats - Caveats from the permission context (checksummed). + * @param contractAddresses - Checksummed enforcer addresses for the chain. + * @returns Decoded allowance terms. + */ +function validateAndDecodeData( + caveats: ChecksumCaveat[], + contractAddresses: ChecksumEnforcersByChainId, +): DecodedPermission['permission']['data'] { + const { nativeTokenPeriodicEnforcer, exactCalldataEnforcer } = + contractAddresses; + + const exactCalldataTerms = getTermsByEnforcer({ + caveats, + enforcer: exactCalldataEnforcer, + }); + + if (exactCalldataTerms !== '0x') { + throw new Error('Invalid exact-calldata terms: must be 0x'); + } + + const terms = getTermsByEnforcer({ + caveats, + enforcer: nativeTokenPeriodicEnforcer, + }); + + const EXPECTED_TERMS_BYTELENGTH = 96; // 32 + 32 + 32 + + if (getByteLength(terms) !== EXPECTED_TERMS_BYTELENGTH) { + throw new Error('Invalid native-token-allowance terms: expected 96 bytes'); + } + + const [allowanceAmount, periodDurationRaw, startTimeRaw] = splitHex( + terms, + [32, 32, 32], + ); + if (!allowanceAmount || !periodDurationRaw || !startTimeRaw) { + throw new Error('Invalid native-token-allowance terms'); + } + + if (periodDurationRaw.toLowerCase() !== UINT256_MAX) { + throw new Error( + 'Invalid native-token-allowance terms: periodDuration must be UINT256_MAX', + ); + } + + const startTime = hexToNumber(startTimeRaw); + + if (startTime === 0) { + throw new Error( + 'Invalid native-token-allowance terms: startTime must be a positive number', + ); + } + + if (allowanceAmount === ZERO_32_BYTES) { + throw new Error( + 'Invalid native-token-allowance terms: allowanceAmount must be a positive number', + ); + } + + return { allowanceAmount, startTime }; +} diff --git a/packages/7715-permission-types/src/permissions/nativeTokenPeriodic.ts b/packages/7715-permission-types/src/permissions/nativeTokenPeriodic.ts new file mode 100644 index 00000000..3d5c3f48 --- /dev/null +++ b/packages/7715-permission-types/src/permissions/nativeTokenPeriodic.ts @@ -0,0 +1,126 @@ +import { hexToBigInt, hexToNumber } from '@metamask/utils'; + +import { expiryRule } from './expiryRule'; +import { nativePayeeRuleDecoder } from './nativePayeeRuleDecoder'; +import { redeemerRuleDecoder } from './redeemerRuleDecoder'; +import type { + ChecksumCaveat, + ChecksumEnforcersByChainId, + DecodedPermission, + MakePermissionDecoderConfig, +} from './types'; +import { + getByteLength, + getTermsByEnforcer, + MAX_PERIOD_DURATION, + splitHex, +} from './utils'; + +/** + * Builds the configuration for the native-token-periodic permission decoder. + * + * @param contractAddresses - Checksummed enforcer addresses for the chain. + * @returns The native-token-periodic permission decoder configuration. + */ +export function makeNativeTokenPeriodicDecoderConfig( + contractAddresses: ChecksumEnforcersByChainId, +): MakePermissionDecoderConfig { + const { + timestampEnforcer, + nativeTokenPeriodicEnforcer, + exactCalldataEnforcer, + nonceEnforcer, + allowedTargetsEnforcer, + redeemerEnforcer, + } = contractAddresses; + + return { + permissionType: 'native-token-periodic', + contractAddresses, + optionalEnforcers: [ + timestampEnforcer, // expiry rule + redeemerEnforcer, // redeemer rule + allowedTargetsEnforcer, // payee rule + ], + requiredEnforcers: { + [nativeTokenPeriodicEnforcer]: 1, + [exactCalldataEnforcer]: 1, + [nonceEnforcer]: 1, + }, + rules: [expiryRule, redeemerRuleDecoder, nativePayeeRuleDecoder], + validateAndDecodeData, + }; +} + +/** + * Decodes native-token-periodic permission data from caveats; throws on invalid. + * + * @param caveats - Caveats from the permission context (checksummed). + * @param contractAddresses - Checksummed enforcer addresses for the chain. + * @returns Decoded periodic terms. + */ +function validateAndDecodeData( + caveats: ChecksumCaveat[], + contractAddresses: ChecksumEnforcersByChainId, +): DecodedPermission['permission']['data'] { + const { nativeTokenPeriodicEnforcer, exactCalldataEnforcer } = + contractAddresses; + + 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], + ); + if (!periodAmount || !periodDurationRaw || !startTimeRaw) { + throw new Error('Invalid native-token-periodic terms'); + } + + 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 (periodDuration > MAX_PERIOD_DURATION) { + throw new Error( + 'Invalid native-token-periodic terms: periodDuration must be less than or equal to MAX_PERIOD_DURATION', + ); + } + + if (startTime === 0) { + throw new Error( + 'Invalid native-token-periodic terms: startTime must be a positive number', + ); + } + + return { periodAmount, periodDuration, startTime }; +} diff --git a/packages/7715-permission-types/src/permissions/nativeTokenStream.ts b/packages/7715-permission-types/src/permissions/nativeTokenStream.ts new file mode 100644 index 00000000..a29ee3ec --- /dev/null +++ b/packages/7715-permission-types/src/permissions/nativeTokenStream.ts @@ -0,0 +1,116 @@ +import { hexToBigInt, hexToNumber } from '@metamask/utils'; + +import { expiryRule } from './expiryRule'; +import { nativePayeeRuleDecoder } from './nativePayeeRuleDecoder'; +import { redeemerRuleDecoder } from './redeemerRuleDecoder'; +import type { + ChecksumCaveat, + ChecksumEnforcersByChainId, + DecodedPermission, + MakePermissionDecoderConfig, +} from './types'; +import { getByteLength, getTermsByEnforcer, splitHex } from './utils'; + +/** + * Builds the configuration for the native-token-stream permission decoder. + * + * @param contractAddresses - Checksummed enforcer addresses for the chain. + * @returns The native-token-stream permission decoder configuration. + */ +export function makeNativeTokenStreamDecoderConfig( + contractAddresses: ChecksumEnforcersByChainId, +): MakePermissionDecoderConfig { + const { + timestampEnforcer, + nativeTokenStreamingEnforcer, + exactCalldataEnforcer, + nonceEnforcer, + allowedTargetsEnforcer, + redeemerEnforcer, + } = contractAddresses; + + return { + permissionType: 'native-token-stream', + contractAddresses, + optionalEnforcers: [ + timestampEnforcer, // expiry rule + redeemerEnforcer, // redeemer rule + allowedTargetsEnforcer, // payee rule + ], + requiredEnforcers: { + [nativeTokenStreamingEnforcer]: 1, + [exactCalldataEnforcer]: 1, + [nonceEnforcer]: 1, + }, + rules: [expiryRule, redeemerRuleDecoder, nativePayeeRuleDecoder], + validateAndDecodeData, + }; +} + +/** + * Decodes native-token-stream permission data from caveats; throws on invalid. + * + * @param caveats - Caveats from the permission context (checksummed). + * @param contractAddresses - Checksummed enforcer addresses for the chain. + * @returns Decoded stream terms. + */ +function validateAndDecodeData( + caveats: ChecksumCaveat[], + contractAddresses: ChecksumEnforcersByChainId, +): DecodedPermission['permission']['data'] { + const { nativeTokenStreamingEnforcer, exactCalldataEnforcer } = + contractAddresses; + + 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], + ); + if (!initialAmount || !maxAmount || !amountPerSecond || !startTimeRaw) { + throw new Error('Invalid native-token-stream terms'); + } + + 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 (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/7715-permission-types/src/permissions/payeeRule.ts b/packages/7715-permission-types/src/permissions/payeeRule.ts new file mode 100644 index 00000000..bbe80ca1 --- /dev/null +++ b/packages/7715-permission-types/src/permissions/payeeRule.ts @@ -0,0 +1,12 @@ +import type { Hex } from '../types'; + +/** + * Execution permission rule restricting which addresses may receive payments + * (on-chain AllowedCalldataEnforcer / AllowedTargetsEnforcer caveat). + */ +export type PayeeRule = { + type: 'payee'; + data: { + addresses: Hex[]; + }; +}; diff --git a/packages/7715-permission-types/src/permissions/redeemerRule.ts b/packages/7715-permission-types/src/permissions/redeemerRule.ts new file mode 100644 index 00000000..3f743bf7 --- /dev/null +++ b/packages/7715-permission-types/src/permissions/redeemerRule.ts @@ -0,0 +1,12 @@ +import type { Hex } from '../types'; + +/** + * Execution permission rule restricting which addresses may redeem the delegation + * (on-chain RedeemerEnforcer caveat). + */ +export type RedeemerRule = { + type: 'redeemer'; + data: { + addresses: Hex[]; + }; +}; diff --git a/packages/7715-permission-types/src/permissions/redeemerRuleDecoder.ts b/packages/7715-permission-types/src/permissions/redeemerRuleDecoder.ts new file mode 100644 index 00000000..a38f172c --- /dev/null +++ b/packages/7715-permission-types/src/permissions/redeemerRuleDecoder.ts @@ -0,0 +1,40 @@ +import { decodeRedeemerTerms } from '@metamask/delegation-core'; +import { getChecksumAddress } from '@metamask/utils'; + +import type { RuleDecoder } from './types'; +import { getTermsByEnforcer } from './utils'; + +export const EXECUTION_PERMISSION_REDEEMER_RULE_TYPE = 'redeemer' as const; + +/** + * Rule decoder that extracts a redeemer allowlist from a RedeemerEnforcer caveat. + * + * @param options0 - Rule decoder arguments. + * @param options0.contractAddresses - Checksummed enforcer addresses for the chain. + * @param options0.caveats - Checksummed caveats from the delegation. + * @returns The decoded redeemer rule when present, otherwise `null`. + */ +export const redeemerRuleDecoder: RuleDecoder = ({ + contractAddresses, + caveats, +}) => { + const { redeemerEnforcer } = contractAddresses; + + const redeemerTerms = getTermsByEnforcer({ + caveats, + enforcer: redeemerEnforcer, + throwIfNotFound: false, + }); + + if (!redeemerTerms) { + return null; + } + + return { + type: EXECUTION_PERMISSION_REDEEMER_RULE_TYPE, + data: { + addresses: + decodeRedeemerTerms(redeemerTerms).redeemers.map(getChecksumAddress), + }, + }; +}; diff --git a/packages/7715-permission-types/src/permissions/tokenApprovalRevocation.ts b/packages/7715-permission-types/src/permissions/tokenApprovalRevocation.ts new file mode 100644 index 00000000..689b0805 --- /dev/null +++ b/packages/7715-permission-types/src/permissions/tokenApprovalRevocation.ts @@ -0,0 +1,119 @@ +/* eslint-disable no-bitwise */ +import { hexToNumber } from '@metamask/utils'; + +import { expiryRule } from './expiryRule'; +import type { + ChecksumCaveat, + ChecksumEnforcersByChainId, + DecodedPermission, + MakePermissionDecoderConfig, +} from './types'; +import { getTermsByEnforcer } from './utils'; + +enum ApprovalRevocationFlag { + Erc20Approve = 0x01, + Erc721Approve = 0x02, + Erc721SetApprovalForAll = 0x04, + Permit2Approve = 0x08, + Permit2Lockdown = 0x10, + Permit2InvalidateNonces = 0x20, +} + +const MAX_APPROVAL_REVOCATION_MASK = + ApprovalRevocationFlag.Permit2InvalidateNonces | + ApprovalRevocationFlag.Permit2Lockdown | + ApprovalRevocationFlag.Permit2Approve | + ApprovalRevocationFlag.Erc721SetApprovalForAll | + ApprovalRevocationFlag.Erc721Approve | + ApprovalRevocationFlag.Erc20Approve; + +/** + * Builds the configuration for the token-approval-revocation permission decoder. + * + * @param contractAddresses - Checksummed enforcer addresses for the chain. + * @returns The token-approval-revocation permission decoder configuration. + */ +export function makeTokenApprovalRevocationDecoderConfig( + contractAddresses: ChecksumEnforcersByChainId, +): MakePermissionDecoderConfig { + const { timestampEnforcer, approvalRevocationEnforcer, nonceEnforcer } = + contractAddresses; + + return { + permissionType: 'token-approval-revocation', + contractAddresses, + optionalEnforcers: [ + timestampEnforcer, // expiry rule + ], + requiredEnforcers: { + [approvalRevocationEnforcer]: 1, + [nonceEnforcer]: 1, + }, + rules: [expiryRule], + validateAndDecodeData, + }; +} + +/** + * Decodes token-approval-revocation permission data from caveats; throws on invalid. + * + * @param caveats - Caveats from the permission context (checksummed). + * @param contractAddresses - Checksummed enforcer addresses for the chain. + * @returns Decoded approval-revocation capability flags. + */ +function validateAndDecodeData( + caveats: ChecksumCaveat[], + contractAddresses: ChecksumEnforcersByChainId, +): DecodedPermission['permission']['data'] { + const { approvalRevocationEnforcer } = contractAddresses; + + const terms = getTermsByEnforcer({ + caveats, + enforcer: approvalRevocationEnforcer, + }); + + if (terms === '0x') { + throw new Error('Invalid ApprovalRevocation terms: must be greater than 0'); + } + + const mask = hexToNumber(terms); + + if (mask > MAX_APPROVAL_REVOCATION_MASK) { + throw new Error( + `Invalid ApprovalRevocation terms: must be less than or equal to ${MAX_APPROVAL_REVOCATION_MASK}`, + ); + } + + if (mask === 0) { + throw new Error('Invalid ApprovalRevocation terms: must be greater than 0'); + } + + return { + erc20Approve: isFlagEnabled(mask, ApprovalRevocationFlag.Erc20Approve), + erc721Approve: isFlagEnabled(mask, ApprovalRevocationFlag.Erc721Approve), + erc721SetApprovalForAll: isFlagEnabled( + mask, + ApprovalRevocationFlag.Erc721SetApprovalForAll, + ), + permit2Approve: isFlagEnabled(mask, ApprovalRevocationFlag.Permit2Approve), + permit2Lockdown: isFlagEnabled( + mask, + ApprovalRevocationFlag.Permit2Lockdown, + ), + permit2InvalidateNonces: isFlagEnabled( + mask, + ApprovalRevocationFlag.Permit2InvalidateNonces, + ), + }; +} + +/** + * Returns whether a specific bitwise flag is enabled in the mask. + * + * @param mask - The full bitmask value. + * @param flag - The flag to check. + * @returns `true` if the flag is set, otherwise `false`. + */ +function isFlagEnabled(mask: number, flag: number): boolean { + return (mask & flag) === flag; +} diff --git a/packages/7715-permission-types/src/permissions/types.ts b/packages/7715-permission-types/src/permissions/types.ts new file mode 100644 index 00000000..8dc338d8 --- /dev/null +++ b/packages/7715-permission-types/src/permissions/types.ts @@ -0,0 +1,120 @@ +import type { Hex, PermissionRequest, PermissionTypes, Rule } from '../types'; + +/** + * Minimal caveat representation used by permission decoders. + */ +export type Caveat = { + enforcer: TEnforcer; + terms: Hex; +}; + +/** + * Checksummed enforcer contract addresses for a chain (from getChecksumEnforcersByChainId). + */ +export type ChecksumEnforcersByChainId = { + erc20StreamingEnforcer: Hex; + erc20PeriodicEnforcer: Hex; + nativeTokenStreamingEnforcer: Hex; + nativeTokenPeriodicEnforcer: Hex; + approvalRevocationEnforcer: Hex; + exactCalldataEnforcer: Hex; + valueLteEnforcer: Hex; + timestampEnforcer: Hex; + nonceEnforcer: Hex; + allowedCalldataEnforcer: Hex; + allowedTargetsEnforcer: Hex; + redeemerEnforcer: Hex; +}; + +/** Caveat with checksummed enforcer address; used by rule decode functions. */ +export type ChecksumCaveat = Caveat; + +/** + * A partially reconstructed permission object decoded from a permission context. + */ +export type DecodedPermission = Pick< + PermissionRequest, + 'chainId' | 'from' | 'to' +> & { + permission: Omit< + PermissionRequest['permission'], + 'isAdjustmentAllowed' | 'type' | 'data' + > & { + type: PermissionTypes['type']; + data: PermissionTypes['data']; + justification?: string; + }; + /** + * @deprecated Use `rules` instead. + */ + expiry: number | null; + origin: string; + /** Rules recovered from caveats (e.g. redeemer allowlist). */ + rules?: Rule[]; +}; + +/** + * Supported permission type identifiers that can be decoded from a permission context. + */ +export type PermissionType = DecodedPermission['permission']['type']; + +/** + * A function that inspects checksummed caveats and optionally produces a Rule. + */ +export type RuleDecoder = (args: { + contractAddresses: ChecksumEnforcersByChainId; + caveats: ChecksumCaveat[]; + requiredEnforcers: Map; +}) => Rule | null; + +/** + * Configuration object describing how to decode a single permission type. + */ +export type PermissionDecoderConfig = { + permissionType: PermissionType; + contractAddresses: ChecksumEnforcersByChainId; + optionalEnforcers: Hex[]; + requiredEnforcers: Record; + rules: RuleDecoder[]; + validateAndDecodeData: ( + caveats: ChecksumCaveat[], + contractAddresses: ChecksumEnforcersByChainId, + ) => DecodedPermission['permission']['data']; +}; + +/** + * Alias kept to align with existing decoder factory naming. + */ +export type MakePermissionDecoderConfig = PermissionDecoderConfig; + +export type PermissionDecoderSpec = ( + contractAddresses: ChecksumEnforcersByChainId, +) => PermissionDecoderConfig; + +/** + * Result of validating and decoding permission terms from caveats. + */ +export type ValidateAndDecodeResult = + | { + isValid: true; + expiry: number | null; + data: DecodedPermission['permission']['data']; + rules?: Rule[]; + } + | { isValid: false; error: Error }; + +/** + * Decoder object used to match caveats and decode permission payloads. + */ +export type PermissionDecoder = { + permissionType: PermissionType; + requiredEnforcers: Map; + optionalEnforcers: Set; + caveatAddressesMatch: (caveatAddresses: Hex[]) => boolean; + validateAndDecodePermission: (caveats: Caveat[]) => ValidateAndDecodeResult; +}; + +/** + * A map of deployed contract names to addresses for one chain. + */ +export type DeployedContractsByName = Record; diff --git a/packages/7715-permission-types/src/permissions/utils.ts b/packages/7715-permission-types/src/permissions/utils.ts new file mode 100644 index 00000000..9b75fcdd --- /dev/null +++ b/packages/7715-permission-types/src/permissions/utils.ts @@ -0,0 +1,184 @@ +import { getChecksumAddress } from '@metamask/utils'; + +import type { ChecksumCaveat, ChecksumEnforcersByChainId } from './types'; +import type { Hex } from '../types'; + +/** + * 32 bytes of zero (0x + 64 hex chars). + */ +export const ZERO_32_BYTES = + '0x0000000000000000000000000000000000000000000000000000000000000000' as const; + +/** + * Maximum unsigned 256-bit integer encoded as 32 bytes (0x + 64 hex chars). + */ +export const UINT256_MAX = + '0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff' as const; + +/** AllowedCalldataEnforcer terms for ERC20 approve selector. */ +export const ERC20_APPROVE_SELECTOR_TERMS = + '0x0000000000000000000000000000000000000000000000000000000000000000095ea7b3' as const; + +/** AllowedCalldataEnforcer terms for ERC20 approve zero amount. */ +export const ERC20_APPROVE_ZERO_AMOUNT_TERMS = + '0x00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000' as const; + +/** Maximum period duration in seconds. */ +export const MAX_PERIOD_DURATION = 10 * 365 * 24 * 60 * 60; + +const ENFORCER_CONTRACT_NAMES = { + ERC20PeriodTransferEnforcer: 'ERC20PeriodTransferEnforcer', + ERC20StreamingEnforcer: 'ERC20StreamingEnforcer', + ApprovalRevocationEnforcer: 'ApprovalRevocationEnforcer', + ExactCalldataEnforcer: 'ExactCalldataEnforcer', + NativeTokenPeriodTransferEnforcer: 'NativeTokenPeriodTransferEnforcer', + NativeTokenStreamingEnforcer: 'NativeTokenStreamingEnforcer', + TimestampEnforcer: 'TimestampEnforcer', + ValueLteEnforcer: 'ValueLteEnforcer', + NonceEnforcer: 'NonceEnforcer', + AllowedCalldataEnforcer: 'AllowedCalldataEnforcer', + AllowedTargetsEnforcer: 'AllowedTargetsEnforcer', + RedeemerEnforcer: 'RedeemerEnforcer', +}; + +/** + * Gets the terms for a given enforcer from a list of caveats. + * + * @param options0 - Terms lookup arguments. + * @param options0.caveats - Caveats to search. + * @param options0.enforcer - Enforcer address to match. + * @param options0.throwIfNotFound - Whether to throw if no match is found. + * @returns The matched terms, or `null` when disabled and no caveat is found. + */ +export function getTermsByEnforcer({ + caveats, + enforcer, + throwIfNotFound, +}: { + caveats: ChecksumCaveat[]; + enforcer: Hex; + throwIfNotFound?: TThrowIfNotFound; +}): TThrowIfNotFound extends true ? Hex : Hex | null { + const matchingCaveats = caveats.filter( + (caveat) => caveat.enforcer === enforcer, + ); + + if (matchingCaveats.length === 0) { + if (throwIfNotFound ?? true) { + throw new Error('Invalid caveats'); + } + return null as TThrowIfNotFound extends true ? Hex : Hex | null; + } + + if (matchingCaveats.length > 1) { + throw new Error('Invalid caveats'); + } + + const [caveat] = matchingCaveats; + if (!caveat) { + throw new Error('Invalid caveats'); + } + + return caveat.terms; +} + +/** + * Get the byte length of a hex string. + * + * @param hexString - A 0x-prefixed hex string. + * @returns The byte length of the provided hex string. + */ +export const getByteLength = (hexString: Hex): number => { + return (hexString.length - 2) / 2; +}; + +/** + * Splits a 0x-prefixed hex string into parts according to the provided byte lengths. + * + * @param value - The hex value to split. + * @param lengths - Segment lengths in bytes. + * @returns The split hex segments, each with `0x` prefix. + */ +export function splitHex(value: Hex, lengths: number[]): Hex[] { + let start = 2; + const parts: Hex[] = []; + for (const partLength of lengths) { + const partCharLength = partLength * 2; + const part = value.slice(start, start + partCharLength); + start += partCharLength; + parts.push(`0x${part}` as const); + } + return parts; +} + +/** + * Resolves and returns checksummed enforcer addresses from deployed contracts. + * + * @param contracts - Deployed contract-name-to-address map for a chain. + * @returns Checksummed enforcer addresses keyed by known enforcer role. + */ +export const getChecksumEnforcersByChainId = ( + contracts: Record, +): ChecksumEnforcersByChainId => { + const getChecksumContractAddress = (contractName: string): Hex => { + const address = contracts[contractName]; + + if (!address) { + throw new Error(`Contract not found: ${contractName}`); + } + + return getChecksumAddress(address); + }; + + const erc20StreamingEnforcer = getChecksumContractAddress( + ENFORCER_CONTRACT_NAMES.ERC20StreamingEnforcer, + ); + const erc20PeriodicEnforcer = getChecksumContractAddress( + ENFORCER_CONTRACT_NAMES.ERC20PeriodTransferEnforcer, + ); + const nativeTokenStreamingEnforcer = getChecksumContractAddress( + ENFORCER_CONTRACT_NAMES.NativeTokenStreamingEnforcer, + ); + const nativeTokenPeriodicEnforcer = getChecksumContractAddress( + ENFORCER_CONTRACT_NAMES.NativeTokenPeriodTransferEnforcer, + ); + const approvalRevocationEnforcer = getChecksumContractAddress( + ENFORCER_CONTRACT_NAMES.ApprovalRevocationEnforcer, + ); + const exactCalldataEnforcer = getChecksumContractAddress( + ENFORCER_CONTRACT_NAMES.ExactCalldataEnforcer, + ); + const valueLteEnforcer = getChecksumContractAddress( + ENFORCER_CONTRACT_NAMES.ValueLteEnforcer, + ); + const timestampEnforcer = getChecksumContractAddress( + ENFORCER_CONTRACT_NAMES.TimestampEnforcer, + ); + const nonceEnforcer = getChecksumContractAddress( + ENFORCER_CONTRACT_NAMES.NonceEnforcer, + ); + const allowedCalldataEnforcer = getChecksumContractAddress( + ENFORCER_CONTRACT_NAMES.AllowedCalldataEnforcer, + ); + const allowedTargetsEnforcer = getChecksumContractAddress( + ENFORCER_CONTRACT_NAMES.AllowedTargetsEnforcer, + ); + const redeemerEnforcer = getChecksumContractAddress( + ENFORCER_CONTRACT_NAMES.RedeemerEnforcer, + ); + + return { + erc20StreamingEnforcer, + erc20PeriodicEnforcer, + nativeTokenStreamingEnforcer, + nativeTokenPeriodicEnforcer, + approvalRevocationEnforcer, + exactCalldataEnforcer, + valueLteEnforcer, + timestampEnforcer, + nonceEnforcer, + allowedCalldataEnforcer, + allowedTargetsEnforcer, + redeemerEnforcer, + }; +}; diff --git a/packages/7715-permission-types/test/erc20PayeeRule.test.ts b/packages/7715-permission-types/test/erc20PayeeRule.test.ts new file mode 100644 index 00000000..84f193e9 --- /dev/null +++ b/packages/7715-permission-types/test/erc20PayeeRule.test.ts @@ -0,0 +1,150 @@ +import { createAllowedCalldataTerms } from '@metamask/delegation-core'; +import { + CHAIN_ID, + DELEGATOR_CONTRACTS, +} from '@metamask/delegation-deployments'; +import { getChecksumAddress } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; + +import { erc20PayeeRuleDecoder } from '../src/permissions/erc20PayeeRuleDecoder'; +import type { ChecksumCaveat } from '../src/permissions/types'; +import { getChecksumEnforcersByChainId } from '../src/permissions/utils'; + +describe('erc20PayeeRuleDecoder', () => { + const contracts = DELEGATOR_CONTRACTS['1.3.0'][CHAIN_ID.sepolia]; + const contractAddresses = getChecksumEnforcersByChainId(contracts); + const { allowedCalldataEnforcer, nonceEnforcer } = contractAddresses; + const requiredEnforcers = new Map([[nonceEnforcer, 1]]); + + const PAYEE_ADDRESS: Hex = '0x3333333333333333333333333333333333333333'; + const CHECKSUM_PAYEE_INPUT: Hex = + '0x8617e340b3d01fa5f11f306f4090fd50e238070d'; + const paddedPayee: Hex = `0x${PAYEE_ADDRESS.slice(2).padStart(64, '0')}`; + + const validPayeeCaveat: ChecksumCaveat = { + enforcer: allowedCalldataEnforcer, + terms: createAllowedCalldataTerms({ + startIndex: 4, + value: paddedPayee, + }), + args: '0x' as Hex, + }; + + it('returns null when no AllowedCalldataEnforcer caveat is present', () => { + const caveats: ChecksumCaveat[] = [ + { enforcer: nonceEnforcer, terms: '0x' as Hex, args: '0x' as Hex }, + ]; + + expect( + erc20PayeeRuleDecoder({ contractAddresses, caveats, requiredEnforcers }), + ).toBeNull(); + }); + + it('returns a payee rule with the decoded checksummed address', () => { + expect( + erc20PayeeRuleDecoder({ + contractAddresses, + caveats: [validPayeeCaveat], + requiredEnforcers, + }), + ).toStrictEqual({ + type: 'payee', + data: { addresses: [getChecksumAddress(PAYEE_ADDRESS)] }, + }); + }); + + it('returns a checksummed payee address when encoded address is lowercase', () => { + const paddedLowercasePayee: Hex = `0x${CHECKSUM_PAYEE_INPUT.slice(2).padStart(64, '0')}`; + const caveat: ChecksumCaveat = { + enforcer: allowedCalldataEnforcer, + terms: createAllowedCalldataTerms({ + startIndex: 4, + value: paddedLowercasePayee, + }), + args: '0x' as Hex, + }; + + expect( + erc20PayeeRuleDecoder({ + contractAddresses, + caveats: [caveat], + requiredEnforcers, + }), + ).toStrictEqual({ + type: 'payee', + data: { addresses: [getChecksumAddress(CHECKSUM_PAYEE_INPUT)] }, + }); + }); + + it('throws when allowedCalldataEnforcer is configured as required', () => { + const requiredWithPayee = new Map([ + [nonceEnforcer, 1], + [allowedCalldataEnforcer, 1], + ]); + + expect(() => + erc20PayeeRuleDecoder({ + contractAddresses, + caveats: [validPayeeCaveat], + requiredEnforcers: requiredWithPayee, + }), + ).toThrow( + 'Invalid payee caveats: payee enforcer may not be a required caveat', + ); + }); + + it('throws when more than one AllowedCalldataEnforcer caveat is present', () => { + expect(() => + erc20PayeeRuleDecoder({ + contractAddresses, + caveats: [validPayeeCaveat, validPayeeCaveat], + requiredEnforcers, + }), + ).toThrow( + 'Invalid payee caveats: multiple AllowedCalldataEnforcer caveats', + ); + }); + + it('throws when startIndex is not 4', () => { + const caveat: ChecksumCaveat = { + enforcer: allowedCalldataEnforcer, + terms: createAllowedCalldataTerms({ + startIndex: 0, + value: paddedPayee, + }), + args: '0x' as Hex, + }; + + expect(() => + erc20PayeeRuleDecoder({ + contractAddresses, + caveats: [caveat], + requiredEnforcers, + }), + ).toThrow( + 'Invalid payee caveat: AllowedCalldataEnforcer startIndex must be 4', + ); + }); + + it('throws when the encoded value is not 32 bytes long', () => { + const shortValue: Hex = `0x${PAYEE_ADDRESS.slice(2)}`; + const caveat: ChecksumCaveat = { + enforcer: allowedCalldataEnforcer, + terms: createAllowedCalldataTerms({ + startIndex: 4, + value: shortValue, + }), + args: '0x' as Hex, + }; + + expect(() => + erc20PayeeRuleDecoder({ + contractAddresses, + caveats: [caveat], + requiredEnforcers, + }), + ).toThrow( + 'Invalid payee caveat: AllowedCalldataEnforcer value must be 32 bytes long', + ); + }); +}); diff --git a/packages/7715-permission-types/test/erc20TokenAllowance.test.ts b/packages/7715-permission-types/test/erc20TokenAllowance.test.ts new file mode 100644 index 00000000..79433e57 --- /dev/null +++ b/packages/7715-permission-types/test/erc20TokenAllowance.test.ts @@ -0,0 +1,269 @@ +import { createTimestampTerms } from '@metamask/delegation-core'; +import type { Hex } from '@metamask/delegation-core'; +import { + CHAIN_ID, + DELEGATOR_CONTRACTS, +} from '@metamask/delegation-deployments'; + +import { createPermissionDecodersForContracts } from '../src/permissions'; +import { ZERO_32_BYTES } from '../src/permissions/utils'; + +describe('erc20-token-allowance decoder', () => { + const chainId = CHAIN_ID.sepolia; + const contracts = DELEGATOR_CONTRACTS['1.3.0'][chainId]; + const { TimestampEnforcer, ERC20PeriodTransferEnforcer, ValueLteEnforcer } = + contracts; + const permissionDecoders = createPermissionDecodersForContracts(contracts); + const decoder = permissionDecoders.find( + (candidate) => candidate.permissionType === 'erc20-token-allowance', + ); + if (!decoder) { + throw new Error('Decoder not found'); + } + + const expiryCaveat = { + enforcer: TimestampEnforcer, + terms: createTimestampTerms({ + afterThreshold: 0, + beforeThreshold: 1720000, + }), + args: '0x' as const, + }; + + const valueLteCaveat = { + enforcer: ValueLteEnforcer, + terms: ZERO_32_BYTES, + args: '0x' as const, + }; + + const TOKEN_ADDRESS_HEX = 'aa'.repeat(20); + const TOKEN_ADDRESS: Hex = `0x${TOKEN_ADDRESS_HEX}`; + const ALLOWANCE_AMOUNT_HEX = 100n.toString(16).padStart(64, '0'); + const PERIOD_DURATION_MAX_HEX = 'f'.repeat(64); + const START_DATE_HEX = (1715664).toString(16).padStart(64, '0'); + const ALLOWANCE_TERMS = + `0x${TOKEN_ADDRESS_HEX}${ALLOWANCE_AMOUNT_HEX}${PERIOD_DURATION_MAX_HEX}${START_DATE_HEX}` as Hex; + + it('rejects duplicate ERC20PeriodTransferEnforcer caveats', () => { + const caveats = [ + expiryCaveat, + valueLteCaveat, + { + enforcer: ERC20PeriodTransferEnforcer, + terms: ALLOWANCE_TERMS, + args: '0x' as const, + }, + { + enforcer: ERC20PeriodTransferEnforcer, + terms: ALLOWANCE_TERMS, + args: '0x' as const, + }, + ]; + const result = decoder.validateAndDecodePermission(caveats); + expect(result.isValid).toBe(false); + + if (result.isValid) { + throw new Error('Expected invalid result'); + } + + expect(result.error.message).toContain('Invalid caveats'); + }); + + it('rejects truncated terms', () => { + const truncatedTerms: Hex = `0x${'00'.repeat(40)}`; // 40 bytes, need 116 + + const caveats = [ + expiryCaveat, + valueLteCaveat, + { + enforcer: ERC20PeriodTransferEnforcer, + terms: truncatedTerms, + args: '0x' as const, + }, + ]; + + const result = decoder.validateAndDecodePermission(caveats); + expect(result.isValid).toBe(false); + + if (result.isValid) { + throw new Error('Expected invalid result'); + } + + expect(result.error.message).toContain( + 'Invalid erc20-token-allowance terms: expected 116 bytes', + ); + }); + + it('rejects when terms have trailing bytes', () => { + const termsWithTrailing = `${ALLOWANCE_TERMS}deadbeef` as Hex; + const caveats = [ + expiryCaveat, + valueLteCaveat, + { + enforcer: ERC20PeriodTransferEnforcer, + terms: termsWithTrailing, + args: '0x' as const, + }, + ]; + const result = decoder.validateAndDecodePermission(caveats); + expect(result.isValid).toBe(false); + + if (result.isValid) { + throw new Error('Expected invalid result'); + } + + expect(result.error.message).toContain( + 'Invalid erc20-token-allowance terms: expected 116 bytes', + ); + }); + + it('rejects when ValueLteEnforcer terms are not zero', () => { + const nonZeroValueLte: Hex = `0x${'0'.repeat(62)}01` as Hex; + const caveats = [ + expiryCaveat, + { + enforcer: ValueLteEnforcer, + terms: nonZeroValueLte, + args: '0x' as const, + }, + { + enforcer: ERC20PeriodTransferEnforcer, + terms: ALLOWANCE_TERMS, + args: '0x' as const, + }, + ]; + const result = decoder.validateAndDecodePermission(caveats); + expect(result.isValid).toBe(false); + + if (result.isValid) { + throw new Error('Expected invalid result'); + } + + expect(result.error.message).toContain( + `Invalid value-lte terms: must be ${ZERO_32_BYTES}`, + ); + }); + + it('successfully decodes valid erc20-token-allowance caveats', () => { + const caveats = [ + expiryCaveat, + valueLteCaveat, + { + enforcer: ERC20PeriodTransferEnforcer, + terms: ALLOWANCE_TERMS, + args: '0x' as const, + }, + ]; + + const result = decoder.validateAndDecodePermission(caveats); + expect(result.isValid).toBe(true); + + if (!result.isValid) { + throw new Error('Expected valid result'); + } + + expect(result.expiry).toBe(1720000); + expect(result.data).toStrictEqual({ + tokenAddress: TOKEN_ADDRESS, + allowanceAmount: `0x${ALLOWANCE_AMOUNT_HEX}`, + startTime: 1715664, + }); + }); + + it('successfully decodes when periodDuration uses uppercase UINT256_MAX', () => { + const uppercaseMax = 'F'.repeat(64); + const terms = + `0x${TOKEN_ADDRESS_HEX}${ALLOWANCE_AMOUNT_HEX}${uppercaseMax}${START_DATE_HEX}` as Hex; + + const caveats = [ + expiryCaveat, + valueLteCaveat, + { + enforcer: ERC20PeriodTransferEnforcer, + terms, + args: '0x' as const, + }, + ]; + + const result = decoder.validateAndDecodePermission(caveats); + expect(result.isValid).toBe(true); + }); + + it('rejects when periodDuration is not UINT256_MAX', () => { + const nonMaxDuration = (86400).toString(16).padStart(64, '0'); + const terms = + `0x${TOKEN_ADDRESS_HEX}${ALLOWANCE_AMOUNT_HEX}${nonMaxDuration}${START_DATE_HEX}` as Hex; + + const caveats = [ + expiryCaveat, + valueLteCaveat, + { + enforcer: ERC20PeriodTransferEnforcer, + terms, + args: '0x' as const, + }, + ]; + const result = decoder.validateAndDecodePermission(caveats); + expect(result.isValid).toBe(false); + + if (result.isValid) { + throw new Error('Expected invalid result'); + } + + expect(result.error.message).toContain( + 'Invalid erc20-token-allowance terms: periodDuration must be UINT256_MAX', + ); + }); + + it('rejects when startTime is 0', () => { + const startTimeZero = '0'.repeat(64); + const terms = + `0x${TOKEN_ADDRESS_HEX}${ALLOWANCE_AMOUNT_HEX}${PERIOD_DURATION_MAX_HEX}${startTimeZero}` as Hex; + const caveats = [ + expiryCaveat, + valueLteCaveat, + { + enforcer: ERC20PeriodTransferEnforcer, + terms, + args: '0x' as const, + }, + ]; + const result = decoder.validateAndDecodePermission(caveats); + expect(result.isValid).toBe(false); + + if (result.isValid) { + throw new Error('Expected invalid result'); + } + + expect(result.error.message).toContain( + 'Invalid erc20-token-allowance terms: startTime must be a positive number', + ); + }); + + it('rejects when allowanceAmount is 0', () => { + const allowanceZero = '0'.repeat(64); + const terms = + `0x${TOKEN_ADDRESS_HEX}${allowanceZero}${PERIOD_DURATION_MAX_HEX}${START_DATE_HEX}` as Hex; + + const caveats = [ + expiryCaveat, + valueLteCaveat, + { + enforcer: ERC20PeriodTransferEnforcer, + terms, + args: '0x' as const, + }, + ]; + + const result = decoder.validateAndDecodePermission(caveats); + expect(result.isValid).toBe(false); + + if (result.isValid) { + throw new Error('Expected invalid result'); + } + + expect(result.error.message).toContain( + 'Invalid erc20-token-allowance terms: allowanceAmount must be a positive number', + ); + }); +}); diff --git a/packages/7715-permission-types/test/erc20TokenPeriodic.test.ts b/packages/7715-permission-types/test/erc20TokenPeriodic.test.ts new file mode 100644 index 00000000..1396fea9 --- /dev/null +++ b/packages/7715-permission-types/test/erc20TokenPeriodic.test.ts @@ -0,0 +1,395 @@ +import { + createERC20TokenPeriodTransferTerms, + createTimestampTerms, +} from '@metamask/delegation-core'; +import type { Hex } from '@metamask/delegation-core'; +import { + CHAIN_ID, + DELEGATOR_CONTRACTS, +} from '@metamask/delegation-deployments'; + +import { createPermissionDecodersForContracts } from '../src/permissions'; +import { MAX_PERIOD_DURATION, ZERO_32_BYTES } from '../src/permissions/utils'; + +describe('erc20-token-periodic decoder', () => { + const chainId = CHAIN_ID.sepolia; + const contracts = DELEGATOR_CONTRACTS['1.3.0'][chainId]; + const { TimestampEnforcer, ERC20PeriodTransferEnforcer, ValueLteEnforcer } = + contracts; + const permissionDecoders = createPermissionDecodersForContracts(contracts); + const decoder = permissionDecoders.find( + (candidate) => candidate.permissionType === 'erc20-token-periodic', + ); + if (!decoder) { + throw new Error('Decoder not found'); + } + + const expiryCaveat = { + enforcer: TimestampEnforcer, + terms: createTimestampTerms({ + afterThreshold: 0, + beforeThreshold: 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 = decoder.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 = decoder.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 = decoder.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 = decoder.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('decodes mixed-case token address', () => { + const mixedCaseAddress = contracts.ERC20PeriodTransferEnforcer; + const caveats = [ + expiryCaveat, + valueLteCaveat, + { + enforcer: ERC20PeriodTransferEnforcer, + terms: createERC20TokenPeriodTransferTerms( + { + tokenAddress: mixedCaseAddress, + periodAmount: 200n, + periodDuration: 86400, + startDate: 1715664, + }, + { out: 'hex' }, + ), + args: '0x' as const, + }, + ]; + const result = decoder.validateAndDecodePermission(caveats); + expect(result.isValid).toBe(true); + + // this is here as a type guard + if (!result.isValid) { + throw new Error('Expected valid result'); + } + + expect(result.expiry).toBe(1720000); + expect(result.data.tokenAddress.toLowerCase()).toBe( + mixedCaseAddress.toLowerCase(), + ); + }); + + it('rejects when periodDuration is 0', () => { + const tokenAddress = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex; + const periodAmountHex = 100n.toString(16).padStart(64, '0'); + 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 = decoder.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 = decoder.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 = decoder.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 = decoder.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 periodDuration exceeds MAX_PERIOD_DURATION', () => { + const tokenAddress = '0xffffffffffffffffffffffffffffffffffffffff' as Hex; + const terms = createERC20TokenPeriodTransferTerms( + { + tokenAddress, + periodAmount: 100n, + periodDuration: MAX_PERIOD_DURATION + 1, + startDate: 1715664, + }, + { out: 'hex' }, + ); + + const caveats = [ + expiryCaveat, + valueLteCaveat, + { + enforcer: ERC20PeriodTransferEnforcer, + terms, + args: '0x' as const, + }, + ]; + + const result = decoder.validateAndDecodePermission(caveats); + expect(result.isValid).toBe(false); + + // this is here as a type guard + if (result.isValid) { + throw new Error('Expected invalid result'); + } + + expect(result.error.message).toContain( + 'Invalid erc20-token-periodic terms: periodDuration must be less than or equal to MAX_PERIOD_DURATION', + ); + }); + + it('accepts when periodDuration equals MAX_PERIOD_DURATION', () => { + const tokenAddress = '0xcccccccccccccccccccccccccccccccccccccccc' as Hex; + const caveats = [ + expiryCaveat, + valueLteCaveat, + { + enforcer: ERC20PeriodTransferEnforcer, + terms: createERC20TokenPeriodTransferTerms( + { + tokenAddress, + periodAmount: 200n, + periodDuration: MAX_PERIOD_DURATION, + startDate: 1715664, + }, + { out: 'hex' }, + ), + args: '0x' as const, + }, + ]; + const result = decoder.validateAndDecodePermission(caveats); + expect(result.isValid).toBe(true); + + // this is here as a type guard + if (!result.isValid) { + throw new Error('Expected valid result'); + } + + expect(result.data.periodDuration).toBe(MAX_PERIOD_DURATION); + }); +}); diff --git a/packages/7715-permission-types/test/erc20TokenRevocation.test.ts b/packages/7715-permission-types/test/erc20TokenRevocation.test.ts new file mode 100644 index 00000000..30fa582d --- /dev/null +++ b/packages/7715-permission-types/test/erc20TokenRevocation.test.ts @@ -0,0 +1,286 @@ +import { createTimestampTerms } from '@metamask/delegation-core'; +import { + CHAIN_ID, + DELEGATOR_CONTRACTS, +} from '@metamask/delegation-deployments'; +import { getChecksumAddress } from '@metamask/utils'; + +import { createPermissionDecodersForContracts } from '../src/permissions'; + +describe('erc20-token-revocation decoder', () => { + const chainId = CHAIN_ID.sepolia; + const contracts = DELEGATOR_CONTRACTS['1.3.0'][chainId]; + const { + TimestampEnforcer, + AllowedCalldataEnforcer, + ValueLteEnforcer, + RedeemerEnforcer, + } = contracts; + const permissionDecoders = createPermissionDecodersForContracts(contracts); + const decoder = permissionDecoders.find( + (candidate) => candidate.permissionType === 'erc20-token-revocation', + ); + if (!decoder) { + throw new Error('Decoder not found'); + } + + const expiryCaveat = { + enforcer: TimestampEnforcer, + terms: createTimestampTerms({ + afterThreshold: 0, + beforeThreshold: 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 = decoder.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 = decoder.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 = decoder.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 = decoder.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 = decoder.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({}); + expect(result.rules).toStrictEqual([ + { + type: 'expiry', + data: { timestamp: 1720000 }, + }, + ]); + }); + + it('includes redeemer rule but not payee when RedeemerEnforcer caveat is present', () => { + const packedAddr = '1111111111111111111111111111111111111111' as const; + 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: RedeemerEnforcer, + terms: `0x${packedAddr}` as const, + args: '0x' as const, + }, + ]; + const result = decoder.validateAndDecodePermission(caveats); + expect(result.isValid).toBe(true); + if (!result.isValid) { + throw new Error('Expected valid result'); + } + expect(result.rules).toStrictEqual([ + { + type: 'expiry', + data: { timestamp: 1720000 }, + }, + { + type: 'redeemer', + data: { + addresses: [ + getChecksumAddress( + '0x1111111111111111111111111111111111111111' as const, + ), + ], + }, + }, + ]); + }); +}); diff --git a/packages/7715-permission-types/test/erc20TokenStream.test.ts b/packages/7715-permission-types/test/erc20TokenStream.test.ts new file mode 100644 index 00000000..1010bbfb --- /dev/null +++ b/packages/7715-permission-types/test/erc20TokenStream.test.ts @@ -0,0 +1,336 @@ +import { + createERC20StreamingTerms, + createTimestampTerms, +} from '@metamask/delegation-core'; +import type { Hex } from '@metamask/delegation-core'; +import { + CHAIN_ID, + DELEGATOR_CONTRACTS, +} from '@metamask/delegation-deployments'; + +import { createPermissionDecodersForContracts } from '../src/permissions'; +import { ZERO_32_BYTES } from '../src/permissions/utils'; + +describe('erc20-token-stream decoder', () => { + const chainId = CHAIN_ID.sepolia; + const contracts = DELEGATOR_CONTRACTS['1.3.0'][chainId]; + const { TimestampEnforcer, ERC20StreamingEnforcer, ValueLteEnforcer } = + contracts; + const permissionDecoders = createPermissionDecodersForContracts(contracts); + const decoder = permissionDecoders.find( + (candidate) => candidate.permissionType === 'erc20-token-stream', + ); + if (!decoder) { + throw new Error('Decoder not found'); + } + + const expiryCaveat = { + enforcer: TimestampEnforcer, + terms: createTimestampTerms({ + afterThreshold: 0, + beforeThreshold: 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 = decoder.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 = decoder.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 = decoder.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 mixed-case token address', () => { + const mixedCaseAddress = contracts.ERC20StreamingEnforcer; + const caveats = [ + expiryCaveat, + valueLteCaveat, + { + enforcer: ERC20StreamingEnforcer, + terms: createERC20StreamingTerms( + { + tokenAddress: mixedCaseAddress, + initialAmount: 1n, + maxAmount: 2n, + amountPerSecond: 1n, + startTime: 1715664, + }, + { out: 'hex' }, + ), + args: '0x' as const, + }, + ]; + const result = decoder.validateAndDecodePermission(caveats); + expect(result.isValid).toBe(true); + + // this is here as a type guard + if (!result.isValid) { + throw new Error('Expected valid result'); + } + + expect(result.expiry).toBe(1720000); + expect(result.data?.tokenAddress.toLowerCase()).toBe( + mixedCaseAddress.toLowerCase(), + ); + }); + + it('decodes zero token address', () => { + const zeroAddress = '0x0000000000000000000000000000000000000000' as Hex; + const caveats = [ + expiryCaveat, + valueLteCaveat, + { + enforcer: ERC20StreamingEnforcer, + terms: createERC20StreamingTerms( + { + tokenAddress: zeroAddress, + initialAmount: 1n, + maxAmount: 2n, + amountPerSecond: 1n, + startTime: 1715664, + }, + { out: 'hex' }, + ), + args: '0x' as const, + }, + ]; + const result = decoder.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 = decoder.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 maxAmount equals initialAmount', () => { + const tokenAddress = '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Hex; + const initialAmountAndMaxAmount = 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)}${initialAmountAndMaxAmount}${initialAmountAndMaxAmount}${amountPerSecondHex}${startTimeHex}` as Hex; + const caveats = [ + expiryCaveat, + valueLteCaveat, + { enforcer: ERC20StreamingEnforcer, terms, args: '0x' as const }, + ]; + const result = decoder.validateAndDecodePermission(caveats); + expect(result.isValid).toBe(false); + + // this is here as a type guard + if (result.isValid) { + throw new Error('Expected invalid result'); + } + + expect(result.error.message).toContain( + 'maxAmount must be greater than initialAmount', + ); + }); + + it('rejects when terms have trailing bytes', () => { + const tokenAddress = '0xcccccccccccccccccccccccccccccccccccccccc' as Hex; + const validTerms = createERC20StreamingTerms( + { + tokenAddress, + initialAmount: 42n, + maxAmount: 100n, + amountPerSecond: 1n, + startTime: 1715664, + }, + { out: 'hex' }, + ); + const termsWithTrailing = `${validTerms}deadbeef` as Hex; + const caveats = [ + expiryCaveat, + valueLteCaveat, + { + enforcer: ERC20StreamingEnforcer, + terms: termsWithTrailing, + args: '0x' as const, + }, + ]; + const result = decoder.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 = decoder.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 = decoder.validateAndDecodePermission(caveats); + expect(result.isValid).toBe(false); + + // this is here as a type guard + if (result.isValid) { + throw new Error('Expected invalid result'); + } + + expect(result.error.message).toContain( + 'Invalid erc20-token-stream terms: startTime must be a positive number', + ); + }); +}); diff --git a/packages/7715-permission-types/test/expiryRule.test.ts b/packages/7715-permission-types/test/expiryRule.test.ts new file mode 100644 index 00000000..aecc371c --- /dev/null +++ b/packages/7715-permission-types/test/expiryRule.test.ts @@ -0,0 +1,70 @@ +import { createTimestampTerms } from '@metamask/delegation-core'; +import { + CHAIN_ID, + DELEGATOR_CONTRACTS, +} from '@metamask/delegation-deployments'; +import type { Hex } from '@metamask/utils'; + +import { expiryRule } from '../src/permissions/expiryRule'; +import type { ChecksumCaveat } from '../src/permissions/types'; +import { getChecksumEnforcersByChainId } from '../src/permissions/utils'; + +describe('expiryRule', () => { + const contracts = DELEGATOR_CONTRACTS['1.3.0'][CHAIN_ID.sepolia]; + const contractAddresses = getChecksumEnforcersByChainId(contracts); + const { timestampEnforcer, nonceEnforcer } = contractAddresses; + const requiredEnforcers = new Map([[nonceEnforcer, 1]]); + + it('returns null when no TimestampEnforcer caveat is present', () => { + const caveats: ChecksumCaveat[] = [ + { enforcer: nonceEnforcer, terms: '0x' as Hex, args: '0x' as Hex }, + ]; + + expect( + expiryRule({ contractAddresses, caveats, requiredEnforcers }), + ).toBeNull(); + }); + + it('returns an expiry rule with the decoded timestamp when TimestampEnforcer is present', () => { + const beforeThreshold = 1_750_000_000; + const caveats: ChecksumCaveat[] = [ + { + enforcer: timestampEnforcer, + terms: createTimestampTerms({ + afterThreshold: 0, + beforeThreshold, + }), + args: '0x' as Hex, + }, + ]; + + expect( + expiryRule({ contractAddresses, caveats, requiredEnforcers }), + ).toStrictEqual({ + type: 'expiry', + data: { timestamp: beforeThreshold }, + }); + }); + + it('ignores caveats from unrelated enforcers', () => { + const beforeThreshold = 1_700_000_000; + const caveats: ChecksumCaveat[] = [ + { enforcer: nonceEnforcer, terms: '0x' as Hex, args: '0x' as Hex }, + { + enforcer: timestampEnforcer, + terms: createTimestampTerms({ + afterThreshold: 0, + beforeThreshold, + }), + args: '0x' as Hex, + }, + ]; + + expect( + expiryRule({ contractAddresses, caveats, requiredEnforcers }), + ).toStrictEqual({ + type: 'expiry', + data: { timestamp: beforeThreshold }, + }); + }); +}); diff --git a/packages/7715-permission-types/test/nativePayeeRule.test.ts b/packages/7715-permission-types/test/nativePayeeRule.test.ts new file mode 100644 index 00000000..2f0faed5 --- /dev/null +++ b/packages/7715-permission-types/test/nativePayeeRule.test.ts @@ -0,0 +1,129 @@ +import { createAllowedTargetsTerms } from '@metamask/delegation-core'; +import { + CHAIN_ID, + DELEGATOR_CONTRACTS, +} from '@metamask/delegation-deployments'; +import { getChecksumAddress } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; + +import { nativePayeeRuleDecoder } from '../src/permissions/nativePayeeRuleDecoder'; +import type { ChecksumCaveat } from '../src/permissions/types'; +import { getChecksumEnforcersByChainId } from '../src/permissions/utils'; + +describe('nativePayeeRuleDecoder', () => { + const contracts = DELEGATOR_CONTRACTS['1.3.0'][CHAIN_ID.sepolia]; + const contractAddresses = getChecksumEnforcersByChainId(contracts); + const { allowedTargetsEnforcer, nonceEnforcer } = contractAddresses; + const requiredEnforcers = new Map([[nonceEnforcer, 1]]); + + const PAYEE_A: Hex = '0x4444444444444444444444444444444444444444'; + const PAYEE_B: Hex = '0x5555555555555555555555555555555555555555'; + const CHECKSUM_PAYEE_INPUT: Hex = + '0xde709f2102306220921060314715629080e2fb77'; + + it('returns null when no AllowedTargetsEnforcer caveat is present', () => { + const caveats: ChecksumCaveat[] = [ + { enforcer: nonceEnforcer, terms: '0x' as Hex, args: '0x' as Hex }, + ]; + + expect( + nativePayeeRuleDecoder({ contractAddresses, caveats, requiredEnforcers }), + ).toBeNull(); + }); + + it('returns a payee rule with a single decoded checksummed address', () => { + const caveats: ChecksumCaveat[] = [ + { + enforcer: allowedTargetsEnforcer, + terms: createAllowedTargetsTerms({ targets: [PAYEE_A] }), + args: '0x' as Hex, + }, + ]; + + expect( + nativePayeeRuleDecoder({ contractAddresses, caveats, requiredEnforcers }), + ).toStrictEqual({ + type: 'payee', + data: { addresses: [getChecksumAddress(PAYEE_A)] }, + }); + }); + + it('returns a payee rule with multiple decoded checksummed addresses', () => { + const caveats: ChecksumCaveat[] = [ + { + enforcer: allowedTargetsEnforcer, + terms: createAllowedTargetsTerms({ targets: [PAYEE_A, PAYEE_B] }), + args: '0x' as Hex, + }, + ]; + + expect( + nativePayeeRuleDecoder({ contractAddresses, caveats, requiredEnforcers }), + ).toStrictEqual({ + type: 'payee', + data: { + addresses: [getChecksumAddress(PAYEE_A), getChecksumAddress(PAYEE_B)], + }, + }); + }); + + it('returns checksummed payee addresses', () => { + const caveats: ChecksumCaveat[] = [ + { + enforcer: allowedTargetsEnforcer, + terms: createAllowedTargetsTerms({ targets: [CHECKSUM_PAYEE_INPUT] }), + args: '0x' as Hex, + }, + ]; + + expect( + nativePayeeRuleDecoder({ contractAddresses, caveats, requiredEnforcers }), + ).toStrictEqual({ + type: 'payee', + data: { addresses: [getChecksumAddress(CHECKSUM_PAYEE_INPUT)] }, + }); + }); + + it('throws when allowedTargetsEnforcer is configured as required', () => { + const requiredWithPayee = new Map([ + [nonceEnforcer, 1], + [allowedTargetsEnforcer, 1], + ]); + const caveats: ChecksumCaveat[] = [ + { + enforcer: allowedTargetsEnforcer, + terms: createAllowedTargetsTerms({ targets: [PAYEE_A] }), + args: '0x' as Hex, + }, + ]; + + expect(() => + nativePayeeRuleDecoder({ + contractAddresses, + caveats, + requiredEnforcers: requiredWithPayee, + }), + ).toThrow( + 'Invalid payee caveats: payee enforcer may not be a required caveat', + ); + }); + + it('throws when more than one AllowedTargetsEnforcer caveat is present', () => { + const caveats: ChecksumCaveat[] = [ + { + enforcer: allowedTargetsEnforcer, + terms: createAllowedTargetsTerms({ targets: [PAYEE_A] }), + args: '0x' as Hex, + }, + { + enforcer: allowedTargetsEnforcer, + terms: createAllowedTargetsTerms({ targets: [PAYEE_B] }), + args: '0x' as Hex, + }, + ]; + + expect(() => + nativePayeeRuleDecoder({ contractAddresses, caveats, requiredEnforcers }), + ).toThrow('Invalid payee caveats: multiple AllowedTargetsEnforcer caveats'); + }); +}); diff --git a/packages/7715-permission-types/test/nativeTokenAllowance.test.ts b/packages/7715-permission-types/test/nativeTokenAllowance.test.ts new file mode 100644 index 00000000..b301f139 --- /dev/null +++ b/packages/7715-permission-types/test/nativeTokenAllowance.test.ts @@ -0,0 +1,268 @@ +import { createTimestampTerms } from '@metamask/delegation-core'; +import type { Hex } from '@metamask/delegation-core'; +import { + CHAIN_ID, + DELEGATOR_CONTRACTS, +} from '@metamask/delegation-deployments'; + +import { createPermissionDecodersForContracts } from '../src/permissions'; + +describe('native-token-allowance decoder', () => { + const chainId = CHAIN_ID.sepolia; + const contracts = DELEGATOR_CONTRACTS['1.3.0'][chainId]; + const { + TimestampEnforcer, + NativeTokenPeriodTransferEnforcer, + ExactCalldataEnforcer, + } = contracts; + const permissionDecoders = createPermissionDecodersForContracts(contracts); + const decoder = permissionDecoders.find( + (candidate) => candidate.permissionType === 'native-token-allowance', + ); + if (!decoder) { + throw new Error('Decoder not found'); + } + + const expiryCaveat = { + enforcer: TimestampEnforcer, + terms: createTimestampTerms({ + afterThreshold: 0, + beforeThreshold: 1720000, + }), + args: '0x' as const, + }; + + const exactCalldataCaveat = { + enforcer: ExactCalldataEnforcer, + terms: '0x' as Hex, + args: '0x' as const, + }; + + const PERIOD_AMOUNT_HEX = 100n.toString(16).padStart(64, '0'); + const PERIOD_DURATION_MAX_HEX = 'f'.repeat(64); + const START_DATE_HEX = (1715664).toString(16).padStart(64, '0'); + const ALLOWANCE_TERMS = + `0x${PERIOD_AMOUNT_HEX}${PERIOD_DURATION_MAX_HEX}${START_DATE_HEX}` as Hex; + + it('rejects duplicate NativeTokenPeriodTransferEnforcer caveats', () => { + const caveats = [ + expiryCaveat, + exactCalldataCaveat, + { + enforcer: NativeTokenPeriodTransferEnforcer, + terms: ALLOWANCE_TERMS, + args: '0x' as const, + }, + { + enforcer: NativeTokenPeriodTransferEnforcer, + terms: ALLOWANCE_TERMS, + args: '0x' as const, + }, + ]; + const result = decoder.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 = decoder.validateAndDecodePermission(caveats); + expect(result.isValid).toBe(false); + + if (result.isValid) { + throw new Error('Expected invalid result'); + } + + expect(result.error.message).toContain( + 'Invalid native-token-allowance terms: expected 96 bytes', + ); + }); + + it('rejects when terms have trailing bytes', () => { + const termsWithTrailing = `${ALLOWANCE_TERMS}deadbeef` as Hex; + const caveats = [ + expiryCaveat, + exactCalldataCaveat, + { + enforcer: NativeTokenPeriodTransferEnforcer, + terms: termsWithTrailing, + args: '0x' as const, + }, + ]; + const result = decoder.validateAndDecodePermission(caveats); + expect(result.isValid).toBe(false); + + if (result.isValid) { + throw new Error('Expected invalid result'); + } + + expect(result.error.message).toContain( + 'Invalid native-token-allowance terms: expected 96 bytes', + ); + }); + + it('rejects when ExactCalldataEnforcer terms are not 0x', () => { + const caveats = [ + expiryCaveat, + { + enforcer: ExactCalldataEnforcer, + terms: '0x00' as Hex, + args: '0x' as const, + }, + { + enforcer: NativeTokenPeriodTransferEnforcer, + terms: ALLOWANCE_TERMS, + args: '0x' as const, + }, + ]; + const result = decoder.validateAndDecodePermission(caveats); + expect(result.isValid).toBe(false); + + if (result.isValid) { + throw new Error('Expected invalid result'); + } + + expect(result.error.message).toContain( + 'Invalid exact-calldata terms: must be 0x', + ); + }); + + it('successfully decodes valid native-token-allowance caveats', () => { + const caveats = [ + expiryCaveat, + exactCalldataCaveat, + { + enforcer: NativeTokenPeriodTransferEnforcer, + terms: ALLOWANCE_TERMS, + args: '0x' as const, + }, + ]; + + const result = decoder.validateAndDecodePermission(caveats); + expect(result.isValid).toBe(true); + + if (!result.isValid) { + throw new Error('Expected valid result'); + } + + expect(result.expiry).toBe(1720000); + expect(result.data).toStrictEqual({ + allowanceAmount: `0x${PERIOD_AMOUNT_HEX}`, + startTime: 1715664, + }); + }); + + it('successfully decodes when periodDuration uses uppercase UINT256_MAX', () => { + const uppercaseMax = 'F'.repeat(64); + const terms = + `0x${PERIOD_AMOUNT_HEX}${uppercaseMax}${START_DATE_HEX}` as Hex; + + const caveats = [ + expiryCaveat, + exactCalldataCaveat, + { + enforcer: NativeTokenPeriodTransferEnforcer, + terms, + args: '0x' as const, + }, + ]; + + const result = decoder.validateAndDecodePermission(caveats); + expect(result.isValid).toBe(true); + }); + + it('rejects when periodDuration is not UINT256_MAX', () => { + const nonMaxDuration = (86400).toString(16).padStart(64, '0'); + const terms = + `0x${PERIOD_AMOUNT_HEX}${nonMaxDuration}${START_DATE_HEX}` as Hex; + + const caveats = [ + expiryCaveat, + exactCalldataCaveat, + { + enforcer: NativeTokenPeriodTransferEnforcer, + terms, + args: '0x' as const, + }, + ]; + const result = decoder.validateAndDecodePermission(caveats); + expect(result.isValid).toBe(false); + + if (result.isValid) { + throw new Error('Expected invalid result'); + } + + expect(result.error.message).toContain( + 'Invalid native-token-allowance terms: periodDuration must be UINT256_MAX', + ); + }); + + it('rejects when startTime is 0', () => { + const startTimeZero = '0'.repeat(64); + const terms = + `0x${PERIOD_AMOUNT_HEX}${PERIOD_DURATION_MAX_HEX}${startTimeZero}` as Hex; + const caveats = [ + expiryCaveat, + exactCalldataCaveat, + { + enforcer: NativeTokenPeriodTransferEnforcer, + terms, + args: '0x' as const, + }, + ]; + const result = decoder.validateAndDecodePermission(caveats); + expect(result.isValid).toBe(false); + + if (result.isValid) { + throw new Error('Expected invalid result'); + } + + expect(result.error.message).toContain( + 'Invalid native-token-allowance terms: startTime must be a positive number', + ); + }); + + it('rejects when allowanceAmount is 0', () => { + const allowanceZero = '0'.repeat(64); + const terms = + `0x${allowanceZero}${PERIOD_DURATION_MAX_HEX}${START_DATE_HEX}` as Hex; + + const caveats = [ + expiryCaveat, + exactCalldataCaveat, + { + enforcer: NativeTokenPeriodTransferEnforcer, + terms, + args: '0x' as const, + }, + ]; + + const result = decoder.validateAndDecodePermission(caveats); + expect(result.isValid).toBe(false); + + if (result.isValid) { + throw new Error('Expected invalid result'); + } + + expect(result.error.message).toContain( + 'Invalid native-token-allowance terms: allowanceAmount must be a positive number', + ); + }); +}); diff --git a/packages/7715-permission-types/test/nativeTokenPeriodic.test.ts b/packages/7715-permission-types/test/nativeTokenPeriodic.test.ts new file mode 100644 index 00000000..68f728aa --- /dev/null +++ b/packages/7715-permission-types/test/nativeTokenPeriodic.test.ts @@ -0,0 +1,350 @@ +import { + createNativeTokenPeriodTransferTerms, + createTimestampTerms, +} from '@metamask/delegation-core'; +import type { Hex } from '@metamask/delegation-core'; +import { + CHAIN_ID, + DELEGATOR_CONTRACTS, +} from '@metamask/delegation-deployments'; + +import { createPermissionDecodersForContracts } from '../src/permissions'; +import { MAX_PERIOD_DURATION } from '../src/permissions/utils'; + +describe('native-token-periodic decoder', () => { + const chainId = CHAIN_ID.sepolia; + const contracts = DELEGATOR_CONTRACTS['1.3.0'][chainId]; + const { + TimestampEnforcer, + NativeTokenPeriodTransferEnforcer, + ExactCalldataEnforcer, + } = contracts; + const permissionDecoders = createPermissionDecodersForContracts(contracts); + const decoder = permissionDecoders.find( + (candidate) => candidate.permissionType === 'native-token-periodic', + ); + if (!decoder) { + throw new Error('Decoder not found'); + } + + const expiryCaveat = { + enforcer: TimestampEnforcer, + terms: createTimestampTerms({ + afterThreshold: 0, + beforeThreshold: 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 = decoder.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 = decoder.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 = decoder.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 = decoder.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 = decoder.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 = decoder.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 = decoder.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 = decoder.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', + ); + }); + + it('rejects when periodDuration exceeds MAX_PERIOD_DURATION', () => { + const terms = createNativeTokenPeriodTransferTerms( + { + periodAmount: 100n, + periodDuration: MAX_PERIOD_DURATION + 1, + startDate: 1715664, + }, + { out: 'hex' }, + ); + + const caveats = [ + expiryCaveat, + exactCalldataCaveat, + { + enforcer: NativeTokenPeriodTransferEnforcer, + terms, + args: '0x' as const, + }, + ]; + + const result = decoder.validateAndDecodePermission(caveats); + expect(result.isValid).toBe(false); + + // this is here as a type guard + if (result.isValid) { + throw new Error('Expected invalid result'); + } + + expect(result.error.message).toContain( + 'Invalid native-token-periodic terms: periodDuration must be less than or equal to MAX_PERIOD_DURATION', + ); + }); + + it('accepts when periodDuration equals MAX_PERIOD_DURATION', () => { + const caveats = [ + expiryCaveat, + exactCalldataCaveat, + { + enforcer: NativeTokenPeriodTransferEnforcer, + terms: createNativeTokenPeriodTransferTerms( + { + periodAmount: 100n, + periodDuration: MAX_PERIOD_DURATION, + startDate: 1715664, + }, + { out: 'hex' }, + ), + args: '0x' as const, + }, + ]; + + const result = decoder.validateAndDecodePermission(caveats); + expect(result.isValid).toBe(true); + + // this is here as a type guard + if (!result.isValid) { + throw new Error('Expected valid result'); + } + + expect(result.data.periodDuration).toBe(MAX_PERIOD_DURATION); + }); +}); diff --git a/packages/7715-permission-types/test/nativeTokenStream.test.ts b/packages/7715-permission-types/test/nativeTokenStream.test.ts new file mode 100644 index 00000000..3a566d64 --- /dev/null +++ b/packages/7715-permission-types/test/nativeTokenStream.test.ts @@ -0,0 +1,477 @@ +import { + createNativeTokenStreamingTerms, + createTimestampTerms, +} from '@metamask/delegation-core'; +import type { Hex } from '@metamask/delegation-core'; +import { + CHAIN_ID, + DELEGATOR_CONTRACTS, +} from '@metamask/delegation-deployments'; + +import { createPermissionDecodersForContracts } from '../src/permissions'; + +describe('native-token-stream decoder', () => { + const chainId = CHAIN_ID.sepolia; + const contracts = DELEGATOR_CONTRACTS['1.3.0'][chainId]; + const { + TimestampEnforcer, + NativeTokenStreamingEnforcer, + ExactCalldataEnforcer, + } = contracts; + const permissionDecoders = createPermissionDecodersForContracts(contracts); + const decoder = permissionDecoders.find( + (candidate) => candidate.permissionType === 'native-token-stream', + ); + if (!decoder) { + throw new Error('Decoder not found'); + } + + const expiryCaveat = { + enforcer: TimestampEnforcer, + terms: createTimestampTerms({ + afterThreshold: 0, + beforeThreshold: 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({ + afterThreshold: 0, + beforeThreshold: 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 = decoder.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 = decoder.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 = decoder.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 = decoder.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 = decoder.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 = decoder.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 = decoder.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({ + afterThreshold: 0, + beforeThreshold: 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 = decoder.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({ + afterThreshold: 1, + beforeThreshold: 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 = decoder.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 = decoder.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 when maxAmount equals initialAmount', () => { + const initialAmountAndMaxAmount = 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${initialAmountAndMaxAmount}${initialAmountAndMaxAmount}${amountPerSecondHex}${startTimeHex}` as Hex; + const caveats = [ + expiryCaveat, + exactCalldataCaveat, + { enforcer: NativeTokenStreamingEnforcer, terms, args: '0x' as const }, + ]; + const result = decoder.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 = decoder.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 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 = decoder.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 = decoder.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/7715-permission-types/test/redeemerRule.test.ts b/packages/7715-permission-types/test/redeemerRule.test.ts new file mode 100644 index 00000000..a60bc527 --- /dev/null +++ b/packages/7715-permission-types/test/redeemerRule.test.ts @@ -0,0 +1,102 @@ +import { createRedeemerTerms } from '@metamask/delegation-core'; +import { + CHAIN_ID, + DELEGATOR_CONTRACTS, +} from '@metamask/delegation-deployments'; +import { getChecksumAddress } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; + +import { redeemerRuleDecoder } from '../src/permissions/redeemerRuleDecoder'; +import type { ChecksumCaveat } from '../src/permissions/types'; +import { getChecksumEnforcersByChainId } from '../src/permissions/utils'; + +describe('redeemerRuleDecoder', () => { + const contracts = DELEGATOR_CONTRACTS['1.3.0'][CHAIN_ID.sepolia]; + const contractAddresses = getChecksumEnforcersByChainId(contracts); + const { redeemerEnforcer, nonceEnforcer } = contractAddresses; + const requiredEnforcers = new Map([[nonceEnforcer, 1]]); + + const ADDRESS_A: Hex = '0x1111111111111111111111111111111111111111'; + const ADDRESS_B: Hex = '0x2222222222222222222222222222222222222222'; + const CHECKSUM_REDEEMER_INPUT: Hex = + '0x52908400098527886e0f7030069857d2e4169ee7'; + + it('returns null when no RedeemerEnforcer caveat is present', () => { + const caveats: ChecksumCaveat[] = [ + { enforcer: nonceEnforcer, terms: '0x' as Hex, args: '0x' as Hex }, + ]; + + expect( + redeemerRuleDecoder({ contractAddresses, caveats, requiredEnforcers }), + ).toBeNull(); + }); + + it('returns a redeemer rule with a single decoded address', () => { + const caveats: ChecksumCaveat[] = [ + { + enforcer: redeemerEnforcer, + terms: createRedeemerTerms({ redeemers: [ADDRESS_A] }), + args: '0x' as Hex, + }, + ]; + + expect( + redeemerRuleDecoder({ contractAddresses, caveats, requiredEnforcers }), + ).toStrictEqual({ + type: 'redeemer', + data: { addresses: [ADDRESS_A] }, + }); + }); + + it('returns a redeemer rule with multiple decoded addresses', () => { + const caveats: ChecksumCaveat[] = [ + { + enforcer: redeemerEnforcer, + terms: createRedeemerTerms({ redeemers: [ADDRESS_A, ADDRESS_B] }), + args: '0x' as Hex, + }, + ]; + + expect( + redeemerRuleDecoder({ contractAddresses, caveats, requiredEnforcers }), + ).toStrictEqual({ + type: 'redeemer', + data: { addresses: [ADDRESS_A, ADDRESS_B] }, + }); + }); + + it('returns checksummed redeemer addresses', () => { + const caveats: ChecksumCaveat[] = [ + { + enforcer: redeemerEnforcer, + terms: createRedeemerTerms({ redeemers: [CHECKSUM_REDEEMER_INPUT] }), + args: '0x' as Hex, + }, + ]; + + expect( + redeemerRuleDecoder({ contractAddresses, caveats, requiredEnforcers }), + ).toStrictEqual({ + type: 'redeemer', + data: { addresses: [getChecksumAddress(CHECKSUM_REDEEMER_INPUT)] }, + }); + }); + + it('ignores caveats from unrelated enforcers', () => { + const caveats: ChecksumCaveat[] = [ + { enforcer: nonceEnforcer, terms: '0x' as Hex, args: '0x' as Hex }, + { + enforcer: redeemerEnforcer, + terms: createRedeemerTerms({ redeemers: [ADDRESS_A] }), + args: '0x' as Hex, + }, + ]; + + expect( + redeemerRuleDecoder({ contractAddresses, caveats, requiredEnforcers }), + ).toStrictEqual({ + type: 'redeemer', + data: { addresses: [getChecksumAddress(ADDRESS_A)] }, + }); + }); +}); diff --git a/packages/7715-permission-types/test/tokenApprovalRevocation.test.ts b/packages/7715-permission-types/test/tokenApprovalRevocation.test.ts new file mode 100644 index 00000000..eb9edacc --- /dev/null +++ b/packages/7715-permission-types/test/tokenApprovalRevocation.test.ts @@ -0,0 +1,184 @@ +import { createTimestampTerms } from '@metamask/delegation-core'; +import { + CHAIN_ID, + DELEGATOR_CONTRACTS, +} from '@metamask/delegation-deployments'; + +import { createPermissionDecodersForContracts } from '../src/permissions'; +import { getChecksumEnforcersByChainId } from '../src/permissions/utils'; + +describe('token-approval-revocation decoder', () => { + const chainId = CHAIN_ID.sepolia; + const contracts = DELEGATOR_CONTRACTS['1.3.0'][chainId]; + const { timestampEnforcer, approvalRevocationEnforcer, nonceEnforcer } = + getChecksumEnforcersByChainId(contracts); + const permissionDecoders = createPermissionDecodersForContracts(contracts); + const decoder = permissionDecoders.find( + (candidate) => candidate.permissionType === 'token-approval-revocation', + ); + + if (!decoder) { + throw new Error('Decoder not found'); + } + + const expiryCaveat = { + enforcer: timestampEnforcer, + terms: createTimestampTerms({ + afterThreshold: 0, + beforeThreshold: 1720000, + }), + args: '0x' as const, + }; + + it('rejects empty terms', () => { + const caveats = [ + expiryCaveat, + { + enforcer: approvalRevocationEnforcer, + terms: '0x' as const, + args: '0x' as const, + }, + { + enforcer: nonceEnforcer, + terms: '0x' as const, + args: '0x' as const, + }, + ]; + + const result = decoder.validateAndDecodePermission(caveats); + expect(result.isValid).toBe(false); + + if (result.isValid) { + throw new Error('Expected invalid result'); + } + + expect(result.error.message).toContain( + 'Invalid ApprovalRevocation terms: must be greater than 0', + ); + }); + + it('rejects 0x00 terms', () => { + const caveats = [ + expiryCaveat, + { + enforcer: approvalRevocationEnforcer, + terms: '0x00' as const, + args: '0x' as const, + }, + { + enforcer: nonceEnforcer, + terms: '0x' as const, + args: '0x' as const, + }, + ]; + + const result = decoder.validateAndDecodePermission(caveats); + expect(result.isValid).toBe(false); + + if (result.isValid) { + throw new Error('Expected invalid result'); + } + + expect(result.error.message).toContain( + 'Invalid ApprovalRevocation terms: must be greater than 0', + ); + }); + + it('rejects terms whose mask exceeds the supported max', () => { + const caveats = [ + expiryCaveat, + { + enforcer: approvalRevocationEnforcer, + terms: '0x40' as const, + args: '0x' as const, + }, + { + enforcer: nonceEnforcer, + terms: '0x' as const, + args: '0x' as const, + }, + ]; + + const result = decoder.validateAndDecodePermission(caveats); + expect(result.isValid).toBe(false); + + if (result.isValid) { + throw new Error('Expected invalid result'); + } + + expect(result.error.message).toContain( + 'Invalid ApprovalRevocation terms: must be less than or equal to 63', + ); + }); + + it('successfully decodes valid token-approval-revocation caveats', () => { + const caveats = [ + expiryCaveat, + { + enforcer: approvalRevocationEnforcer, + terms: '0x01' as const, + args: '0x' as const, + }, + { + enforcer: nonceEnforcer, + terms: '0x' as const, + args: '0x' as const, + }, + ]; + + const result = decoder.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({ + erc20Approve: true, + erc721Approve: false, + erc721SetApprovalForAll: false, + permit2Approve: false, + permit2Lockdown: false, + permit2InvalidateNonces: false, + }); + expect(result.rules).toStrictEqual([ + { + type: 'expiry', + data: { timestamp: 1720000 }, + }, + ]); + }); + + it('decodes all supported flags from the terms bitmask', () => { + const caveats = [ + expiryCaveat, + { + enforcer: approvalRevocationEnforcer, + terms: '0x3f' as const, + args: '0x' as const, + }, + { + enforcer: nonceEnforcer, + terms: '0x' as const, + args: '0x' as const, + }, + ]; + + const result = decoder.validateAndDecodePermission(caveats); + expect(result.isValid).toBe(true); + + if (!result.isValid) { + throw new Error('Expected valid result'); + } + + expect(result.data).toStrictEqual({ + erc20Approve: true, + erc721Approve: true, + erc721SetApprovalForAll: true, + permit2Approve: true, + permit2Lockdown: true, + permit2InvalidateNonces: true, + }); + }); +}); diff --git a/packages/7715-permission-types/tsconfig.eslint.json b/packages/7715-permission-types/tsconfig.eslint.json new file mode 100644 index 00000000..5c06f745 --- /dev/null +++ b/packages/7715-permission-types/tsconfig.eslint.json @@ -0,0 +1,19 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "types": [ + "node", + "vitest/globals" + ] + }, + "include": [ + "./src/**/*.ts", + "./test/**/*.ts" + ], + "exclude": [ + "./node_modules/**/*", + "./dist/**/*", + "./script/**/*" + ] +} diff --git a/packages/7715-permission-types/tsconfig.json b/packages/7715-permission-types/tsconfig.json index 19ae5b69..5fc8af7a 100644 --- a/packages/7715-permission-types/tsconfig.json +++ b/packages/7715-permission-types/tsconfig.json @@ -9,6 +9,6 @@ ], "compilerOptions": { "baseUrl": ".", - "outDir": "dist", + "outDir": "dist" } } \ No newline at end of file diff --git a/packages/7715-permission-types/vitest.config.ts b/packages/7715-permission-types/vitest.config.ts new file mode 100644 index 00000000..7382f40e --- /dev/null +++ b/packages/7715-permission-types/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + }, +}); diff --git a/yarn.lock b/yarn.lock index 10a3f02f..34b8ef1a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -577,11 +577,16 @@ __metadata: resolution: "@metamask/7715-permission-types@workspace:packages/7715-permission-types" dependencies: "@metamask/auto-changelog": "npm:^6.1.1" + "@metamask/delegation-core": "npm:^2.2.1" + "@metamask/delegation-deployments": "npm:^1.4.0" + "@metamask/utils": "npm:^11.4.0" + "@types/node": "npm:^20.19.0" compare-versions: "npm:^6.1.1" eslint: "npm:^9.39.2" prettier: "npm:^3.5.3" tsup: "npm:^8.5.0" typescript: "npm:5.5.4" + vitest: "npm:^3.2.4" languageName: unknown linkType: soft From 251a766f015ab871e1fa5e9b902fab45799538a7 Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Tue, 9 Jun 2026 10:49:32 +1200 Subject: [PATCH 02/16] Shuffle permission decoding logic --- .../{ => caveats}/erc20TokenAllowance.ts | 10 +- .../{ => caveats}/erc20TokenPeriodic.ts | 10 +- .../{ => caveats}/erc20TokenRevocation.ts | 8 +- .../{ => caveats}/erc20TokenStream.ts | 10 +- .../{ => caveats}/nativeTokenAllowance.ts | 10 +- .../{ => caveats}/nativeTokenPeriodic.ts | 10 +- .../{ => caveats}/nativeTokenStream.ts | 10 +- .../{ => caveats}/tokenApprovalRevocation.ts | 6 +- .../src/permissions/index.ts | 159 +++++++++++++++++- .../src/permissions/nativePayeeRuleDecoder.ts | 56 ------ .../src/permissions/payeeRule.ts | 12 -- .../src/permissions/redeemerRule.ts | 12 -- .../{expiryRule.ts => rules/expiry.ts} | 26 ++- .../payee.ts} | 73 +++++++- .../redeemer.ts} | 16 +- .../caveats}/erc20TokenAllowance.test.ts | 4 +- .../caveats}/erc20TokenPeriodic.test.ts | 4 +- .../caveats}/erc20TokenRevocation.test.ts | 2 +- .../caveats}/erc20TokenStream.test.ts | 4 +- .../caveats}/nativeTokenAllowance.test.ts | 2 +- .../caveats}/nativeTokenPeriodic.test.ts | 4 +- .../caveats}/nativeTokenStream.test.ts | 2 +- .../caveats}/tokenApprovalRevocation.test.ts | 4 +- .../rules/erc20Payee.test.ts} | 6 +- .../rules/expiry.test.ts} | 6 +- .../rules/nativePayee.test.ts} | 6 +- .../rules/redeemer.test.ts} | 6 +- 27 files changed, 318 insertions(+), 160 deletions(-) rename packages/7715-permission-types/src/permissions/{ => caveats}/erc20TokenAllowance.ts (93%) rename packages/7715-permission-types/src/permissions/{ => caveats}/erc20TokenPeriodic.ts (94%) rename packages/7715-permission-types/src/permissions/{ => caveats}/erc20TokenRevocation.ts (94%) rename packages/7715-permission-types/src/permissions/{ => caveats}/erc20TokenStream.ts (94%) rename packages/7715-permission-types/src/permissions/{ => caveats}/nativeTokenAllowance.ts (93%) rename packages/7715-permission-types/src/permissions/{ => caveats}/nativeTokenPeriodic.ts (94%) rename packages/7715-permission-types/src/permissions/{ => caveats}/nativeTokenStream.ts (92%) rename packages/7715-permission-types/src/permissions/{ => caveats}/tokenApprovalRevocation.ts (96%) delete mode 100644 packages/7715-permission-types/src/permissions/nativePayeeRuleDecoder.ts delete mode 100644 packages/7715-permission-types/src/permissions/payeeRule.ts delete mode 100644 packages/7715-permission-types/src/permissions/redeemerRule.ts rename packages/7715-permission-types/src/permissions/{expiryRule.ts => rules/expiry.ts} (55%) rename packages/7715-permission-types/src/permissions/{erc20PayeeRuleDecoder.ts => rules/payee.ts} (52%) rename packages/7715-permission-types/src/permissions/{redeemerRuleDecoder.ts => rules/redeemer.ts} (75%) rename packages/7715-permission-types/test/{ => permissions/caveats}/erc20TokenAllowance.test.ts (98%) rename packages/7715-permission-types/test/{ => permissions/caveats}/erc20TokenPeriodic.test.ts (98%) rename packages/7715-permission-types/test/{ => permissions/caveats}/erc20TokenRevocation.test.ts (99%) rename packages/7715-permission-types/test/{ => permissions/caveats}/erc20TokenStream.test.ts (98%) rename packages/7715-permission-types/test/{ => permissions/caveats}/nativeTokenAllowance.test.ts (98%) rename packages/7715-permission-types/test/{ => permissions/caveats}/nativeTokenPeriodic.test.ts (98%) rename packages/7715-permission-types/test/{ => permissions/caveats}/nativeTokenStream.test.ts (99%) rename packages/7715-permission-types/test/{ => permissions/caveats}/tokenApprovalRevocation.test.ts (96%) rename packages/7715-permission-types/test/{erc20PayeeRule.test.ts => permissions/rules/erc20Payee.test.ts} (94%) rename packages/7715-permission-types/test/{expiryRule.test.ts => permissions/rules/expiry.test.ts} (90%) rename packages/7715-permission-types/test/{nativePayeeRule.test.ts => permissions/rules/nativePayee.test.ts} (94%) rename packages/7715-permission-types/test/{redeemerRule.test.ts => permissions/rules/redeemer.test.ts} (93%) diff --git a/packages/7715-permission-types/src/permissions/erc20TokenAllowance.ts b/packages/7715-permission-types/src/permissions/caveats/erc20TokenAllowance.ts similarity index 93% rename from packages/7715-permission-types/src/permissions/erc20TokenAllowance.ts rename to packages/7715-permission-types/src/permissions/caveats/erc20TokenAllowance.ts index d19d2416..3871d773 100644 --- a/packages/7715-permission-types/src/permissions/erc20TokenAllowance.ts +++ b/packages/7715-permission-types/src/permissions/caveats/erc20TokenAllowance.ts @@ -1,19 +1,19 @@ -import { erc20PayeeRuleDecoder } from './erc20PayeeRuleDecoder'; -import { expiryRule } from './expiryRule'; -import { redeemerRuleDecoder } from './redeemerRuleDecoder'; +import { expiryRule } from '../rules/expiry'; +import { erc20PayeeRuleDecoder } from '../rules/payee'; +import { redeemerRuleDecoder } from '../rules/redeemer'; import type { ChecksumCaveat, ChecksumEnforcersByChainId, DecodedPermission, MakePermissionDecoderConfig, -} from './types'; +} from '../types'; import { getByteLength, getTermsByEnforcer, splitHex, UINT256_MAX, ZERO_32_BYTES, -} from './utils'; +} from '../utils'; /** * Builds the configuration for the erc20-token-allowance permission decoder. diff --git a/packages/7715-permission-types/src/permissions/erc20TokenPeriodic.ts b/packages/7715-permission-types/src/permissions/caveats/erc20TokenPeriodic.ts similarity index 94% rename from packages/7715-permission-types/src/permissions/erc20TokenPeriodic.ts rename to packages/7715-permission-types/src/permissions/caveats/erc20TokenPeriodic.ts index 502dfe8f..a0a7369a 100644 --- a/packages/7715-permission-types/src/permissions/erc20TokenPeriodic.ts +++ b/packages/7715-permission-types/src/permissions/caveats/erc20TokenPeriodic.ts @@ -1,21 +1,21 @@ import { hexToBigInt, hexToNumber } from '@metamask/utils'; -import { erc20PayeeRuleDecoder } from './erc20PayeeRuleDecoder'; -import { expiryRule } from './expiryRule'; -import { redeemerRuleDecoder } from './redeemerRuleDecoder'; +import { expiryRule } from '../rules/expiry'; +import { erc20PayeeRuleDecoder } from '../rules/payee'; +import { redeemerRuleDecoder } from '../rules/redeemer'; import type { ChecksumCaveat, ChecksumEnforcersByChainId, DecodedPermission, MakePermissionDecoderConfig, -} from './types'; +} from '../types'; import { getByteLength, getTermsByEnforcer, MAX_PERIOD_DURATION, splitHex, ZERO_32_BYTES, -} from './utils'; +} from '../utils'; /** * Builds the configuration for the erc20-token-periodic permission decoder. diff --git a/packages/7715-permission-types/src/permissions/erc20TokenRevocation.ts b/packages/7715-permission-types/src/permissions/caveats/erc20TokenRevocation.ts similarity index 94% rename from packages/7715-permission-types/src/permissions/erc20TokenRevocation.ts rename to packages/7715-permission-types/src/permissions/caveats/erc20TokenRevocation.ts index 64f1bd9d..f505f116 100644 --- a/packages/7715-permission-types/src/permissions/erc20TokenRevocation.ts +++ b/packages/7715-permission-types/src/permissions/caveats/erc20TokenRevocation.ts @@ -1,17 +1,17 @@ -import { expiryRule } from './expiryRule'; -import { redeemerRuleDecoder } from './redeemerRuleDecoder'; +import { expiryRule } from '../rules/expiry'; +import { redeemerRuleDecoder } from '../rules/redeemer'; import type { ChecksumCaveat, ChecksumEnforcersByChainId, DecodedPermission, MakePermissionDecoderConfig, -} from './types'; +} from '../types'; import { ERC20_APPROVE_SELECTOR_TERMS, ERC20_APPROVE_ZERO_AMOUNT_TERMS, getTermsByEnforcer, ZERO_32_BYTES, -} from './utils'; +} from '../utils'; /** * Builds the configuration for the erc20-token-revocation permission decoder. diff --git a/packages/7715-permission-types/src/permissions/erc20TokenStream.ts b/packages/7715-permission-types/src/permissions/caveats/erc20TokenStream.ts similarity index 94% rename from packages/7715-permission-types/src/permissions/erc20TokenStream.ts rename to packages/7715-permission-types/src/permissions/caveats/erc20TokenStream.ts index 9fa6935f..18858ab2 100644 --- a/packages/7715-permission-types/src/permissions/erc20TokenStream.ts +++ b/packages/7715-permission-types/src/permissions/caveats/erc20TokenStream.ts @@ -1,20 +1,20 @@ import { hexToBigInt, hexToNumber } from '@metamask/utils'; -import { erc20PayeeRuleDecoder } from './erc20PayeeRuleDecoder'; -import { expiryRule } from './expiryRule'; -import { redeemerRuleDecoder } from './redeemerRuleDecoder'; +import { expiryRule } from '../rules/expiry'; +import { erc20PayeeRuleDecoder } from '../rules/payee'; +import { redeemerRuleDecoder } from '../rules/redeemer'; import type { ChecksumCaveat, ChecksumEnforcersByChainId, DecodedPermission, MakePermissionDecoderConfig, -} from './types'; +} from '../types'; import { getByteLength, getTermsByEnforcer, splitHex, ZERO_32_BYTES, -} from './utils'; +} from '../utils'; /** * Builds the configuration for the erc20-token-stream permission decoder. diff --git a/packages/7715-permission-types/src/permissions/nativeTokenAllowance.ts b/packages/7715-permission-types/src/permissions/caveats/nativeTokenAllowance.ts similarity index 93% rename from packages/7715-permission-types/src/permissions/nativeTokenAllowance.ts rename to packages/7715-permission-types/src/permissions/caveats/nativeTokenAllowance.ts index f8ef9f17..35d0f228 100644 --- a/packages/7715-permission-types/src/permissions/nativeTokenAllowance.ts +++ b/packages/7715-permission-types/src/permissions/caveats/nativeTokenAllowance.ts @@ -1,21 +1,21 @@ import { hexToNumber } from '@metamask/utils'; -import { expiryRule } from './expiryRule'; -import { nativePayeeRuleDecoder } from './nativePayeeRuleDecoder'; -import { redeemerRuleDecoder } from './redeemerRuleDecoder'; +import { expiryRule } from '../rules/expiry'; +import { nativePayeeRuleDecoder } from '../rules/payee'; +import { redeemerRuleDecoder } from '../rules/redeemer'; import type { ChecksumCaveat, ChecksumEnforcersByChainId, DecodedPermission, MakePermissionDecoderConfig, -} from './types'; +} from '../types'; import { getByteLength, getTermsByEnforcer, splitHex, UINT256_MAX, ZERO_32_BYTES, -} from './utils'; +} from '../utils'; /** * Builds the configuration for the native-token-allowance permission decoder. diff --git a/packages/7715-permission-types/src/permissions/nativeTokenPeriodic.ts b/packages/7715-permission-types/src/permissions/caveats/nativeTokenPeriodic.ts similarity index 94% rename from packages/7715-permission-types/src/permissions/nativeTokenPeriodic.ts rename to packages/7715-permission-types/src/permissions/caveats/nativeTokenPeriodic.ts index 3d5c3f48..830f0015 100644 --- a/packages/7715-permission-types/src/permissions/nativeTokenPeriodic.ts +++ b/packages/7715-permission-types/src/permissions/caveats/nativeTokenPeriodic.ts @@ -1,20 +1,20 @@ import { hexToBigInt, hexToNumber } from '@metamask/utils'; -import { expiryRule } from './expiryRule'; -import { nativePayeeRuleDecoder } from './nativePayeeRuleDecoder'; -import { redeemerRuleDecoder } from './redeemerRuleDecoder'; +import { expiryRule } from '../rules/expiry'; +import { nativePayeeRuleDecoder } from '../rules/payee'; +import { redeemerRuleDecoder } from '../rules/redeemer'; import type { ChecksumCaveat, ChecksumEnforcersByChainId, DecodedPermission, MakePermissionDecoderConfig, -} from './types'; +} from '../types'; import { getByteLength, getTermsByEnforcer, MAX_PERIOD_DURATION, splitHex, -} from './utils'; +} from '../utils'; /** * Builds the configuration for the native-token-periodic permission decoder. diff --git a/packages/7715-permission-types/src/permissions/nativeTokenStream.ts b/packages/7715-permission-types/src/permissions/caveats/nativeTokenStream.ts similarity index 92% rename from packages/7715-permission-types/src/permissions/nativeTokenStream.ts rename to packages/7715-permission-types/src/permissions/caveats/nativeTokenStream.ts index a29ee3ec..9ff7797b 100644 --- a/packages/7715-permission-types/src/permissions/nativeTokenStream.ts +++ b/packages/7715-permission-types/src/permissions/caveats/nativeTokenStream.ts @@ -1,15 +1,15 @@ import { hexToBigInt, hexToNumber } from '@metamask/utils'; -import { expiryRule } from './expiryRule'; -import { nativePayeeRuleDecoder } from './nativePayeeRuleDecoder'; -import { redeemerRuleDecoder } from './redeemerRuleDecoder'; +import { expiryRule } from '../rules/expiry'; +import { nativePayeeRuleDecoder } from '../rules/payee'; +import { redeemerRuleDecoder } from '../rules/redeemer'; import type { ChecksumCaveat, ChecksumEnforcersByChainId, DecodedPermission, MakePermissionDecoderConfig, -} from './types'; -import { getByteLength, getTermsByEnforcer, splitHex } from './utils'; +} from '../types'; +import { getByteLength, getTermsByEnforcer, splitHex } from '../utils'; /** * Builds the configuration for the native-token-stream permission decoder. diff --git a/packages/7715-permission-types/src/permissions/tokenApprovalRevocation.ts b/packages/7715-permission-types/src/permissions/caveats/tokenApprovalRevocation.ts similarity index 96% rename from packages/7715-permission-types/src/permissions/tokenApprovalRevocation.ts rename to packages/7715-permission-types/src/permissions/caveats/tokenApprovalRevocation.ts index 689b0805..68ba15e9 100644 --- a/packages/7715-permission-types/src/permissions/tokenApprovalRevocation.ts +++ b/packages/7715-permission-types/src/permissions/caveats/tokenApprovalRevocation.ts @@ -1,14 +1,14 @@ /* eslint-disable no-bitwise */ import { hexToNumber } from '@metamask/utils'; -import { expiryRule } from './expiryRule'; +import { expiryRule } from '../rules/expiry'; import type { ChecksumCaveat, ChecksumEnforcersByChainId, DecodedPermission, MakePermissionDecoderConfig, -} from './types'; -import { getTermsByEnforcer } from './utils'; +} from '../types'; +import { getTermsByEnforcer } from '../utils'; enum ApprovalRevocationFlag { Erc20Approve = 0x01, diff --git a/packages/7715-permission-types/src/permissions/index.ts b/packages/7715-permission-types/src/permissions/index.ts index d16a79a8..9575388b 100644 --- a/packages/7715-permission-types/src/permissions/index.ts +++ b/packages/7715-permission-types/src/permissions/index.ts @@ -1,12 +1,21 @@ -import { makeErc20TokenAllowanceDecoderConfig } from './erc20TokenAllowance'; -import { makeErc20TokenPeriodicDecoderConfig } from './erc20TokenPeriodic'; -import { makeErc20TokenRevocationDecoderConfig } from './erc20TokenRevocation'; -import { makeErc20TokenStreamDecoderConfig } from './erc20TokenStream'; -import { makeNativeTokenAllowanceDecoderConfig } from './nativeTokenAllowance'; -import { makeNativeTokenPeriodicDecoderConfig } from './nativeTokenPeriodic'; -import { makeNativeTokenStreamDecoderConfig } from './nativeTokenStream'; -import { makeTokenApprovalRevocationDecoderConfig } from './tokenApprovalRevocation'; -import type { DeployedContractsByName, PermissionDecoderConfig } from './types'; +import { getChecksumAddress } from '@metamask/utils'; + +import { makeErc20TokenAllowanceDecoderConfig } from './caveats/erc20TokenAllowance'; +import { makeErc20TokenPeriodicDecoderConfig } from './caveats/erc20TokenPeriodic'; +import { makeErc20TokenRevocationDecoderConfig } from './caveats/erc20TokenRevocation'; +import { makeErc20TokenStreamDecoderConfig } from './caveats/erc20TokenStream'; +import { makeNativeTokenAllowanceDecoderConfig } from './caveats/nativeTokenAllowance'; +import { makeNativeTokenPeriodicDecoderConfig } from './caveats/nativeTokenPeriodic'; +import { makeNativeTokenStreamDecoderConfig } from './caveats/nativeTokenStream'; +import { makeTokenApprovalRevocationDecoderConfig } from './caveats/tokenApprovalRevocation'; +import type { + Caveat, + DeployedContractsByName, + PermissionDecoder, + PermissionDecoderConfig, + ValidateAndDecodeResult, +} from './types'; +import type { Hex } from '../types'; import { getChecksumEnforcersByChainId } from './utils'; export type { @@ -16,8 +25,40 @@ export type { DeployedContractsByName, DecodedPermission, MakePermissionDecoderConfig, + PermissionDecoder, + PermissionDecoderConfig, + PermissionDecoderSpec, + PermissionType, + RuleDecoder, + ValidateAndDecodeResult, } from './types'; +export type { ExpiryRule } from './rules/expiry'; +export type { PayeeRule } from './rules/payee'; +export type { RedeemerRule } from './rules/redeemer'; + +export { + EXECUTION_PERMISSION_EXPIRY_RULE_TYPE, + expiryRule, +} from './rules/expiry'; +export { + EXECUTION_PERMISSION_PAYEE_RULE_TYPE, + erc20PayeeRuleDecoder, + nativePayeeRuleDecoder, +} from './rules/payee'; +export { + EXECUTION_PERMISSION_REDEEMER_RULE_TYPE, + redeemerRuleDecoder, +} from './rules/redeemer'; +export { makeNativeTokenStreamDecoderConfig } from './caveats/nativeTokenStream'; +export { makeNativeTokenPeriodicDecoderConfig } from './caveats/nativeTokenPeriodic'; +export { makeNativeTokenAllowanceDecoderConfig } from './caveats/nativeTokenAllowance'; +export { makeErc20TokenStreamDecoderConfig } from './caveats/erc20TokenStream'; +export { makeErc20TokenPeriodicDecoderConfig } from './caveats/erc20TokenPeriodic'; +export { makeErc20TokenAllowanceDecoderConfig } from './caveats/erc20TokenAllowance'; +export { makeErc20TokenRevocationDecoderConfig } from './caveats/erc20TokenRevocation'; +export { makeTokenApprovalRevocationDecoderConfig } from './caveats/tokenApprovalRevocation'; + /** * Builds the canonical set of permission decoders for a chain. * @@ -40,3 +81,103 @@ export const makePermissionDecoderConfigs = ( makeTokenApprovalRevocationDecoderConfig(contractAddresses), ]; }; + +/** + * Creates a runtime decoder from one permission decoder configuration. + * + * @param config - Permission decoder configuration. + * @returns Permission decoder. + */ +export const makePermissionDecoder = ( + config: PermissionDecoderConfig, +): PermissionDecoder => { + const requiredEnforcers = new Map( + Object.entries(config.requiredEnforcers) as [Hex, number][], + ); + const optionalEnforcers = new Set(config.optionalEnforcers); + + const caveatAddressesMatch = (caveatAddresses: Hex[]): boolean => { + const counts = new Map(); + + for (const address of caveatAddresses) { + counts.set(address, (counts.get(address) ?? 0) + 1); + } + for (const [address, count] of counts) { + const maxAllowedCount = + requiredEnforcers.get(address) ?? + (optionalEnforcers.has(address) ? 1 : 0); + if (maxAllowedCount === 0 || count > maxAllowedCount) { + return false; + } + } + + return true; + }; + + const validateAndDecodePermission = ( + caveats: Caveat[], + ): ValidateAndDecodeResult => { + try { + const normalizedCaveats = caveats.map((caveat) => ({ + ...caveat, + enforcer: getChecksumAddress(caveat.enforcer), + })); + + const caveatAddresses = normalizedCaveats.map( + (caveat) => caveat.enforcer, + ); + + if (!caveatAddressesMatch(caveatAddresses)) { + throw new Error('Invalid caveats'); + } + + const data = config.validateAndDecodeData( + normalizedCaveats, + config.contractAddresses, + ); + const rules = config.rules + .map((decodeRule) => + decodeRule({ + contractAddresses: config.contractAddresses, + caveats: normalizedCaveats, + requiredEnforcers, + }), + ) + .filter((rule) => rule !== null); + const expiryRule = rules.find((rule) => rule.type === 'expiry'); + + return { + isValid: true, + expiry: + expiryRule?.type === 'expiry' ? expiryRule.data.timestamp : null, + data, + rules: rules.length > 0 ? rules : undefined, + }; + } catch (error) { + return { + isValid: false, + error: error instanceof Error ? error : new Error('Invalid caveats'), + }; + } + }; + + return { + permissionType: config.permissionType, + requiredEnforcers, + optionalEnforcers, + caveatAddressesMatch, + validateAndDecodePermission, + }; +}; + +/** + * Creates permission decoders for all supported permission types. + * + * @param contracts - Deployed delegation framework contracts for one chain. + * @returns Runtime permission decoders. + */ +export const createPermissionDecodersForContracts = ( + contracts: DeployedContractsByName, +): PermissionDecoder[] => { + return makePermissionDecoderConfigs(contracts).map(makePermissionDecoder); +}; diff --git a/packages/7715-permission-types/src/permissions/nativePayeeRuleDecoder.ts b/packages/7715-permission-types/src/permissions/nativePayeeRuleDecoder.ts deleted file mode 100644 index 3f8c146f..00000000 --- a/packages/7715-permission-types/src/permissions/nativePayeeRuleDecoder.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { decodeAllowedTargetsTerms } from '@metamask/delegation-core'; -import { getChecksumAddress } from '@metamask/utils'; - -import { EXECUTION_PERMISSION_PAYEE_RULE_TYPE } from './erc20PayeeRuleDecoder'; -import type { RuleDecoder } from './types'; - -/** - * Rule decoder for native-token style payees from AllowedTargetsEnforcer caveats. - * - * @param options0 - Rule decoder arguments. - * @param options0.contractAddresses - Checksummed enforcer addresses for the chain. - * @param options0.caveats - Checksummed caveats from the delegation. - * @param options0.requiredEnforcers - Required enforcer counts for this decoder. - * @returns The decoded payee rule when present, otherwise `null`. - */ -export const nativePayeeRuleDecoder: RuleDecoder = ({ - contractAddresses, - caveats, - requiredEnforcers, -}) => { - const { allowedTargetsEnforcer } = contractAddresses; - - if (requiredEnforcers.has(allowedTargetsEnforcer)) { - throw new Error( - 'Invalid payee caveats: payee enforcer may not be a required caveat', - ); - } - - const matchingCaveats = caveats.filter( - (caveat) => caveat.enforcer === allowedTargetsEnforcer, - ); - - if (matchingCaveats.length === 0) { - return null; - } - - if (matchingCaveats.length > 1) { - throw new Error( - 'Invalid payee caveats: multiple AllowedTargetsEnforcer caveats', - ); - } - - const [caveat] = matchingCaveats; - if (!caveat) { - throw new Error( - 'Invalid payee caveats: multiple AllowedTargetsEnforcer caveats', - ); - } - - const decoded = decodeAllowedTargetsTerms(caveat.terms); - - return { - type: EXECUTION_PERMISSION_PAYEE_RULE_TYPE, - data: { addresses: decoded.targets.map(getChecksumAddress) }, - }; -}; diff --git a/packages/7715-permission-types/src/permissions/payeeRule.ts b/packages/7715-permission-types/src/permissions/payeeRule.ts deleted file mode 100644 index bbe80ca1..00000000 --- a/packages/7715-permission-types/src/permissions/payeeRule.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { Hex } from '../types'; - -/** - * Execution permission rule restricting which addresses may receive payments - * (on-chain AllowedCalldataEnforcer / AllowedTargetsEnforcer caveat). - */ -export type PayeeRule = { - type: 'payee'; - data: { - addresses: Hex[]; - }; -}; diff --git a/packages/7715-permission-types/src/permissions/redeemerRule.ts b/packages/7715-permission-types/src/permissions/redeemerRule.ts deleted file mode 100644 index 3f743bf7..00000000 --- a/packages/7715-permission-types/src/permissions/redeemerRule.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { Hex } from '../types'; - -/** - * Execution permission rule restricting which addresses may redeem the delegation - * (on-chain RedeemerEnforcer caveat). - */ -export type RedeemerRule = { - type: 'redeemer'; - data: { - addresses: Hex[]; - }; -}; diff --git a/packages/7715-permission-types/src/permissions/expiryRule.ts b/packages/7715-permission-types/src/permissions/rules/expiry.ts similarity index 55% rename from packages/7715-permission-types/src/permissions/expiryRule.ts rename to packages/7715-permission-types/src/permissions/rules/expiry.ts index 6ab03231..8e3ae0de 100644 --- a/packages/7715-permission-types/src/permissions/expiryRule.ts +++ b/packages/7715-permission-types/src/permissions/rules/expiry.ts @@ -1,5 +1,7 @@ -import type { RuleDecoder } from './types'; -import { extractExpiryFromCaveatTerms, getTermsByEnforcer } from './utils'; +import { decodeTimestampTerms } from '@metamask/delegation-core'; + +import type { RuleDecoder } from '../types'; +import { getTermsByEnforcer } from '../utils'; export const EXECUTION_PERMISSION_EXPIRY_RULE_TYPE = 'expiry' as const; @@ -35,8 +37,26 @@ export const expiryRule: RuleDecoder = ({ contractAddresses, caveats }) => { return null; } + if (expiryTerms.length !== 66) { + throw new Error('Invalid TimestampEnforcer terms length'); + } + + const decodedTerms = decodeTimestampTerms(expiryTerms); + const timestampBeforeThreshold = Number(decodedTerms.beforeThreshold); + const timestampAfterThreshold = Number(decodedTerms.afterThreshold); + + if (timestampBeforeThreshold <= 0) { + throw new Error( + 'Invalid expiry: timestampBeforeThreshold must be greater than 0', + ); + } + + if (timestampAfterThreshold !== 0) { + throw new Error('Invalid expiry: timestampAfterThreshold must be 0'); + } + return { type: EXECUTION_PERMISSION_EXPIRY_RULE_TYPE, - data: { timestamp: extractExpiryFromCaveatTerms(expiryTerms) }, + data: { timestamp: timestampBeforeThreshold }, }; }; diff --git a/packages/7715-permission-types/src/permissions/erc20PayeeRuleDecoder.ts b/packages/7715-permission-types/src/permissions/rules/payee.ts similarity index 52% rename from packages/7715-permission-types/src/permissions/erc20PayeeRuleDecoder.ts rename to packages/7715-permission-types/src/permissions/rules/payee.ts index d3805085..e2e8ed68 100644 --- a/packages/7715-permission-types/src/permissions/erc20PayeeRuleDecoder.ts +++ b/packages/7715-permission-types/src/permissions/rules/payee.ts @@ -1,15 +1,29 @@ -import { decodeAllowedCalldataTerms } from '@metamask/delegation-core'; +import { + decodeAllowedCalldataTerms, + decodeAllowedTargetsTerms, +} from '@metamask/delegation-core'; import { getChecksumAddress } from '@metamask/utils'; -import type { RuleDecoder } from './types'; -import type { Hex } from '../types'; -import { getByteLength } from './utils'; +import type { Hex } from '../../types'; +import type { RuleDecoder } from '../types'; +import { getByteLength } from '../utils'; export const EXECUTION_PERMISSION_PAYEE_RULE_TYPE = 'payee' as const; const ERC20_TRANSFER_PAYEE_START_INDEX = 4; const ERC20_PAYEE_VALUE_BYTE_LENGTH = 32; +/** + * Execution permission rule restricting which addresses may receive payments + * (on-chain AllowedCalldataEnforcer / AllowedTargetsEnforcer caveat). + */ +export type PayeeRule = { + type: 'payee'; + data: { + addresses: Hex[]; + }; +}; + /** * Rule decoder for ERC-20 style payees from AllowedCalldataEnforcer caveats. * @@ -74,3 +88,54 @@ export const erc20PayeeRuleDecoder: RuleDecoder = ({ data: { addresses: [getChecksumAddress(address)] }, }; }; + +/** + * Rule decoder for native-token style payees from AllowedTargetsEnforcer caveats. + * + * @param options0 - Rule decoder arguments. + * @param options0.contractAddresses - Checksummed enforcer addresses for the chain. + * @param options0.caveats - Checksummed caveats from the delegation. + * @param options0.requiredEnforcers - Required enforcer counts for this decoder. + * @returns The decoded payee rule when present, otherwise `null`. + */ +export const nativePayeeRuleDecoder: RuleDecoder = ({ + contractAddresses, + caveats, + requiredEnforcers, +}) => { + const { allowedTargetsEnforcer } = contractAddresses; + + if (requiredEnforcers.has(allowedTargetsEnforcer)) { + throw new Error( + 'Invalid payee caveats: payee enforcer may not be a required caveat', + ); + } + + const matchingCaveats = caveats.filter( + (caveat) => caveat.enforcer === allowedTargetsEnforcer, + ); + + if (matchingCaveats.length === 0) { + return null; + } + + if (matchingCaveats.length > 1) { + throw new Error( + 'Invalid payee caveats: multiple AllowedTargetsEnforcer caveats', + ); + } + + const [caveat] = matchingCaveats; + if (!caveat) { + throw new Error( + 'Invalid payee caveats: multiple AllowedTargetsEnforcer caveats', + ); + } + + const decoded = decodeAllowedTargetsTerms(caveat.terms); + + return { + type: EXECUTION_PERMISSION_PAYEE_RULE_TYPE, + data: { addresses: decoded.targets.map(getChecksumAddress) }, + }; +}; diff --git a/packages/7715-permission-types/src/permissions/redeemerRuleDecoder.ts b/packages/7715-permission-types/src/permissions/rules/redeemer.ts similarity index 75% rename from packages/7715-permission-types/src/permissions/redeemerRuleDecoder.ts rename to packages/7715-permission-types/src/permissions/rules/redeemer.ts index a38f172c..4099e13f 100644 --- a/packages/7715-permission-types/src/permissions/redeemerRuleDecoder.ts +++ b/packages/7715-permission-types/src/permissions/rules/redeemer.ts @@ -1,11 +1,23 @@ import { decodeRedeemerTerms } from '@metamask/delegation-core'; import { getChecksumAddress } from '@metamask/utils'; -import type { RuleDecoder } from './types'; -import { getTermsByEnforcer } from './utils'; +import type { Hex } from '../../types'; +import type { RuleDecoder } from '../types'; +import { getTermsByEnforcer } from '../utils'; export const EXECUTION_PERMISSION_REDEEMER_RULE_TYPE = 'redeemer' as const; +/** + * Execution permission rule restricting which addresses may redeem the delegation + * (on-chain RedeemerEnforcer caveat). + */ +export type RedeemerRule = { + type: 'redeemer'; + data: { + addresses: Hex[]; + }; +}; + /** * Rule decoder that extracts a redeemer allowlist from a RedeemerEnforcer caveat. * diff --git a/packages/7715-permission-types/test/erc20TokenAllowance.test.ts b/packages/7715-permission-types/test/permissions/caveats/erc20TokenAllowance.test.ts similarity index 98% rename from packages/7715-permission-types/test/erc20TokenAllowance.test.ts rename to packages/7715-permission-types/test/permissions/caveats/erc20TokenAllowance.test.ts index 79433e57..ea52546b 100644 --- a/packages/7715-permission-types/test/erc20TokenAllowance.test.ts +++ b/packages/7715-permission-types/test/permissions/caveats/erc20TokenAllowance.test.ts @@ -5,8 +5,8 @@ import { DELEGATOR_CONTRACTS, } from '@metamask/delegation-deployments'; -import { createPermissionDecodersForContracts } from '../src/permissions'; -import { ZERO_32_BYTES } from '../src/permissions/utils'; +import { createPermissionDecodersForContracts } from '../../../src/permissions'; +import { ZERO_32_BYTES } from '../../../src/permissions/utils'; describe('erc20-token-allowance decoder', () => { const chainId = CHAIN_ID.sepolia; diff --git a/packages/7715-permission-types/test/erc20TokenPeriodic.test.ts b/packages/7715-permission-types/test/permissions/caveats/erc20TokenPeriodic.test.ts similarity index 98% rename from packages/7715-permission-types/test/erc20TokenPeriodic.test.ts rename to packages/7715-permission-types/test/permissions/caveats/erc20TokenPeriodic.test.ts index 1396fea9..03f8d50e 100644 --- a/packages/7715-permission-types/test/erc20TokenPeriodic.test.ts +++ b/packages/7715-permission-types/test/permissions/caveats/erc20TokenPeriodic.test.ts @@ -8,8 +8,8 @@ import { DELEGATOR_CONTRACTS, } from '@metamask/delegation-deployments'; -import { createPermissionDecodersForContracts } from '../src/permissions'; -import { MAX_PERIOD_DURATION, ZERO_32_BYTES } from '../src/permissions/utils'; +import { createPermissionDecodersForContracts } from '../../../src/permissions'; +import { MAX_PERIOD_DURATION, ZERO_32_BYTES } from '../../../src/permissions/utils'; describe('erc20-token-periodic decoder', () => { const chainId = CHAIN_ID.sepolia; diff --git a/packages/7715-permission-types/test/erc20TokenRevocation.test.ts b/packages/7715-permission-types/test/permissions/caveats/erc20TokenRevocation.test.ts similarity index 99% rename from packages/7715-permission-types/test/erc20TokenRevocation.test.ts rename to packages/7715-permission-types/test/permissions/caveats/erc20TokenRevocation.test.ts index 30fa582d..10b66764 100644 --- a/packages/7715-permission-types/test/erc20TokenRevocation.test.ts +++ b/packages/7715-permission-types/test/permissions/caveats/erc20TokenRevocation.test.ts @@ -5,7 +5,7 @@ import { } from '@metamask/delegation-deployments'; import { getChecksumAddress } from '@metamask/utils'; -import { createPermissionDecodersForContracts } from '../src/permissions'; +import { createPermissionDecodersForContracts } from '../../../src/permissions'; describe('erc20-token-revocation decoder', () => { const chainId = CHAIN_ID.sepolia; diff --git a/packages/7715-permission-types/test/erc20TokenStream.test.ts b/packages/7715-permission-types/test/permissions/caveats/erc20TokenStream.test.ts similarity index 98% rename from packages/7715-permission-types/test/erc20TokenStream.test.ts rename to packages/7715-permission-types/test/permissions/caveats/erc20TokenStream.test.ts index 1010bbfb..f4b3e8bc 100644 --- a/packages/7715-permission-types/test/erc20TokenStream.test.ts +++ b/packages/7715-permission-types/test/permissions/caveats/erc20TokenStream.test.ts @@ -8,8 +8,8 @@ import { DELEGATOR_CONTRACTS, } from '@metamask/delegation-deployments'; -import { createPermissionDecodersForContracts } from '../src/permissions'; -import { ZERO_32_BYTES } from '../src/permissions/utils'; +import { createPermissionDecodersForContracts } from '../../../src/permissions'; +import { ZERO_32_BYTES } from '../../../src/permissions/utils'; describe('erc20-token-stream decoder', () => { const chainId = CHAIN_ID.sepolia; diff --git a/packages/7715-permission-types/test/nativeTokenAllowance.test.ts b/packages/7715-permission-types/test/permissions/caveats/nativeTokenAllowance.test.ts similarity index 98% rename from packages/7715-permission-types/test/nativeTokenAllowance.test.ts rename to packages/7715-permission-types/test/permissions/caveats/nativeTokenAllowance.test.ts index b301f139..b58d98f5 100644 --- a/packages/7715-permission-types/test/nativeTokenAllowance.test.ts +++ b/packages/7715-permission-types/test/permissions/caveats/nativeTokenAllowance.test.ts @@ -5,7 +5,7 @@ import { DELEGATOR_CONTRACTS, } from '@metamask/delegation-deployments'; -import { createPermissionDecodersForContracts } from '../src/permissions'; +import { createPermissionDecodersForContracts } from '../../../src/permissions'; describe('native-token-allowance decoder', () => { const chainId = CHAIN_ID.sepolia; diff --git a/packages/7715-permission-types/test/nativeTokenPeriodic.test.ts b/packages/7715-permission-types/test/permissions/caveats/nativeTokenPeriodic.test.ts similarity index 98% rename from packages/7715-permission-types/test/nativeTokenPeriodic.test.ts rename to packages/7715-permission-types/test/permissions/caveats/nativeTokenPeriodic.test.ts index 68f728aa..9d4743f1 100644 --- a/packages/7715-permission-types/test/nativeTokenPeriodic.test.ts +++ b/packages/7715-permission-types/test/permissions/caveats/nativeTokenPeriodic.test.ts @@ -8,8 +8,8 @@ import { DELEGATOR_CONTRACTS, } from '@metamask/delegation-deployments'; -import { createPermissionDecodersForContracts } from '../src/permissions'; -import { MAX_PERIOD_DURATION } from '../src/permissions/utils'; +import { createPermissionDecodersForContracts } from '../../../src/permissions'; +import { MAX_PERIOD_DURATION } from '../../../src/permissions/utils'; describe('native-token-periodic decoder', () => { const chainId = CHAIN_ID.sepolia; diff --git a/packages/7715-permission-types/test/nativeTokenStream.test.ts b/packages/7715-permission-types/test/permissions/caveats/nativeTokenStream.test.ts similarity index 99% rename from packages/7715-permission-types/test/nativeTokenStream.test.ts rename to packages/7715-permission-types/test/permissions/caveats/nativeTokenStream.test.ts index 3a566d64..28ac4f7e 100644 --- a/packages/7715-permission-types/test/nativeTokenStream.test.ts +++ b/packages/7715-permission-types/test/permissions/caveats/nativeTokenStream.test.ts @@ -8,7 +8,7 @@ import { DELEGATOR_CONTRACTS, } from '@metamask/delegation-deployments'; -import { createPermissionDecodersForContracts } from '../src/permissions'; +import { createPermissionDecodersForContracts } from '../../../src/permissions'; describe('native-token-stream decoder', () => { const chainId = CHAIN_ID.sepolia; diff --git a/packages/7715-permission-types/test/tokenApprovalRevocation.test.ts b/packages/7715-permission-types/test/permissions/caveats/tokenApprovalRevocation.test.ts similarity index 96% rename from packages/7715-permission-types/test/tokenApprovalRevocation.test.ts rename to packages/7715-permission-types/test/permissions/caveats/tokenApprovalRevocation.test.ts index eb9edacc..b095349c 100644 --- a/packages/7715-permission-types/test/tokenApprovalRevocation.test.ts +++ b/packages/7715-permission-types/test/permissions/caveats/tokenApprovalRevocation.test.ts @@ -4,8 +4,8 @@ import { DELEGATOR_CONTRACTS, } from '@metamask/delegation-deployments'; -import { createPermissionDecodersForContracts } from '../src/permissions'; -import { getChecksumEnforcersByChainId } from '../src/permissions/utils'; +import { createPermissionDecodersForContracts } from '../../../src/permissions'; +import { getChecksumEnforcersByChainId } from '../../../src/permissions/utils'; describe('token-approval-revocation decoder', () => { const chainId = CHAIN_ID.sepolia; diff --git a/packages/7715-permission-types/test/erc20PayeeRule.test.ts b/packages/7715-permission-types/test/permissions/rules/erc20Payee.test.ts similarity index 94% rename from packages/7715-permission-types/test/erc20PayeeRule.test.ts rename to packages/7715-permission-types/test/permissions/rules/erc20Payee.test.ts index 84f193e9..98baf742 100644 --- a/packages/7715-permission-types/test/erc20PayeeRule.test.ts +++ b/packages/7715-permission-types/test/permissions/rules/erc20Payee.test.ts @@ -6,9 +6,9 @@ import { import { getChecksumAddress } from '@metamask/utils'; import type { Hex } from '@metamask/utils'; -import { erc20PayeeRuleDecoder } from '../src/permissions/erc20PayeeRuleDecoder'; -import type { ChecksumCaveat } from '../src/permissions/types'; -import { getChecksumEnforcersByChainId } from '../src/permissions/utils'; +import { erc20PayeeRuleDecoder } from '../../../src/permissions/rules/payee'; +import type { ChecksumCaveat } from '../../../src/permissions/types'; +import { getChecksumEnforcersByChainId } from '../../../src/permissions/utils'; describe('erc20PayeeRuleDecoder', () => { const contracts = DELEGATOR_CONTRACTS['1.3.0'][CHAIN_ID.sepolia]; diff --git a/packages/7715-permission-types/test/expiryRule.test.ts b/packages/7715-permission-types/test/permissions/rules/expiry.test.ts similarity index 90% rename from packages/7715-permission-types/test/expiryRule.test.ts rename to packages/7715-permission-types/test/permissions/rules/expiry.test.ts index aecc371c..18f3434b 100644 --- a/packages/7715-permission-types/test/expiryRule.test.ts +++ b/packages/7715-permission-types/test/permissions/rules/expiry.test.ts @@ -5,9 +5,9 @@ import { } from '@metamask/delegation-deployments'; import type { Hex } from '@metamask/utils'; -import { expiryRule } from '../src/permissions/expiryRule'; -import type { ChecksumCaveat } from '../src/permissions/types'; -import { getChecksumEnforcersByChainId } from '../src/permissions/utils'; +import { expiryRule } from '../../../src/permissions/rules/expiry'; +import type { ChecksumCaveat } from '../../../src/permissions/types'; +import { getChecksumEnforcersByChainId } from '../../../src/permissions/utils'; describe('expiryRule', () => { const contracts = DELEGATOR_CONTRACTS['1.3.0'][CHAIN_ID.sepolia]; diff --git a/packages/7715-permission-types/test/nativePayeeRule.test.ts b/packages/7715-permission-types/test/permissions/rules/nativePayee.test.ts similarity index 94% rename from packages/7715-permission-types/test/nativePayeeRule.test.ts rename to packages/7715-permission-types/test/permissions/rules/nativePayee.test.ts index 2f0faed5..65dce57f 100644 --- a/packages/7715-permission-types/test/nativePayeeRule.test.ts +++ b/packages/7715-permission-types/test/permissions/rules/nativePayee.test.ts @@ -6,9 +6,9 @@ import { import { getChecksumAddress } from '@metamask/utils'; import type { Hex } from '@metamask/utils'; -import { nativePayeeRuleDecoder } from '../src/permissions/nativePayeeRuleDecoder'; -import type { ChecksumCaveat } from '../src/permissions/types'; -import { getChecksumEnforcersByChainId } from '../src/permissions/utils'; +import { nativePayeeRuleDecoder } from '../../../src/permissions/rules/payee'; +import type { ChecksumCaveat } from '../../../src/permissions/types'; +import { getChecksumEnforcersByChainId } from '../../../src/permissions/utils'; describe('nativePayeeRuleDecoder', () => { const contracts = DELEGATOR_CONTRACTS['1.3.0'][CHAIN_ID.sepolia]; diff --git a/packages/7715-permission-types/test/redeemerRule.test.ts b/packages/7715-permission-types/test/permissions/rules/redeemer.test.ts similarity index 93% rename from packages/7715-permission-types/test/redeemerRule.test.ts rename to packages/7715-permission-types/test/permissions/rules/redeemer.test.ts index a60bc527..aa80df2e 100644 --- a/packages/7715-permission-types/test/redeemerRule.test.ts +++ b/packages/7715-permission-types/test/permissions/rules/redeemer.test.ts @@ -6,9 +6,9 @@ import { import { getChecksumAddress } from '@metamask/utils'; import type { Hex } from '@metamask/utils'; -import { redeemerRuleDecoder } from '../src/permissions/redeemerRuleDecoder'; -import type { ChecksumCaveat } from '../src/permissions/types'; -import { getChecksumEnforcersByChainId } from '../src/permissions/utils'; +import { redeemerRuleDecoder } from '../../../src/permissions/rules/redeemer'; +import type { ChecksumCaveat } from '../../../src/permissions/types'; +import { getChecksumEnforcersByChainId } from '../../../src/permissions/utils'; describe('redeemerRuleDecoder', () => { const contracts = DELEGATOR_CONTRACTS['1.3.0'][CHAIN_ID.sepolia]; From 2c4a780e5121febebb18faba6067f2940986a080 Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Tue, 9 Jun 2026 11:01:08 +1200 Subject: [PATCH 03/16] Switch to use terms decoders from @metamask/delegation-core --- packages/7715-permission-types/src/index.ts | 1 - .../permissions/caveats/erc20TokenPeriodic.ts | 27 +- .../caveats/erc20TokenRevocation.ts | 94 ------ .../permissions/caveats/erc20TokenStream.ts | 43 +-- .../caveats/nativeTokenPeriodic.ts | 34 +-- .../permissions/caveats/nativeTokenStream.ts | 29 +- .../caveats/tokenApprovalRevocation.ts | 75 +---- .../src/permissions/index.ts | 3 - .../caveats/erc20TokenPeriodic.test.ts | 9 +- .../caveats/erc20TokenRevocation.test.ts | 286 ------------------ .../caveats/erc20TokenStream.test.ts | 4 +- .../caveats/nativeTokenPeriodic.test.ts | 4 +- .../caveats/nativeTokenStream.test.ts | 4 +- .../caveats/tokenApprovalRevocation.test.ts | 6 +- 14 files changed, 58 insertions(+), 561 deletions(-) delete mode 100644 packages/7715-permission-types/src/permissions/caveats/erc20TokenRevocation.ts delete mode 100644 packages/7715-permission-types/test/permissions/caveats/erc20TokenRevocation.test.ts diff --git a/packages/7715-permission-types/src/index.ts b/packages/7715-permission-types/src/index.ts index 0593ccfb..e1c29618 100644 --- a/packages/7715-permission-types/src/index.ts +++ b/packages/7715-permission-types/src/index.ts @@ -54,7 +54,6 @@ export { makeErc20TokenStreamDecoderConfig, makeErc20TokenPeriodicDecoderConfig, makeErc20TokenAllowanceDecoderConfig, - makeErc20TokenRevocationDecoderConfig, makeTokenApprovalRevocationDecoderConfig, createPermissionDecodersForContracts, } from './permissions'; diff --git a/packages/7715-permission-types/src/permissions/caveats/erc20TokenPeriodic.ts b/packages/7715-permission-types/src/permissions/caveats/erc20TokenPeriodic.ts index a0a7369a..ef47872f 100644 --- a/packages/7715-permission-types/src/permissions/caveats/erc20TokenPeriodic.ts +++ b/packages/7715-permission-types/src/permissions/caveats/erc20TokenPeriodic.ts @@ -1,4 +1,4 @@ -import { hexToBigInt, hexToNumber } from '@metamask/utils'; +import { decodeERC20TokenPeriodTransferTerms } from '@metamask/delegation-core'; import { expiryRule } from '../rules/expiry'; import { erc20PayeeRuleDecoder } from '../rules/payee'; @@ -10,10 +10,8 @@ import type { MakePermissionDecoderConfig, } from '../types'; import { - getByteLength, getTermsByEnforcer, MAX_PERIOD_DURATION, - splitHex, ZERO_32_BYTES, } from '../utils'; @@ -79,23 +77,14 @@ function validateAndDecodeData( 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]); - if (!tokenAddress || !periodAmount || !periodDurationRaw || !startTimeRaw) { - throw new Error('Invalid erc20-token-periodic terms'); - } - - const periodDuration = hexToNumber(periodDurationRaw); - const periodAmountBigInt = hexToBigInt(periodAmount); - const startTime = hexToNumber(startTimeRaw); + const { + tokenAddress, + periodAmount, + periodDuration, + startDate: startTime, + } = decodeERC20TokenPeriodTransferTerms(terms); - if (periodAmountBigInt === 0n) { + if (periodAmount === 0n) { throw new Error( 'Invalid erc20-token-periodic terms: periodAmount must be a positive number', ); diff --git a/packages/7715-permission-types/src/permissions/caveats/erc20TokenRevocation.ts b/packages/7715-permission-types/src/permissions/caveats/erc20TokenRevocation.ts deleted file mode 100644 index f505f116..00000000 --- a/packages/7715-permission-types/src/permissions/caveats/erc20TokenRevocation.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { expiryRule } from '../rules/expiry'; -import { redeemerRuleDecoder } from '../rules/redeemer'; -import type { - ChecksumCaveat, - ChecksumEnforcersByChainId, - DecodedPermission, - MakePermissionDecoderConfig, -} from '../types'; -import { - ERC20_APPROVE_SELECTOR_TERMS, - ERC20_APPROVE_ZERO_AMOUNT_TERMS, - getTermsByEnforcer, - ZERO_32_BYTES, -} from '../utils'; - -/** - * Builds the configuration for the erc20-token-revocation permission decoder. - * - * @param contractAddresses - Checksummed enforcer addresses for the chain. - * @returns The erc20-token-revocation permission decoder configuration. - */ -export function makeErc20TokenRevocationDecoderConfig( - contractAddresses: ChecksumEnforcersByChainId, -): MakePermissionDecoderConfig { - const { - timestampEnforcer, - allowedCalldataEnforcer, - valueLteEnforcer, - nonceEnforcer, - redeemerEnforcer, - } = contractAddresses; - - return { - permissionType: 'erc20-token-revocation', - contractAddresses, - optionalEnforcers: [ - timestampEnforcer, // expiry rule - redeemerEnforcer, // redeemer rule - ], - requiredEnforcers: { - [allowedCalldataEnforcer]: 2, - [valueLteEnforcer]: 1, - [nonceEnforcer]: 1, - }, - rules: [expiryRule, redeemerRuleDecoder], - validateAndDecodeData, - }; -} - -/** - * Decodes erc20-token-revocation permission data from caveats; throws on invalid. - * - * @param caveats - Caveats from the permission context (checksummed). - * @param contractAddresses - Checksummed enforcer addresses for the chain. - * @returns An empty object (revocation has no decoded data payload). - */ -function validateAndDecodeData( - caveats: ChecksumCaveat[], - contractAddresses: ChecksumEnforcersByChainId, -): DecodedPermission['permission']['data'] { - const { allowedCalldataEnforcer, valueLteEnforcer } = contractAddresses; - - 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/7715-permission-types/src/permissions/caveats/erc20TokenStream.ts b/packages/7715-permission-types/src/permissions/caveats/erc20TokenStream.ts index 18858ab2..13a0552e 100644 --- a/packages/7715-permission-types/src/permissions/caveats/erc20TokenStream.ts +++ b/packages/7715-permission-types/src/permissions/caveats/erc20TokenStream.ts @@ -1,4 +1,4 @@ -import { hexToBigInt, hexToNumber } from '@metamask/utils'; +import { decodeERC20StreamingTerms } from '@metamask/delegation-core'; import { expiryRule } from '../rules/expiry'; import { erc20PayeeRuleDecoder } from '../rules/payee'; @@ -9,12 +9,7 @@ import type { DecodedPermission, MakePermissionDecoderConfig, } from '../types'; -import { - getByteLength, - getTermsByEnforcer, - splitHex, - ZERO_32_BYTES, -} from '../utils'; +import { getTermsByEnforcer, ZERO_32_BYTES } from '../utils'; /** * Builds the configuration for the erc20-token-stream permission decoder. @@ -78,42 +73,16 @@ function validateAndDecodeData( caveats, enforcer: erc20StreamingEnforcer, }); + const { tokenAddress, initialAmount, maxAmount, amountPerSecond, startTime } = + decodeERC20StreamingTerms(terms); - 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]); - if ( - !tokenAddress || - !initialAmount || - !maxAmount || - !amountPerSecond || - !startTimeRaw - ) { - throw new Error('Invalid erc20-token-stream terms'); - } - - const startTime = hexToNumber(startTimeRaw); - const initialAmountBigInt = hexToBigInt(initialAmount); - const maxAmountBigInt = hexToBigInt(maxAmount); - const amountPerSecondBigInt = hexToBigInt(amountPerSecond); - - if (maxAmountBigInt <= initialAmountBigInt) { + if (maxAmount <= initialAmount) { throw new Error( 'Invalid erc20-token-stream terms: maxAmount must be greater than initialAmount', ); } - if (amountPerSecondBigInt === 0n) { + if (amountPerSecond === 0n) { throw new Error( 'Invalid erc20-token-stream terms: amountPerSecond must be a positive number', ); diff --git a/packages/7715-permission-types/src/permissions/caveats/nativeTokenPeriodic.ts b/packages/7715-permission-types/src/permissions/caveats/nativeTokenPeriodic.ts index 830f0015..62d7039c 100644 --- a/packages/7715-permission-types/src/permissions/caveats/nativeTokenPeriodic.ts +++ b/packages/7715-permission-types/src/permissions/caveats/nativeTokenPeriodic.ts @@ -1,4 +1,4 @@ -import { hexToBigInt, hexToNumber } from '@metamask/utils'; +import { decodeNativeTokenPeriodTransferTerms } from '@metamask/delegation-core'; import { expiryRule } from '../rules/expiry'; import { nativePayeeRuleDecoder } from '../rules/payee'; @@ -9,12 +9,7 @@ import type { DecodedPermission, MakePermissionDecoderConfig, } from '../types'; -import { - getByteLength, - getTermsByEnforcer, - MAX_PERIOD_DURATION, - splitHex, -} from '../utils'; +import { getTermsByEnforcer, MAX_PERIOD_DURATION } from '../utils'; /** * Builds the configuration for the native-token-periodic permission decoder. @@ -79,26 +74,13 @@ function validateAndDecodeData( caveats, enforcer: nativeTokenPeriodicEnforcer, }); + const { + periodAmount, + periodDuration, + startDate: startTime, + } = decodeNativeTokenPeriodTransferTerms(terms); - 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], - ); - if (!periodAmount || !periodDurationRaw || !startTimeRaw) { - throw new Error('Invalid native-token-periodic terms'); - } - - const periodDuration = hexToNumber(periodDurationRaw); - const startTime = hexToNumber(startTimeRaw); - const periodAmountBigInt = hexToBigInt(periodAmount); - - if (periodAmountBigInt === 0n) { + if (periodAmount === 0n) { throw new Error( 'Invalid native-token-periodic terms: periodAmount must be a positive number', ); diff --git a/packages/7715-permission-types/src/permissions/caveats/nativeTokenStream.ts b/packages/7715-permission-types/src/permissions/caveats/nativeTokenStream.ts index 9ff7797b..28314f38 100644 --- a/packages/7715-permission-types/src/permissions/caveats/nativeTokenStream.ts +++ b/packages/7715-permission-types/src/permissions/caveats/nativeTokenStream.ts @@ -1,4 +1,4 @@ -import { hexToBigInt, hexToNumber } from '@metamask/utils'; +import { decodeNativeTokenStreamingTerms } from '@metamask/delegation-core'; import { expiryRule } from '../rules/expiry'; import { nativePayeeRuleDecoder } from '../rules/payee'; @@ -9,7 +9,7 @@ import type { DecodedPermission, MakePermissionDecoderConfig, } from '../types'; -import { getByteLength, getTermsByEnforcer, splitHex } from '../utils'; +import { getTermsByEnforcer } from '../utils'; /** * Builds the configuration for the native-token-stream permission decoder. @@ -74,33 +74,16 @@ function validateAndDecodeData( caveats, enforcer: nativeTokenStreamingEnforcer, }); + const { initialAmount, maxAmount, amountPerSecond, startTime } = + decodeNativeTokenStreamingTerms(terms); - 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], - ); - if (!initialAmount || !maxAmount || !amountPerSecond || !startTimeRaw) { - throw new Error('Invalid native-token-stream terms'); - } - - const initialAmountBigInt = hexToBigInt(initialAmount); - const maxAmountBigInt = hexToBigInt(maxAmount); - const amountPerSecondBigInt = hexToBigInt(amountPerSecond); - const startTime = hexToNumber(startTimeRaw); - - if (maxAmountBigInt <= initialAmountBigInt) { + if (maxAmount <= initialAmount) { throw new Error( 'Invalid native-token-stream terms: maxAmount must be greater than initialAmount', ); } - if (amountPerSecondBigInt === 0n) { + if (amountPerSecond === 0n) { throw new Error( 'Invalid native-token-stream terms: amountPerSecond must be a positive number', ); diff --git a/packages/7715-permission-types/src/permissions/caveats/tokenApprovalRevocation.ts b/packages/7715-permission-types/src/permissions/caveats/tokenApprovalRevocation.ts index 68ba15e9..85530849 100644 --- a/packages/7715-permission-types/src/permissions/caveats/tokenApprovalRevocation.ts +++ b/packages/7715-permission-types/src/permissions/caveats/tokenApprovalRevocation.ts @@ -1,5 +1,4 @@ -/* eslint-disable no-bitwise */ -import { hexToNumber } from '@metamask/utils'; +import { decodeApprovalRevocationTerms } from '@metamask/delegation-core'; import { expiryRule } from '../rules/expiry'; import type { @@ -10,23 +9,6 @@ import type { } from '../types'; import { getTermsByEnforcer } from '../utils'; -enum ApprovalRevocationFlag { - Erc20Approve = 0x01, - Erc721Approve = 0x02, - Erc721SetApprovalForAll = 0x04, - Permit2Approve = 0x08, - Permit2Lockdown = 0x10, - Permit2InvalidateNonces = 0x20, -} - -const MAX_APPROVAL_REVOCATION_MASK = - ApprovalRevocationFlag.Permit2InvalidateNonces | - ApprovalRevocationFlag.Permit2Lockdown | - ApprovalRevocationFlag.Permit2Approve | - ApprovalRevocationFlag.Erc721SetApprovalForAll | - ApprovalRevocationFlag.Erc721Approve | - ApprovalRevocationFlag.Erc20Approve; - /** * Builds the configuration for the token-approval-revocation permission decoder. * @@ -72,48 +54,21 @@ function validateAndDecodeData( enforcer: approvalRevocationEnforcer, }); - if (terms === '0x') { - throw new Error('Invalid ApprovalRevocation terms: must be greater than 0'); - } - - const mask = hexToNumber(terms); - - if (mask > MAX_APPROVAL_REVOCATION_MASK) { - throw new Error( - `Invalid ApprovalRevocation terms: must be less than or equal to ${MAX_APPROVAL_REVOCATION_MASK}`, - ); - } - - if (mask === 0) { - throw new Error('Invalid ApprovalRevocation terms: must be greater than 0'); - } + const { + erc20Approve, + erc721Approve, + erc721SetApprovalForAll, + permit2Approve, + permit2Lockdown, + permit2InvalidateNonces, + } = decodeApprovalRevocationTerms(terms); return { - erc20Approve: isFlagEnabled(mask, ApprovalRevocationFlag.Erc20Approve), - erc721Approve: isFlagEnabled(mask, ApprovalRevocationFlag.Erc721Approve), - erc721SetApprovalForAll: isFlagEnabled( - mask, - ApprovalRevocationFlag.Erc721SetApprovalForAll, - ), - permit2Approve: isFlagEnabled(mask, ApprovalRevocationFlag.Permit2Approve), - permit2Lockdown: isFlagEnabled( - mask, - ApprovalRevocationFlag.Permit2Lockdown, - ), - permit2InvalidateNonces: isFlagEnabled( - mask, - ApprovalRevocationFlag.Permit2InvalidateNonces, - ), + erc20Approve, + erc721Approve, + erc721SetApprovalForAll, + permit2Approve, + permit2Lockdown, + permit2InvalidateNonces, }; } - -/** - * Returns whether a specific bitwise flag is enabled in the mask. - * - * @param mask - The full bitmask value. - * @param flag - The flag to check. - * @returns `true` if the flag is set, otherwise `false`. - */ -function isFlagEnabled(mask: number, flag: number): boolean { - return (mask & flag) === flag; -} diff --git a/packages/7715-permission-types/src/permissions/index.ts b/packages/7715-permission-types/src/permissions/index.ts index 9575388b..38186533 100644 --- a/packages/7715-permission-types/src/permissions/index.ts +++ b/packages/7715-permission-types/src/permissions/index.ts @@ -2,7 +2,6 @@ import { getChecksumAddress } from '@metamask/utils'; import { makeErc20TokenAllowanceDecoderConfig } from './caveats/erc20TokenAllowance'; import { makeErc20TokenPeriodicDecoderConfig } from './caveats/erc20TokenPeriodic'; -import { makeErc20TokenRevocationDecoderConfig } from './caveats/erc20TokenRevocation'; import { makeErc20TokenStreamDecoderConfig } from './caveats/erc20TokenStream'; import { makeNativeTokenAllowanceDecoderConfig } from './caveats/nativeTokenAllowance'; import { makeNativeTokenPeriodicDecoderConfig } from './caveats/nativeTokenPeriodic'; @@ -56,7 +55,6 @@ export { makeNativeTokenAllowanceDecoderConfig } from './caveats/nativeTokenAllo export { makeErc20TokenStreamDecoderConfig } from './caveats/erc20TokenStream'; export { makeErc20TokenPeriodicDecoderConfig } from './caveats/erc20TokenPeriodic'; export { makeErc20TokenAllowanceDecoderConfig } from './caveats/erc20TokenAllowance'; -export { makeErc20TokenRevocationDecoderConfig } from './caveats/erc20TokenRevocation'; export { makeTokenApprovalRevocationDecoderConfig } from './caveats/tokenApprovalRevocation'; /** @@ -77,7 +75,6 @@ export const makePermissionDecoderConfigs = ( makeErc20TokenStreamDecoderConfig(contractAddresses), makeErc20TokenPeriodicDecoderConfig(contractAddresses), makeErc20TokenAllowanceDecoderConfig(contractAddresses), - makeErc20TokenRevocationDecoderConfig(contractAddresses), makeTokenApprovalRevocationDecoderConfig(contractAddresses), ]; }; diff --git a/packages/7715-permission-types/test/permissions/caveats/erc20TokenPeriodic.test.ts b/packages/7715-permission-types/test/permissions/caveats/erc20TokenPeriodic.test.ts index 03f8d50e..c8892d05 100644 --- a/packages/7715-permission-types/test/permissions/caveats/erc20TokenPeriodic.test.ts +++ b/packages/7715-permission-types/test/permissions/caveats/erc20TokenPeriodic.test.ts @@ -9,7 +9,10 @@ import { } from '@metamask/delegation-deployments'; import { createPermissionDecodersForContracts } from '../../../src/permissions'; -import { MAX_PERIOD_DURATION, ZERO_32_BYTES } from '../../../src/permissions/utils'; +import { + MAX_PERIOD_DURATION, + ZERO_32_BYTES, +} from '../../../src/permissions/utils'; describe('erc20-token-periodic decoder', () => { const chainId = CHAIN_ID.sepolia; @@ -95,7 +98,7 @@ describe('erc20-token-periodic decoder', () => { } expect(result.error.message).toContain( - 'Invalid erc20-token-periodic terms: expected 116 bytes', + 'Invalid ERC20TokenPeriodTransfer terms: must be exactly 116 bytes', ); }); @@ -264,7 +267,7 @@ describe('erc20-token-periodic decoder', () => { } expect(result.error.message).toContain( - 'Invalid erc20-token-periodic terms: expected 116 bytes', + 'Invalid ERC20TokenPeriodTransfer terms: must be exactly 116 bytes', ); }); diff --git a/packages/7715-permission-types/test/permissions/caveats/erc20TokenRevocation.test.ts b/packages/7715-permission-types/test/permissions/caveats/erc20TokenRevocation.test.ts deleted file mode 100644 index 10b66764..00000000 --- a/packages/7715-permission-types/test/permissions/caveats/erc20TokenRevocation.test.ts +++ /dev/null @@ -1,286 +0,0 @@ -import { createTimestampTerms } from '@metamask/delegation-core'; -import { - CHAIN_ID, - DELEGATOR_CONTRACTS, -} from '@metamask/delegation-deployments'; -import { getChecksumAddress } from '@metamask/utils'; - -import { createPermissionDecodersForContracts } from '../../../src/permissions'; - -describe('erc20-token-revocation decoder', () => { - const chainId = CHAIN_ID.sepolia; - const contracts = DELEGATOR_CONTRACTS['1.3.0'][chainId]; - const { - TimestampEnforcer, - AllowedCalldataEnforcer, - ValueLteEnforcer, - RedeemerEnforcer, - } = contracts; - const permissionDecoders = createPermissionDecodersForContracts(contracts); - const decoder = permissionDecoders.find( - (candidate) => candidate.permissionType === 'erc20-token-revocation', - ); - if (!decoder) { - throw new Error('Decoder not found'); - } - - const expiryCaveat = { - enforcer: TimestampEnforcer, - terms: createTimestampTerms({ - afterThreshold: 0, - beforeThreshold: 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 = decoder.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 = decoder.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 = decoder.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 = decoder.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 = decoder.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({}); - expect(result.rules).toStrictEqual([ - { - type: 'expiry', - data: { timestamp: 1720000 }, - }, - ]); - }); - - it('includes redeemer rule but not payee when RedeemerEnforcer caveat is present', () => { - const packedAddr = '1111111111111111111111111111111111111111' as const; - 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: RedeemerEnforcer, - terms: `0x${packedAddr}` as const, - args: '0x' as const, - }, - ]; - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(true); - if (!result.isValid) { - throw new Error('Expected valid result'); - } - expect(result.rules).toStrictEqual([ - { - type: 'expiry', - data: { timestamp: 1720000 }, - }, - { - type: 'redeemer', - data: { - addresses: [ - getChecksumAddress( - '0x1111111111111111111111111111111111111111' as const, - ), - ], - }, - }, - ]); - }); -}); diff --git a/packages/7715-permission-types/test/permissions/caveats/erc20TokenStream.test.ts b/packages/7715-permission-types/test/permissions/caveats/erc20TokenStream.test.ts index f4b3e8bc..e3f0af20 100644 --- a/packages/7715-permission-types/test/permissions/caveats/erc20TokenStream.test.ts +++ b/packages/7715-permission-types/test/permissions/caveats/erc20TokenStream.test.ts @@ -88,7 +88,7 @@ describe('erc20-token-stream decoder', () => { } expect(result.error.message).toContain( - 'Invalid erc20-token-stream terms: expected 148 bytes', + 'Invalid ERC20Streaming terms: must be exactly 148 bytes', ); }); @@ -278,7 +278,7 @@ describe('erc20-token-stream decoder', () => { } expect(result.error.message).toContain( - 'Invalid erc20-token-stream terms: expected 148 bytes', + 'Invalid ERC20Streaming terms: must be exactly 148 bytes', ); }); diff --git a/packages/7715-permission-types/test/permissions/caveats/nativeTokenPeriodic.test.ts b/packages/7715-permission-types/test/permissions/caveats/nativeTokenPeriodic.test.ts index 9d4743f1..430b65ea 100644 --- a/packages/7715-permission-types/test/permissions/caveats/nativeTokenPeriodic.test.ts +++ b/packages/7715-permission-types/test/permissions/caveats/nativeTokenPeriodic.test.ts @@ -98,7 +98,7 @@ describe('native-token-periodic decoder', () => { } expect(result.error.message).toContain( - 'Invalid native-token-periodic terms: expected 96 bytes', + 'Invalid NativeTokenPeriodTransfer terms: must be exactly 96 bytes', ); }); @@ -130,7 +130,7 @@ describe('native-token-periodic decoder', () => { } expect(result.error.message).toContain( - 'Invalid native-token-periodic terms: expected 96 bytes', + 'Invalid NativeTokenPeriodTransfer terms: must be exactly 96 bytes', ); }); diff --git a/packages/7715-permission-types/test/permissions/caveats/nativeTokenStream.test.ts b/packages/7715-permission-types/test/permissions/caveats/nativeTokenStream.test.ts index 28ac4f7e..0cd7b524 100644 --- a/packages/7715-permission-types/test/permissions/caveats/nativeTokenStream.test.ts +++ b/packages/7715-permission-types/test/permissions/caveats/nativeTokenStream.test.ts @@ -132,7 +132,7 @@ describe('native-token-stream decoder', () => { } expect(result.error.message).toContain( - 'Invalid native-token-stream terms: expected 128 bytes', + 'Invalid NativeTokenStreaming terms: must be exactly 128 bytes', ); }); @@ -165,7 +165,7 @@ describe('native-token-stream decoder', () => { } expect(result.error.message).toContain( - 'Invalid native-token-stream terms: expected 128 bytes', + 'Invalid NativeTokenStreaming terms: must be exactly 128 bytes', ); }); diff --git a/packages/7715-permission-types/test/permissions/caveats/tokenApprovalRevocation.test.ts b/packages/7715-permission-types/test/permissions/caveats/tokenApprovalRevocation.test.ts index b095349c..add3bd15 100644 --- a/packages/7715-permission-types/test/permissions/caveats/tokenApprovalRevocation.test.ts +++ b/packages/7715-permission-types/test/permissions/caveats/tokenApprovalRevocation.test.ts @@ -53,7 +53,7 @@ describe('token-approval-revocation decoder', () => { } expect(result.error.message).toContain( - 'Invalid ApprovalRevocation terms: must be greater than 0', + 'Invalid ApprovalRevocation terms: must be exactly 1 byte', ); }); @@ -80,7 +80,7 @@ describe('token-approval-revocation decoder', () => { } expect(result.error.message).toContain( - 'Invalid ApprovalRevocation terms: must be greater than 0', + 'Invalid ApprovalRevocation terms: at least one revocation primitive must be enabled', ); }); @@ -107,7 +107,7 @@ describe('token-approval-revocation decoder', () => { } expect(result.error.message).toContain( - 'Invalid ApprovalRevocation terms: must be less than or equal to 63', + 'Invalid ApprovalRevocation terms: reserved bits must be zero (only bits 0-5 are defined)', ); }); From 1285930f92918e36927fce92b8ffd46c3458efe4 Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Tue, 9 Jun 2026 11:36:01 +1200 Subject: [PATCH 04/16] Remove makePermissionDecoder and update tests to correctly verify configuration objects --- packages/7715-permission-types/src/index.ts | 2 - .../src/permissions/index.ts | 111 +--- .../caveats/erc20TokenAllowance.test.ts | 373 ++++------- .../caveats/erc20TokenPeriodic.test.ts | 520 +++++---------- .../caveats/erc20TokenStream.test.ts | 491 +++++--------- .../caveats/nativeTokenAllowance.test.ts | 369 ++++------- .../caveats/nativeTokenPeriodic.test.ts | 483 +++++--------- .../caveats/nativeTokenStream.test.ts | 600 +++++------------- .../caveats/tokenApprovalRevocation.test.ts | 253 +++----- .../test/permissions/rules/erc20Payee.test.ts | 1 + .../test/permissions/rules/expiry.test.ts | 1 + .../permissions/rules/nativePayee.test.ts | 1 + .../test/permissions/rules/redeemer.test.ts | 1 + .../7715-permission-types/test/test-utils.ts | 2 + 14 files changed, 970 insertions(+), 2238 deletions(-) create mode 100644 packages/7715-permission-types/test/test-utils.ts diff --git a/packages/7715-permission-types/src/index.ts b/packages/7715-permission-types/src/index.ts index e1c29618..892075ae 100644 --- a/packages/7715-permission-types/src/index.ts +++ b/packages/7715-permission-types/src/index.ts @@ -47,7 +47,6 @@ export { nativePayeeRuleDecoder, EXECUTION_PERMISSION_REDEEMER_RULE_TYPE, redeemerRuleDecoder, - makePermissionDecoder, makeNativeTokenStreamDecoderConfig, makeNativeTokenPeriodicDecoderConfig, makeNativeTokenAllowanceDecoderConfig, @@ -55,5 +54,4 @@ export { makeErc20TokenPeriodicDecoderConfig, makeErc20TokenAllowanceDecoderConfig, makeTokenApprovalRevocationDecoderConfig, - createPermissionDecodersForContracts, } from './permissions'; diff --git a/packages/7715-permission-types/src/permissions/index.ts b/packages/7715-permission-types/src/permissions/index.ts index 38186533..5c4f3913 100644 --- a/packages/7715-permission-types/src/permissions/index.ts +++ b/packages/7715-permission-types/src/permissions/index.ts @@ -1,5 +1,3 @@ -import { getChecksumAddress } from '@metamask/utils'; - import { makeErc20TokenAllowanceDecoderConfig } from './caveats/erc20TokenAllowance'; import { makeErc20TokenPeriodicDecoderConfig } from './caveats/erc20TokenPeriodic'; import { makeErc20TokenStreamDecoderConfig } from './caveats/erc20TokenStream'; @@ -7,14 +5,7 @@ import { makeNativeTokenAllowanceDecoderConfig } from './caveats/nativeTokenAllo import { makeNativeTokenPeriodicDecoderConfig } from './caveats/nativeTokenPeriodic'; import { makeNativeTokenStreamDecoderConfig } from './caveats/nativeTokenStream'; import { makeTokenApprovalRevocationDecoderConfig } from './caveats/tokenApprovalRevocation'; -import type { - Caveat, - DeployedContractsByName, - PermissionDecoder, - PermissionDecoderConfig, - ValidateAndDecodeResult, -} from './types'; -import type { Hex } from '../types'; +import type { DeployedContractsByName, PermissionDecoderConfig } from './types'; import { getChecksumEnforcersByChainId } from './utils'; export type { @@ -78,103 +69,3 @@ export const makePermissionDecoderConfigs = ( makeTokenApprovalRevocationDecoderConfig(contractAddresses), ]; }; - -/** - * Creates a runtime decoder from one permission decoder configuration. - * - * @param config - Permission decoder configuration. - * @returns Permission decoder. - */ -export const makePermissionDecoder = ( - config: PermissionDecoderConfig, -): PermissionDecoder => { - const requiredEnforcers = new Map( - Object.entries(config.requiredEnforcers) as [Hex, number][], - ); - const optionalEnforcers = new Set(config.optionalEnforcers); - - const caveatAddressesMatch = (caveatAddresses: Hex[]): boolean => { - const counts = new Map(); - - for (const address of caveatAddresses) { - counts.set(address, (counts.get(address) ?? 0) + 1); - } - for (const [address, count] of counts) { - const maxAllowedCount = - requiredEnforcers.get(address) ?? - (optionalEnforcers.has(address) ? 1 : 0); - if (maxAllowedCount === 0 || count > maxAllowedCount) { - return false; - } - } - - return true; - }; - - const validateAndDecodePermission = ( - caveats: Caveat[], - ): ValidateAndDecodeResult => { - try { - const normalizedCaveats = caveats.map((caveat) => ({ - ...caveat, - enforcer: getChecksumAddress(caveat.enforcer), - })); - - const caveatAddresses = normalizedCaveats.map( - (caveat) => caveat.enforcer, - ); - - if (!caveatAddressesMatch(caveatAddresses)) { - throw new Error('Invalid caveats'); - } - - const data = config.validateAndDecodeData( - normalizedCaveats, - config.contractAddresses, - ); - const rules = config.rules - .map((decodeRule) => - decodeRule({ - contractAddresses: config.contractAddresses, - caveats: normalizedCaveats, - requiredEnforcers, - }), - ) - .filter((rule) => rule !== null); - const expiryRule = rules.find((rule) => rule.type === 'expiry'); - - return { - isValid: true, - expiry: - expiryRule?.type === 'expiry' ? expiryRule.data.timestamp : null, - data, - rules: rules.length > 0 ? rules : undefined, - }; - } catch (error) { - return { - isValid: false, - error: error instanceof Error ? error : new Error('Invalid caveats'), - }; - } - }; - - return { - permissionType: config.permissionType, - requiredEnforcers, - optionalEnforcers, - caveatAddressesMatch, - validateAndDecodePermission, - }; -}; - -/** - * Creates permission decoders for all supported permission types. - * - * @param contracts - Deployed delegation framework contracts for one chain. - * @returns Runtime permission decoders. - */ -export const createPermissionDecodersForContracts = ( - contracts: DeployedContractsByName, -): PermissionDecoder[] => { - return makePermissionDecoderConfigs(contracts).map(makePermissionDecoder); -}; diff --git a/packages/7715-permission-types/test/permissions/caveats/erc20TokenAllowance.test.ts b/packages/7715-permission-types/test/permissions/caveats/erc20TokenAllowance.test.ts index ea52546b..c4ad2468 100644 --- a/packages/7715-permission-types/test/permissions/caveats/erc20TokenAllowance.test.ts +++ b/packages/7715-permission-types/test/permissions/caveats/erc20TokenAllowance.test.ts @@ -1,269 +1,146 @@ -import { createTimestampTerms } from '@metamask/delegation-core'; -import type { Hex } from '@metamask/delegation-core'; import { CHAIN_ID, DELEGATOR_CONTRACTS, } from '@metamask/delegation-deployments'; +import type { Hex } from '@metamask/utils'; +import { describe, it, expect } from 'vitest'; + +import { makePermissionDecoderConfigs } from '../../../src/permissions'; +import { makeErc20TokenAllowanceDecoderConfig } from '../../../src/permissions/caveats/erc20TokenAllowance'; +import { expiryRule } from '../../../src/permissions/rules/expiry'; +import { erc20PayeeRuleDecoder } from '../../../src/permissions/rules/payee'; +import { redeemerRuleDecoder } from '../../../src/permissions/rules/redeemer'; +import type { ChecksumCaveat } from '../../../src/permissions/types'; +import { + getChecksumEnforcersByChainId, + UINT256_MAX, + ZERO_32_BYTES, +} from '../../../src/permissions/utils'; +import { toWord } from '../../test-utils'; -import { createPermissionDecodersForContracts } from '../../../src/permissions'; -import { ZERO_32_BYTES } from '../../../src/permissions/utils'; - -describe('erc20-token-allowance decoder', () => { +describe('erc20-token-allowance decoder config', () => { const chainId = CHAIN_ID.sepolia; const contracts = DELEGATOR_CONTRACTS['1.3.0'][chainId]; - const { TimestampEnforcer, ERC20PeriodTransferEnforcer, ValueLteEnforcer } = - contracts; - const permissionDecoders = createPermissionDecodersForContracts(contracts); - const decoder = permissionDecoders.find( - (candidate) => candidate.permissionType === 'erc20-token-allowance', + const { + timestampEnforcer, + erc20PeriodicEnforcer, + valueLteEnforcer, + nonceEnforcer, + allowedCalldataEnforcer, + redeemerEnforcer, + } = getChecksumEnforcersByChainId(contracts); + const decoder = makeErc20TokenAllowanceDecoderConfig( + getChecksumEnforcersByChainId(contracts), ); - if (!decoder) { - throw new Error('Decoder not found'); - } - - const expiryCaveat = { - enforcer: TimestampEnforcer, - terms: createTimestampTerms({ - afterThreshold: 0, - beforeThreshold: 1720000, - }), - args: '0x' as const, - }; - - const valueLteCaveat = { - enforcer: ValueLteEnforcer, - terms: ZERO_32_BYTES, - args: '0x' as const, - }; - const TOKEN_ADDRESS_HEX = 'aa'.repeat(20); - const TOKEN_ADDRESS: Hex = `0x${TOKEN_ADDRESS_HEX}`; - const ALLOWANCE_AMOUNT_HEX = 100n.toString(16).padStart(64, '0'); - const PERIOD_DURATION_MAX_HEX = 'f'.repeat(64); - const START_DATE_HEX = (1715664).toString(16).padStart(64, '0'); - const ALLOWANCE_TERMS = - `0x${TOKEN_ADDRESS_HEX}${ALLOWANCE_AMOUNT_HEX}${PERIOD_DURATION_MAX_HEX}${START_DATE_HEX}` as Hex; - - it('rejects duplicate ERC20PeriodTransferEnforcer caveats', () => { - const caveats = [ - expiryCaveat, - valueLteCaveat, - { - enforcer: ERC20PeriodTransferEnforcer, - terms: ALLOWANCE_TERMS, - args: '0x' as const, - }, - { - enforcer: ERC20PeriodTransferEnforcer, - terms: ALLOWANCE_TERMS, - args: '0x' as const, - }, - ]; - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error.message).toContain('Invalid caveats'); - }); - - it('rejects truncated terms', () => { - const truncatedTerms: Hex = `0x${'00'.repeat(40)}`; // 40 bytes, need 116 - - const caveats = [ - expiryCaveat, - valueLteCaveat, - { - enforcer: ERC20PeriodTransferEnforcer, - terms: truncatedTerms, - args: '0x' as const, - }, - ]; - - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error.message).toContain( - 'Invalid erc20-token-allowance terms: expected 116 bytes', - ); - }); - - it('rejects when terms have trailing bytes', () => { - const termsWithTrailing = `${ALLOWANCE_TERMS}deadbeef` as Hex; - const caveats = [ - expiryCaveat, - valueLteCaveat, - { - enforcer: ERC20PeriodTransferEnforcer, - terms: termsWithTrailing, - args: '0x' as const, - }, - ]; - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error.message).toContain( - 'Invalid erc20-token-allowance terms: expected 116 bytes', - ); - }); - - it('rejects when ValueLteEnforcer terms are not zero', () => { - const nonZeroValueLte: Hex = `0x${'0'.repeat(62)}01` as Hex; - const caveats = [ - expiryCaveat, - { - enforcer: ValueLteEnforcer, - terms: nonZeroValueLte, - args: '0x' as const, - }, - { - enforcer: ERC20PeriodTransferEnforcer, - terms: ALLOWANCE_TERMS, - args: '0x' as const, - }, - ]; - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error.message).toContain( - `Invalid value-lte terms: must be ${ZERO_32_BYTES}`, - ); - }); - - it('successfully decodes valid erc20-token-allowance caveats', () => { - const caveats = [ - expiryCaveat, - valueLteCaveat, - { - enforcer: ERC20PeriodTransferEnforcer, - terms: ALLOWANCE_TERMS, - args: '0x' as const, - }, - ]; - - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(true); - - if (!result.isValid) { - throw new Error('Expected valid result'); - } - - expect(result.expiry).toBe(1720000); - expect(result.data).toStrictEqual({ - tokenAddress: TOKEN_ADDRESS, - allowanceAmount: `0x${ALLOWANCE_AMOUNT_HEX}`, - startTime: 1715664, + const ALLOWANCE_AMOUNT_HEX = toWord(100n); + const START_TIME = 1715664; + const START_TIME_HEX = toWord(START_TIME); + const VALID_ALLOWANCE_TERMS = + `0x${TOKEN_ADDRESS_HEX}${ALLOWANCE_AMOUNT_HEX}${UINT256_MAX.slice(2)}${START_TIME_HEX}` as Hex; + + const makeCaveats = ( + erc20PeriodicTerms: Hex, + valueLteTerms: Hex = ZERO_32_BYTES, + ): ChecksumCaveat[] => [ + { + enforcer: erc20PeriodicEnforcer, + terms: erc20PeriodicTerms, + }, + { + enforcer: valueLteEnforcer, + terms: valueLteTerms, + }, + { + enforcer: nonceEnforcer, + terms: '0x' as const, + }, + ]; + + describe('static configuration', () => { + it('exposes expected required enforcers', () => { + expect(decoder.requiredEnforcers).toStrictEqual({ + [erc20PeriodicEnforcer]: 1, + [valueLteEnforcer]: 1, + [nonceEnforcer]: 1, + }); }); - }); - - it('successfully decodes when periodDuration uses uppercase UINT256_MAX', () => { - const uppercaseMax = 'F'.repeat(64); - const terms = - `0x${TOKEN_ADDRESS_HEX}${ALLOWANCE_AMOUNT_HEX}${uppercaseMax}${START_DATE_HEX}` as Hex; - - const caveats = [ - expiryCaveat, - valueLteCaveat, - { - enforcer: ERC20PeriodTransferEnforcer, - terms, - args: '0x' as const, - }, - ]; - - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(true); - }); - - it('rejects when periodDuration is not UINT256_MAX', () => { - const nonMaxDuration = (86400).toString(16).padStart(64, '0'); - const terms = - `0x${TOKEN_ADDRESS_HEX}${ALLOWANCE_AMOUNT_HEX}${nonMaxDuration}${START_DATE_HEX}` as Hex; - - const caveats = [ - expiryCaveat, - valueLteCaveat, - { - enforcer: ERC20PeriodTransferEnforcer, - terms, - args: '0x' as const, - }, - ]; - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - if (result.isValid) { - throw new Error('Expected invalid result'); - } + it('exposes expected optional enforcers', () => { + expect(decoder.optionalEnforcers).toStrictEqual([ + timestampEnforcer, + redeemerEnforcer, + allowedCalldataEnforcer, + ]); + }); - expect(result.error.message).toContain( - 'Invalid erc20-token-allowance terms: periodDuration must be UINT256_MAX', - ); + it('includes expected rule decoders in order', () => { + expect(decoder.rules).toStrictEqual([ + expiryRule, + redeemerRuleDecoder, + erc20PayeeRuleDecoder, + ]); + }); }); - it('rejects when startTime is 0', () => { - const startTimeZero = '0'.repeat(64); - const terms = - `0x${TOKEN_ADDRESS_HEX}${ALLOWANCE_AMOUNT_HEX}${PERIOD_DURATION_MAX_HEX}${startTimeZero}` as Hex; - const caveats = [ - expiryCaveat, - valueLteCaveat, - { - enforcer: ERC20PeriodTransferEnforcer, - terms, - args: '0x' as const, - }, - ]; - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error.message).toContain( - 'Invalid erc20-token-allowance terms: startTime must be a positive number', - ); - }); + describe('validateAndDecodeData', () => { + it('provides a validateAndDecodeData function', () => { + expect(typeof decoder.validateAndDecodeData).toBe('function'); + }); - it('rejects when allowanceAmount is 0', () => { - const allowanceZero = '0'.repeat(64); - const terms = - `0x${TOKEN_ADDRESS_HEX}${allowanceZero}${PERIOD_DURATION_MAX_HEX}${START_DATE_HEX}` as Hex; + it('is included in makePermissionDecoderConfigs', () => { + expect(makePermissionDecoderConfigs(contracts)).toContainEqual(decoder); + }); - const caveats = [ - expiryCaveat, - valueLteCaveat, - { - enforcer: ERC20PeriodTransferEnforcer, - terms, - args: '0x' as const, - }, - ]; + it('validateAndDecodeData decodes valid allowance terms', () => { + expect( + decoder.validateAndDecodeData(makeCaveats(VALID_ALLOWANCE_TERMS), { + ...decoder.contractAddresses, + }), + ).toStrictEqual({ + tokenAddress: `0x${TOKEN_ADDRESS_HEX}`, + allowanceAmount: `0x${ALLOWANCE_AMOUNT_HEX}`, + startTime: START_TIME, + }); + }); - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); + it('validateAndDecodeData rejects non-zero value-lte terms', () => { + expect(() => + decoder.validateAndDecodeData( + makeCaveats(VALID_ALLOWANCE_TERMS, `0x${'0'.repeat(63)}1` as Hex), + decoder.contractAddresses, + ), + ).toThrow(`Invalid value-lte terms: must be ${ZERO_32_BYTES}`); + }); - if (result.isValid) { - throw new Error('Expected invalid result'); - } + it('validateAndDecodeData rejects invalid periodDuration', () => { + const nonMaxDurationHex = toWord(86400); + const invalidTerms = + `0x${TOKEN_ADDRESS_HEX}${ALLOWANCE_AMOUNT_HEX}${nonMaxDurationHex}${START_TIME_HEX}` as Hex; + + expect(() => + decoder.validateAndDecodeData( + makeCaveats(invalidTerms), + decoder.contractAddresses, + ), + ).toThrow( + 'Invalid erc20-token-allowance terms: periodDuration must be UINT256_MAX', + ); + }); - expect(result.error.message).toContain( - 'Invalid erc20-token-allowance terms: allowanceAmount must be a positive number', - ); + it('validateAndDecodeData rejects zero allowanceAmount', () => { + const zeroAllowanceAmount = '0'.repeat(64); + const invalidTerms = + `0x${TOKEN_ADDRESS_HEX}${zeroAllowanceAmount}${UINT256_MAX.slice(2)}${START_TIME_HEX}` as Hex; + + expect(() => + decoder.validateAndDecodeData( + makeCaveats(invalidTerms), + decoder.contractAddresses, + ), + ).toThrow( + 'Invalid erc20-token-allowance terms: allowanceAmount must be a positive number', + ); + }); }); }); diff --git a/packages/7715-permission-types/test/permissions/caveats/erc20TokenPeriodic.test.ts b/packages/7715-permission-types/test/permissions/caveats/erc20TokenPeriodic.test.ts index c8892d05..5a13678f 100644 --- a/packages/7715-permission-types/test/permissions/caveats/erc20TokenPeriodic.test.ts +++ b/packages/7715-permission-types/test/permissions/caveats/erc20TokenPeriodic.test.ts @@ -1,398 +1,174 @@ -import { - createERC20TokenPeriodTransferTerms, - createTimestampTerms, -} from '@metamask/delegation-core'; -import type { Hex } from '@metamask/delegation-core'; +import { createERC20TokenPeriodTransferTerms } from '@metamask/delegation-core'; import { CHAIN_ID, DELEGATOR_CONTRACTS, } from '@metamask/delegation-deployments'; - -import { createPermissionDecodersForContracts } from '../../../src/permissions'; +import type { Hex } from '@metamask/utils'; +import { describe, it, expect } from 'vitest'; + +import { makePermissionDecoderConfigs } from '../../../src/permissions'; +import { makeErc20TokenPeriodicDecoderConfig } from '../../../src/permissions/caveats/erc20TokenPeriodic'; +import { expiryRule } from '../../../src/permissions/rules/expiry'; +import { erc20PayeeRuleDecoder } from '../../../src/permissions/rules/payee'; +import { redeemerRuleDecoder } from '../../../src/permissions/rules/redeemer'; +import type { ChecksumCaveat } from '../../../src/permissions/types'; import { + getChecksumEnforcersByChainId, MAX_PERIOD_DURATION, ZERO_32_BYTES, } from '../../../src/permissions/utils'; -describe('erc20-token-periodic decoder', () => { +describe('erc20-token-periodic decoder config', () => { const chainId = CHAIN_ID.sepolia; const contracts = DELEGATOR_CONTRACTS['1.3.0'][chainId]; - const { TimestampEnforcer, ERC20PeriodTransferEnforcer, ValueLteEnforcer } = - contracts; - const permissionDecoders = createPermissionDecodersForContracts(contracts); - const decoder = permissionDecoders.find( - (candidate) => candidate.permissionType === 'erc20-token-periodic', + const { + timestampEnforcer, + erc20PeriodicEnforcer, + valueLteEnforcer, + nonceEnforcer, + allowedCalldataEnforcer, + redeemerEnforcer, + } = getChecksumEnforcersByChainId(contracts); + const decoder = makeErc20TokenPeriodicDecoderConfig( + getChecksumEnforcersByChainId(contracts), ); - if (!decoder) { - throw new Error('Decoder not found'); - } - - const expiryCaveat = { - enforcer: TimestampEnforcer, - terms: createTimestampTerms({ - afterThreshold: 0, - beforeThreshold: 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, - }, + const TOKEN_ADDRESS = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex; + const START_TIME = 1715664; + + const makeTerms = ({ + tokenAddress = TOKEN_ADDRESS, + periodAmount = 100n, + periodDuration = 86400, + startDate = START_TIME, + }: { + tokenAddress?: Hex; + periodAmount?: bigint; + periodDuration?: number; + startDate?: number; + } = {}): Hex => + createERC20TokenPeriodTransferTerms( + { tokenAddress, periodAmount, periodDuration, startDate }, { out: 'hex' }, ); - const caveats = [ - expiryCaveat, - valueLteCaveat, - { - enforcer: ERC20PeriodTransferEnforcer, - terms, - args: '0x' as const, - }, - { - enforcer: ERC20PeriodTransferEnforcer, - terms, - args: '0x' as const, - }, - ]; - const result = decoder.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 = decoder.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 ERC20TokenPeriodTransfer terms: must be exactly 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 = decoder.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'); + const makeCaveats = ( + terms: Hex, + valueLteTerms: Hex = ZERO_32_BYTES, + ): ChecksumCaveat[] => [ + { + enforcer: erc20PeriodicEnforcer, + terms, + }, + { + enforcer: valueLteEnforcer, + terms: valueLteTerms, + }, + { + enforcer: nonceEnforcer, + terms: '0x' as const, + }, + ]; + + describe('static configuration', () => { + it('exposes expected required enforcers', () => { + expect(decoder.requiredEnforcers).toStrictEqual({ + [erc20PeriodicEnforcer]: 1, + [valueLteEnforcer]: 1, + [nonceEnforcer]: 1, + }); + }); + + it('exposes expected optional enforcers', () => { + expect(decoder.optionalEnforcers).toStrictEqual([ + timestampEnforcer, + redeemerEnforcer, + allowedCalldataEnforcer, + ]); + }); + + it('includes expected rule decoders in order', () => { + expect(decoder.rules).toStrictEqual([ + expiryRule, + redeemerRuleDecoder, + erc20PayeeRuleDecoder, + ]); + }); }); - 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 = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(true); - - // this is here as a type guard - if (!result.isValid) { - throw new Error('Expected valid result'); - } + describe('validateAndDecodeData', () => { + it('provides a validateAndDecodeData function', () => { + expect(typeof decoder.validateAndDecodeData).toBe('function'); + }); - 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('is included in makePermissionDecoderConfigs', () => { + expect(makePermissionDecoderConfigs(contracts)).toContainEqual(decoder); + }); - it('decodes mixed-case token address', () => { - const mixedCaseAddress = contracts.ERC20PeriodTransferEnforcer; - const caveats = [ - expiryCaveat, - valueLteCaveat, - { - enforcer: ERC20PeriodTransferEnforcer, - terms: createERC20TokenPeriodTransferTerms( - { - tokenAddress: mixedCaseAddress, - periodAmount: 200n, - periodDuration: 86400, - startDate: 1715664, - }, - { out: 'hex' }, + it('validateAndDecodeData decodes valid periodic terms', () => { + expect( + decoder.validateAndDecodeData( + makeCaveats(makeTerms()), + decoder.contractAddresses, ), - args: '0x' as const, - }, - ]; - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(true); - - // this is here as a type guard - if (!result.isValid) { - throw new Error('Expected valid result'); - } - - expect(result.expiry).toBe(1720000); - expect(result.data.tokenAddress.toLowerCase()).toBe( - mixedCaseAddress.toLowerCase(), - ); - }); - - it('rejects when periodDuration is 0', () => { - const tokenAddress = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex; - const periodAmountHex = 100n.toString(16).padStart(64, '0'); - 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 = decoder.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, + ).toStrictEqual({ + tokenAddress: TOKEN_ADDRESS, 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 = decoder.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 ERC20TokenPeriodTransfer terms: must be exactly 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 = decoder.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 = decoder.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 periodDuration exceeds MAX_PERIOD_DURATION', () => { - const tokenAddress = '0xffffffffffffffffffffffffffffffffffffffff' as Hex; - const terms = createERC20TokenPeriodTransferTerms( - { - tokenAddress, - periodAmount: 100n, - periodDuration: MAX_PERIOD_DURATION + 1, - startDate: 1715664, - }, - { out: 'hex' }, - ); - - const caveats = [ - expiryCaveat, - valueLteCaveat, - { - enforcer: ERC20PeriodTransferEnforcer, - terms, - args: '0x' as const, - }, - ]; - - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - // this is here as a type guard - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error.message).toContain( - 'Invalid erc20-token-periodic terms: periodDuration must be less than or equal to MAX_PERIOD_DURATION', - ); - }); - - it('accepts when periodDuration equals MAX_PERIOD_DURATION', () => { - const tokenAddress = '0xcccccccccccccccccccccccccccccccccccccccc' as Hex; - const caveats = [ - expiryCaveat, - valueLteCaveat, - { - enforcer: ERC20PeriodTransferEnforcer, - terms: createERC20TokenPeriodTransferTerms( - { - tokenAddress, - periodAmount: 200n, - periodDuration: MAX_PERIOD_DURATION, - startDate: 1715664, - }, - { out: 'hex' }, + startTime: START_TIME, + }); + }); + + it('validateAndDecodeData rejects non-zero value-lte terms', () => { + expect(() => + decoder.validateAndDecodeData( + makeCaveats(makeTerms(), `0x${'0'.repeat(63)}1` as Hex), + decoder.contractAddresses, ), - args: '0x' as const, - }, - ]; - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(true); - - // this is here as a type guard - if (!result.isValid) { - throw new Error('Expected valid result'); - } - - expect(result.data.periodDuration).toBe(MAX_PERIOD_DURATION); + ).toThrow(`Invalid value-lte terms: must be ${ZERO_32_BYTES}`); + }); + + it('validateAndDecodeData rejects when periodDuration is zero', () => { + expect(() => + decoder.validateAndDecodeData( + makeCaveats(makeTerms({ periodDuration: 0 })), + decoder.contractAddresses, + ), + ).toThrow('Invalid periodDuration: must be a positive number'); + }); + + it('validateAndDecodeData rejects when periodAmount is zero', () => { + expect(() => + decoder.validateAndDecodeData( + makeCaveats(makeTerms({ periodAmount: 0n })), + decoder.contractAddresses, + ), + ).toThrow('Invalid periodAmount: must be a positive number'); + }); + + it('validateAndDecodeData rejects when periodDuration exceeds MAX_PERIOD_DURATION', () => { + expect(() => + decoder.validateAndDecodeData( + makeCaveats(makeTerms({ periodDuration: MAX_PERIOD_DURATION + 1 })), + decoder.contractAddresses, + ), + ).toThrow( + 'Invalid erc20-token-periodic terms: periodDuration must be less than or equal to MAX_PERIOD_DURATION', + ); + }); + + it('validateAndDecodeData accepts periodDuration equal to MAX_PERIOD_DURATION', () => { + expect( + decoder.validateAndDecodeData( + makeCaveats(makeTerms({ periodDuration: MAX_PERIOD_DURATION })), + decoder.contractAddresses, + ), + ).toStrictEqual({ + tokenAddress: TOKEN_ADDRESS, + periodAmount: 100n, + periodDuration: MAX_PERIOD_DURATION, + startTime: START_TIME, + }); + }); }); }); diff --git a/packages/7715-permission-types/test/permissions/caveats/erc20TokenStream.test.ts b/packages/7715-permission-types/test/permissions/caveats/erc20TokenStream.test.ts index e3f0af20..1450a030 100644 --- a/packages/7715-permission-types/test/permissions/caveats/erc20TokenStream.test.ts +++ b/packages/7715-permission-types/test/permissions/caveats/erc20TokenStream.test.ts @@ -1,336 +1,189 @@ -import { - createERC20StreamingTerms, - createTimestampTerms, -} from '@metamask/delegation-core'; -import type { Hex } from '@metamask/delegation-core'; +import { createERC20StreamingTerms } from '@metamask/delegation-core'; import { CHAIN_ID, DELEGATOR_CONTRACTS, } from '@metamask/delegation-deployments'; +import type { Hex } from '@metamask/utils'; +import { describe, it, expect } from 'vitest'; + +import { makePermissionDecoderConfigs } from '../../../src/permissions'; +import { makeErc20TokenStreamDecoderConfig } from '../../../src/permissions/caveats/erc20TokenStream'; +import { expiryRule } from '../../../src/permissions/rules/expiry'; +import { erc20PayeeRuleDecoder } from '../../../src/permissions/rules/payee'; +import { redeemerRuleDecoder } from '../../../src/permissions/rules/redeemer'; +import type { ChecksumCaveat } from '../../../src/permissions/types'; +import { + getChecksumEnforcersByChainId, + ZERO_32_BYTES, +} from '../../../src/permissions/utils'; +import { toWord } from '../../test-utils'; -import { createPermissionDecodersForContracts } from '../../../src/permissions'; -import { ZERO_32_BYTES } from '../../../src/permissions/utils'; - -describe('erc20-token-stream decoder', () => { +describe('erc20-token-stream decoder config', () => { const chainId = CHAIN_ID.sepolia; const contracts = DELEGATOR_CONTRACTS['1.3.0'][chainId]; - const { TimestampEnforcer, ERC20StreamingEnforcer, ValueLteEnforcer } = - contracts; - const permissionDecoders = createPermissionDecodersForContracts(contracts); - const decoder = permissionDecoders.find( - (candidate) => candidate.permissionType === 'erc20-token-stream', + const { + timestampEnforcer, + erc20StreamingEnforcer, + valueLteEnforcer, + nonceEnforcer, + allowedCalldataEnforcer, + redeemerEnforcer, + } = getChecksumEnforcersByChainId(contracts); + const decoder = makeErc20TokenStreamDecoderConfig( + getChecksumEnforcersByChainId(contracts), ); - if (!decoder) { - throw new Error('Decoder not found'); - } - - const expiryCaveat = { - enforcer: TimestampEnforcer, - terms: createTimestampTerms({ - afterThreshold: 0, - beforeThreshold: 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 = decoder.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'); + const TOKEN_ADDRESS = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex; + const START_TIME = 1715664; + const VALID_TERMS = createERC20StreamingTerms( + { + tokenAddress: TOKEN_ADDRESS, + initialAmount: 10n, + maxAmount: 100n, + amountPerSecond: 5n, + startTime: START_TIME, + }, + { out: 'hex' }, + ); + const makeRawTerms = ({ + tokenAddress = TOKEN_ADDRESS, + initialAmount = 10n, + maxAmount = 100n, + amountPerSecond = 5n, + startTime = START_TIME, + }: { + tokenAddress?: Hex; + initialAmount?: bigint; + maxAmount?: bigint; + amountPerSecond?: bigint; + startTime?: number; + }): Hex => + `0x${tokenAddress.slice(2)}${toWord(initialAmount)}${toWord(maxAmount)}${toWord( + amountPerSecond, + )}${toWord(startTime)}` as Hex; + + const makeCaveats = ( + erc20StreamingTerms: Hex, + valueLteTerms: Hex = ZERO_32_BYTES, + ): ChecksumCaveat[] => [ + { + enforcer: erc20StreamingEnforcer, + terms: erc20StreamingTerms, + }, + { + enforcer: valueLteEnforcer, + terms: valueLteTerms, + }, + { + enforcer: nonceEnforcer, + terms: '0x' as const, + }, + ]; + + describe('static configuration', () => { + it('exposes expected required enforcers', () => { + expect(decoder.requiredEnforcers).toStrictEqual({ + [erc20StreamingEnforcer]: 1, + [valueLteEnforcer]: 1, + [nonceEnforcer]: 1, + }); + }); + + it('exposes expected optional enforcers', () => { + expect(decoder.optionalEnforcers).toStrictEqual([ + timestampEnforcer, + redeemerEnforcer, + allowedCalldataEnforcer, + ]); + }); + + it('includes expected rule decoders in order', () => { + expect(decoder.rules).toStrictEqual([ + expiryRule, + redeemerRuleDecoder, + erc20PayeeRuleDecoder, + ]); + }); }); - 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 = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); + describe('validateAndDecodeData', () => { + it('provides a validateAndDecodeData function', () => { + expect(typeof decoder.validateAndDecodeData).toBe('function'); + }); - // this is here as a type guard - if (result.isValid) { - throw new Error('Expected invalid result'); - } + it('is included in makePermissionDecoderConfigs', () => { + expect(makePermissionDecoderConfigs(contracts)).toContainEqual(decoder); + }); - expect(result.error.message).toContain( - 'Invalid ERC20Streaming terms: must be exactly 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' }, + it('validateAndDecodeData decodes valid stream terms', () => { + expect( + decoder.validateAndDecodeData( + makeCaveats(VALID_TERMS), + decoder.contractAddresses, ), - args: '0x' as const, - }, - ]; - const result = decoder.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 mixed-case token address', () => { - const mixedCaseAddress = contracts.ERC20StreamingEnforcer; - const caveats = [ - expiryCaveat, - valueLteCaveat, - { - enforcer: ERC20StreamingEnforcer, - terms: createERC20StreamingTerms( - { - tokenAddress: mixedCaseAddress, - initialAmount: 1n, - maxAmount: 2n, - amountPerSecond: 1n, - startTime: 1715664, - }, - { out: 'hex' }, + ).toStrictEqual({ + tokenAddress: TOKEN_ADDRESS, + initialAmount: 10n, + maxAmount: 100n, + amountPerSecond: 5n, + startTime: START_TIME, + }); + }); + + it('validateAndDecodeData rejects non-zero value-lte terms', () => { + expect(() => + decoder.validateAndDecodeData( + makeCaveats(VALID_TERMS, `0x${'0'.repeat(63)}1` as Hex), + decoder.contractAddresses, ), - args: '0x' as const, - }, - ]; - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(true); - - // this is here as a type guard - if (!result.isValid) { - throw new Error('Expected valid result'); - } - - expect(result.expiry).toBe(1720000); - expect(result.data?.tokenAddress.toLowerCase()).toBe( - mixedCaseAddress.toLowerCase(), - ); - }); - - it('decodes zero token address', () => { - const zeroAddress = '0x0000000000000000000000000000000000000000' as Hex; - const caveats = [ - expiryCaveat, - valueLteCaveat, - { - enforcer: ERC20StreamingEnforcer, - terms: createERC20StreamingTerms( - { - tokenAddress: zeroAddress, - initialAmount: 1n, - maxAmount: 2n, - amountPerSecond: 1n, - startTime: 1715664, - }, - { out: 'hex' }, + ).toThrow(`Invalid value-lte terms: must be ${ZERO_32_BYTES}`); + }); + + it('validateAndDecodeData rejects when maxAmount equals initialAmount', () => { + const invalidTerms = createERC20StreamingTerms( + { + tokenAddress: TOKEN_ADDRESS, + initialAmount: 100n, + maxAmount: 100n, + amountPerSecond: 5n, + startTime: START_TIME, + }, + { out: 'hex' }, + ); + + expect(() => + decoder.validateAndDecodeData( + makeCaveats(invalidTerms), + decoder.contractAddresses, ), - args: '0x' as const, - }, - ]; - const result = decoder.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 = decoder.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 maxAmount equals initialAmount', () => { - const tokenAddress = '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Hex; - const initialAmountAndMaxAmount = 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)}${initialAmountAndMaxAmount}${initialAmountAndMaxAmount}${amountPerSecondHex}${startTimeHex}` as Hex; - const caveats = [ - expiryCaveat, - valueLteCaveat, - { enforcer: ERC20StreamingEnforcer, terms, args: '0x' as const }, - ]; - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - // this is here as a type guard - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error.message).toContain( - 'maxAmount must be greater than initialAmount', - ); - }); - - it('rejects when terms have trailing bytes', () => { - const tokenAddress = '0xcccccccccccccccccccccccccccccccccccccccc' as Hex; - const validTerms = createERC20StreamingTerms( - { - tokenAddress, - initialAmount: 42n, - maxAmount: 100n, - amountPerSecond: 1n, - startTime: 1715664, - }, - { out: 'hex' }, - ); - const termsWithTrailing = `${validTerms}deadbeef` as Hex; - const caveats = [ - expiryCaveat, - valueLteCaveat, - { - enforcer: ERC20StreamingEnforcer, - terms: termsWithTrailing, - args: '0x' as const, - }, - ]; - const result = decoder.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 ERC20Streaming terms: must be exactly 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 = decoder.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 = decoder.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', - ); + ).toThrow( + 'Invalid erc20-token-stream terms: maxAmount must be greater than initialAmount', + ); + }); + + it('validateAndDecodeData rejects when amountPerSecond is zero', () => { + const invalidTerms = makeRawTerms({ amountPerSecond: 0n }); + + expect(() => + decoder.validateAndDecodeData( + makeCaveats(invalidTerms), + decoder.contractAddresses, + ), + ).toThrow( + 'Invalid erc20-token-stream terms: amountPerSecond must be a positive number', + ); + }); + + it('validateAndDecodeData rejects when startTime is zero', () => { + const invalidTerms = makeRawTerms({ startTime: 0 }); + + expect(() => + decoder.validateAndDecodeData( + makeCaveats(invalidTerms), + decoder.contractAddresses, + ), + ).toThrow( + 'Invalid erc20-token-stream terms: startTime must be a positive number', + ); + }); }); }); diff --git a/packages/7715-permission-types/test/permissions/caveats/nativeTokenAllowance.test.ts b/packages/7715-permission-types/test/permissions/caveats/nativeTokenAllowance.test.ts index b58d98f5..55ec5006 100644 --- a/packages/7715-permission-types/test/permissions/caveats/nativeTokenAllowance.test.ts +++ b/packages/7715-permission-types/test/permissions/caveats/nativeTokenAllowance.test.ts @@ -1,268 +1,145 @@ -import { createTimestampTerms } from '@metamask/delegation-core'; -import type { Hex } from '@metamask/delegation-core'; import { CHAIN_ID, DELEGATOR_CONTRACTS, } from '@metamask/delegation-deployments'; +import type { Hex } from '@metamask/utils'; +import { describe, it, expect } from 'vitest'; + +import { makePermissionDecoderConfigs } from '../../../src/permissions'; +import { makeNativeTokenAllowanceDecoderConfig } from '../../../src/permissions/caveats/nativeTokenAllowance'; +import { expiryRule } from '../../../src/permissions/rules/expiry'; +import { nativePayeeRuleDecoder } from '../../../src/permissions/rules/payee'; +import { redeemerRuleDecoder } from '../../../src/permissions/rules/redeemer'; +import type { ChecksumCaveat } from '../../../src/permissions/types'; +import { + getChecksumEnforcersByChainId, + UINT256_MAX, +} from '../../../src/permissions/utils'; +import { toWord } from '../../test-utils'; -import { createPermissionDecodersForContracts } from '../../../src/permissions'; - -describe('native-token-allowance decoder', () => { +describe('native-token-allowance decoder config', () => { const chainId = CHAIN_ID.sepolia; const contracts = DELEGATOR_CONTRACTS['1.3.0'][chainId]; const { - TimestampEnforcer, - NativeTokenPeriodTransferEnforcer, - ExactCalldataEnforcer, - } = contracts; - const permissionDecoders = createPermissionDecodersForContracts(contracts); - const decoder = permissionDecoders.find( - (candidate) => candidate.permissionType === 'native-token-allowance', + timestampEnforcer, + nativeTokenPeriodicEnforcer, + exactCalldataEnforcer, + nonceEnforcer, + allowedTargetsEnforcer, + redeemerEnforcer, + } = getChecksumEnforcersByChainId(contracts); + const decoder = makeNativeTokenAllowanceDecoderConfig( + getChecksumEnforcersByChainId(contracts), ); - if (!decoder) { - throw new Error('Decoder not found'); - } - - const expiryCaveat = { - enforcer: TimestampEnforcer, - terms: createTimestampTerms({ - afterThreshold: 0, - beforeThreshold: 1720000, - }), - args: '0x' as const, - }; - - const exactCalldataCaveat = { - enforcer: ExactCalldataEnforcer, - terms: '0x' as Hex, - args: '0x' as const, - }; - - const PERIOD_AMOUNT_HEX = 100n.toString(16).padStart(64, '0'); - const PERIOD_DURATION_MAX_HEX = 'f'.repeat(64); - const START_DATE_HEX = (1715664).toString(16).padStart(64, '0'); - const ALLOWANCE_TERMS = - `0x${PERIOD_AMOUNT_HEX}${PERIOD_DURATION_MAX_HEX}${START_DATE_HEX}` as Hex; - - it('rejects duplicate NativeTokenPeriodTransferEnforcer caveats', () => { - const caveats = [ - expiryCaveat, - exactCalldataCaveat, - { - enforcer: NativeTokenPeriodTransferEnforcer, - terms: ALLOWANCE_TERMS, - args: '0x' as const, - }, - { - enforcer: NativeTokenPeriodTransferEnforcer, - terms: ALLOWANCE_TERMS, - args: '0x' as const, - }, - ]; - const result = decoder.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 = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error.message).toContain( - 'Invalid native-token-allowance terms: expected 96 bytes', - ); - }); - - it('rejects when terms have trailing bytes', () => { - const termsWithTrailing = `${ALLOWANCE_TERMS}deadbeef` as Hex; - const caveats = [ - expiryCaveat, - exactCalldataCaveat, - { - enforcer: NativeTokenPeriodTransferEnforcer, - terms: termsWithTrailing, - args: '0x' as const, - }, - ]; - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error.message).toContain( - 'Invalid native-token-allowance terms: expected 96 bytes', - ); - }); - - it('rejects when ExactCalldataEnforcer terms are not 0x', () => { - const caveats = [ - expiryCaveat, - { - enforcer: ExactCalldataEnforcer, - terms: '0x00' as Hex, - args: '0x' as const, - }, - { - enforcer: NativeTokenPeriodTransferEnforcer, - terms: ALLOWANCE_TERMS, - args: '0x' as const, - }, - ]; - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error.message).toContain( - 'Invalid exact-calldata terms: must be 0x', - ); - }); - - it('successfully decodes valid native-token-allowance caveats', () => { - const caveats = [ - expiryCaveat, - exactCalldataCaveat, - { - enforcer: NativeTokenPeriodTransferEnforcer, - terms: ALLOWANCE_TERMS, - args: '0x' as const, - }, - ]; - - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(true); - if (!result.isValid) { - throw new Error('Expected valid result'); - } - - expect(result.expiry).toBe(1720000); - expect(result.data).toStrictEqual({ - allowanceAmount: `0x${PERIOD_AMOUNT_HEX}`, - startTime: 1715664, + const ALLOWANCE_AMOUNT_HEX = toWord(100n); + const START_TIME = 1715664; + const START_TIME_HEX = toWord(START_TIME); + const VALID_ALLOWANCE_TERMS = + `0x${ALLOWANCE_AMOUNT_HEX}${UINT256_MAX.slice(2)}${START_TIME_HEX}` as Hex; + + const makeCaveats = ( + nativeTokenPeriodicTerms: Hex, + exactCalldataTerms: Hex = '0x', + ): ChecksumCaveat[] => [ + { + enforcer: nativeTokenPeriodicEnforcer, + terms: nativeTokenPeriodicTerms, + }, + { + enforcer: exactCalldataEnforcer, + terms: exactCalldataTerms, + }, + { + enforcer: nonceEnforcer, + terms: '0x' as const, + }, + ]; + + describe('static configuration', () => { + it('exposes expected required enforcers', () => { + expect(decoder.requiredEnforcers).toStrictEqual({ + [nativeTokenPeriodicEnforcer]: 1, + [exactCalldataEnforcer]: 1, + [nonceEnforcer]: 1, + }); }); - }); - - it('successfully decodes when periodDuration uses uppercase UINT256_MAX', () => { - const uppercaseMax = 'F'.repeat(64); - const terms = - `0x${PERIOD_AMOUNT_HEX}${uppercaseMax}${START_DATE_HEX}` as Hex; - - const caveats = [ - expiryCaveat, - exactCalldataCaveat, - { - enforcer: NativeTokenPeriodTransferEnforcer, - terms, - args: '0x' as const, - }, - ]; - - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(true); - }); - - it('rejects when periodDuration is not UINT256_MAX', () => { - const nonMaxDuration = (86400).toString(16).padStart(64, '0'); - const terms = - `0x${PERIOD_AMOUNT_HEX}${nonMaxDuration}${START_DATE_HEX}` as Hex; - - const caveats = [ - expiryCaveat, - exactCalldataCaveat, - { - enforcer: NativeTokenPeriodTransferEnforcer, - terms, - args: '0x' as const, - }, - ]; - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - if (result.isValid) { - throw new Error('Expected invalid result'); - } + it('exposes expected optional enforcers', () => { + expect(decoder.optionalEnforcers).toStrictEqual([ + timestampEnforcer, + redeemerEnforcer, + allowedTargetsEnforcer, + ]); + }); - expect(result.error.message).toContain( - 'Invalid native-token-allowance terms: periodDuration must be UINT256_MAX', - ); + it('includes expected rule decoders in order', () => { + expect(decoder.rules).toStrictEqual([ + expiryRule, + redeemerRuleDecoder, + nativePayeeRuleDecoder, + ]); + }); }); - it('rejects when startTime is 0', () => { - const startTimeZero = '0'.repeat(64); - const terms = - `0x${PERIOD_AMOUNT_HEX}${PERIOD_DURATION_MAX_HEX}${startTimeZero}` as Hex; - const caveats = [ - expiryCaveat, - exactCalldataCaveat, - { - enforcer: NativeTokenPeriodTransferEnforcer, - terms, - args: '0x' as const, - }, - ]; - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error.message).toContain( - 'Invalid native-token-allowance terms: startTime must be a positive number', - ); - }); + describe('validateAndDecodeData', () => { + it('provides a validateAndDecodeData function', () => { + expect(typeof decoder.validateAndDecodeData).toBe('function'); + }); - it('rejects when allowanceAmount is 0', () => { - const allowanceZero = '0'.repeat(64); - const terms = - `0x${allowanceZero}${PERIOD_DURATION_MAX_HEX}${START_DATE_HEX}` as Hex; + it('is included in makePermissionDecoderConfigs', () => { + expect(makePermissionDecoderConfigs(contracts)).toContainEqual(decoder); + }); - const caveats = [ - expiryCaveat, - exactCalldataCaveat, - { - enforcer: NativeTokenPeriodTransferEnforcer, - terms, - args: '0x' as const, - }, - ]; + it('validateAndDecodeData decodes valid allowance terms', () => { + expect( + decoder.validateAndDecodeData( + makeCaveats(VALID_ALLOWANCE_TERMS), + decoder.contractAddresses, + ), + ).toStrictEqual({ + allowanceAmount: `0x${ALLOWANCE_AMOUNT_HEX}`, + startTime: START_TIME, + }); + }); - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); + it('validateAndDecodeData rejects exact-calldata terms that are not 0x', () => { + expect(() => + decoder.validateAndDecodeData( + makeCaveats(VALID_ALLOWANCE_TERMS, '0x00'), + decoder.contractAddresses, + ), + ).toThrow('Invalid exact-calldata terms: must be 0x'); + }); - if (result.isValid) { - throw new Error('Expected invalid result'); - } + it('validateAndDecodeData rejects invalid periodDuration', () => { + const nonMaxDurationHex = toWord(86400); + const invalidTerms = + `0x${ALLOWANCE_AMOUNT_HEX}${nonMaxDurationHex}${START_TIME_HEX}` as Hex; + + expect(() => + decoder.validateAndDecodeData( + makeCaveats(invalidTerms), + decoder.contractAddresses, + ), + ).toThrow( + 'Invalid native-token-allowance terms: periodDuration must be UINT256_MAX', + ); + }); - expect(result.error.message).toContain( - 'Invalid native-token-allowance terms: allowanceAmount must be a positive number', - ); + it('validateAndDecodeData rejects zero allowanceAmount', () => { + const zeroAllowanceAmount = '0'.repeat(64); + const invalidTerms = + `0x${zeroAllowanceAmount}${UINT256_MAX.slice(2)}${START_TIME_HEX}` as Hex; + + expect(() => + decoder.validateAndDecodeData( + makeCaveats(invalidTerms), + decoder.contractAddresses, + ), + ).toThrow( + 'Invalid native-token-allowance terms: allowanceAmount must be a positive number', + ); + }); }); }); diff --git a/packages/7715-permission-types/test/permissions/caveats/nativeTokenPeriodic.test.ts b/packages/7715-permission-types/test/permissions/caveats/nativeTokenPeriodic.test.ts index 430b65ea..30da7896 100644 --- a/packages/7715-permission-types/test/permissions/caveats/nativeTokenPeriodic.test.ts +++ b/packages/7715-permission-types/test/permissions/caveats/nativeTokenPeriodic.test.ts @@ -1,350 +1,177 @@ -import { - createNativeTokenPeriodTransferTerms, - createTimestampTerms, -} from '@metamask/delegation-core'; -import type { Hex } from '@metamask/delegation-core'; +import { createNativeTokenPeriodTransferTerms } from '@metamask/delegation-core'; import { CHAIN_ID, DELEGATOR_CONTRACTS, } from '@metamask/delegation-deployments'; +import type { Hex } from '@metamask/utils'; +import { describe, it, expect } from 'vitest'; + +import { makePermissionDecoderConfigs } from '../../../src/permissions'; +import { makeNativeTokenPeriodicDecoderConfig } from '../../../src/permissions/caveats/nativeTokenPeriodic'; +import { expiryRule } from '../../../src/permissions/rules/expiry'; +import { nativePayeeRuleDecoder } from '../../../src/permissions/rules/payee'; +import { redeemerRuleDecoder } from '../../../src/permissions/rules/redeemer'; +import type { ChecksumCaveat } from '../../../src/permissions/types'; +import { + getChecksumEnforcersByChainId, + MAX_PERIOD_DURATION, +} from '../../../src/permissions/utils'; +import { toWord } from '../../test-utils'; -import { createPermissionDecodersForContracts } from '../../../src/permissions'; -import { MAX_PERIOD_DURATION } from '../../../src/permissions/utils'; - -describe('native-token-periodic decoder', () => { +describe('native-token-periodic decoder config', () => { const chainId = CHAIN_ID.sepolia; const contracts = DELEGATOR_CONTRACTS['1.3.0'][chainId]; const { - TimestampEnforcer, - NativeTokenPeriodTransferEnforcer, - ExactCalldataEnforcer, - } = contracts; - const permissionDecoders = createPermissionDecodersForContracts(contracts); - const decoder = permissionDecoders.find( - (candidate) => candidate.permissionType === 'native-token-periodic', + timestampEnforcer, + nativeTokenPeriodicEnforcer, + exactCalldataEnforcer, + nonceEnforcer, + allowedTargetsEnforcer, + redeemerEnforcer, + } = getChecksumEnforcersByChainId(contracts); + const decoder = makeNativeTokenPeriodicDecoderConfig( + getChecksumEnforcersByChainId(contracts), ); - if (!decoder) { - throw new Error('Decoder not found'); - } - - const expiryCaveat = { - enforcer: TimestampEnforcer, - terms: createTimestampTerms({ - afterThreshold: 0, - beforeThreshold: 1720000, - }), - args: '0x' as const, - }; - - const exactCalldataCaveat = { - enforcer: ExactCalldataEnforcer, - terms: '0x' as Hex, - args: '0x' as const, + const START_TIME = 1715664; + const VALID_TERMS = createNativeTokenPeriodTransferTerms( + { + periodAmount: 100n, + periodDuration: 86400, + startDate: START_TIME, + }, + { out: 'hex' }, + ); + const makeTerms = ({ + periodAmount = 100n, + periodDuration = 86400, + startDate = START_TIME, + }: { + periodAmount?: bigint; + periodDuration?: number; + startDate?: number; + }): Hex => { + return `0x${toWord(periodAmount)}${toWord(periodDuration)}${toWord(startDate)}` as Hex; }; - 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 = decoder.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'); + const makeCaveats = ( + nativeTokenPeriodicTerms: Hex, + exactCalldataTerms: Hex = '0x', + ): ChecksumCaveat[] => [ + { + enforcer: nativeTokenPeriodicEnforcer, + terms: nativeTokenPeriodicTerms, + }, + { + enforcer: exactCalldataEnforcer, + terms: exactCalldataTerms, + }, + { + enforcer: nonceEnforcer, + terms: '0x' as const, + }, + ]; + + describe('static configuration', () => { + it('exposes expected required enforcers', () => { + expect(decoder.requiredEnforcers).toStrictEqual({ + [nativeTokenPeriodicEnforcer]: 1, + [exactCalldataEnforcer]: 1, + [nonceEnforcer]: 1, + }); + }); + + it('exposes expected optional enforcers', () => { + expect(decoder.optionalEnforcers).toStrictEqual([ + timestampEnforcer, + redeemerEnforcer, + allowedTargetsEnforcer, + ]); + }); + + it('includes expected rule decoders in order', () => { + expect(decoder.rules).toStrictEqual([ + expiryRule, + redeemerRuleDecoder, + nativePayeeRuleDecoder, + ]); + }); }); - 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 = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - // this is here as a type guard - if (result.isValid) { - throw new Error('Expected invalid result'); - } + describe('validateAndDecodeData', () => { + it('provides a validateAndDecodeData function', () => { + expect(typeof decoder.validateAndDecodeData).toBe('function'); + }); - expect(result.error.message).toContain( - 'Invalid NativeTokenPeriodTransfer terms: must be exactly 96 bytes', - ); - }); + it('is included in makePermissionDecoderConfigs', () => { + expect(makePermissionDecoderConfigs(contracts)).toContainEqual(decoder); + }); - it('rejects when terms have trailing bytes', () => { - const validTerms = createNativeTokenPeriodTransferTerms( - { + it('validateAndDecodeData decodes valid periodic terms', () => { + expect( + decoder.validateAndDecodeData( + makeCaveats(VALID_TERMS), + decoder.contractAddresses, + ), + ).toStrictEqual({ 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 = decoder.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 NativeTokenPeriodTransfer terms: must be exactly 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' }, + startTime: START_TIME, + }); + }); + + it('validateAndDecodeData rejects exact-calldata terms that are not 0x', () => { + expect(() => + decoder.validateAndDecodeData( + makeCaveats(VALID_TERMS, '0x00'), + decoder.contractAddresses, ), - args: '0x' as const, - }, - ]; - const result = decoder.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' }, + ).toThrow('Invalid exact-calldata terms: must be 0x'); + }); + + it('validateAndDecodeData rejects when periodDuration is zero', () => { + expect(() => + decoder.validateAndDecodeData( + makeCaveats(makeTerms({ periodDuration: 0 })), + decoder.contractAddresses, ), - args: '0x' as const, - }, - ]; - - const result = decoder.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 = decoder.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 = decoder.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 = decoder.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', - ); - }); - - it('rejects when periodDuration exceeds MAX_PERIOD_DURATION', () => { - const terms = createNativeTokenPeriodTransferTerms( - { - periodAmount: 100n, - periodDuration: MAX_PERIOD_DURATION + 1, - startDate: 1715664, - }, - { out: 'hex' }, - ); - - const caveats = [ - expiryCaveat, - exactCalldataCaveat, - { - enforcer: NativeTokenPeriodTransferEnforcer, - terms, - args: '0x' as const, - }, - ]; - - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - // this is here as a type guard - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error.message).toContain( - 'Invalid native-token-periodic terms: periodDuration must be less than or equal to MAX_PERIOD_DURATION', - ); - }); - - it('accepts when periodDuration equals MAX_PERIOD_DURATION', () => { - const caveats = [ - expiryCaveat, - exactCalldataCaveat, - { - enforcer: NativeTokenPeriodTransferEnforcer, - terms: createNativeTokenPeriodTransferTerms( - { - periodAmount: 100n, - periodDuration: MAX_PERIOD_DURATION, - startDate: 1715664, - }, - { out: 'hex' }, + ).toThrow( + 'Invalid native-token-periodic terms: periodDuration must be a positive number', + ); + }); + + it('validateAndDecodeData rejects when periodAmount is zero', () => { + expect(() => + decoder.validateAndDecodeData( + makeCaveats(makeTerms({ periodAmount: 0n })), + decoder.contractAddresses, ), - args: '0x' as const, - }, - ]; - - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(true); - - // this is here as a type guard - if (!result.isValid) { - throw new Error('Expected valid result'); - } - - expect(result.data.periodDuration).toBe(MAX_PERIOD_DURATION); + ).toThrow( + 'Invalid native-token-periodic terms: periodAmount must be a positive number', + ); + }); + + it('validateAndDecodeData rejects when periodDuration exceeds MAX_PERIOD_DURATION', () => { + expect(() => + decoder.validateAndDecodeData( + makeCaveats(makeTerms({ periodDuration: MAX_PERIOD_DURATION + 1 })), + decoder.contractAddresses, + ), + ).toThrow( + 'Invalid native-token-periodic terms: periodDuration must be less than or equal to MAX_PERIOD_DURATION', + ); + }); + + it('validateAndDecodeData accepts periodDuration equal to MAX_PERIOD_DURATION', () => { + expect( + decoder.validateAndDecodeData( + makeCaveats(makeTerms({ periodDuration: MAX_PERIOD_DURATION })), + decoder.contractAddresses, + ), + ).toStrictEqual({ + periodAmount: 100n, + periodDuration: MAX_PERIOD_DURATION, + startTime: START_TIME, + }); + }); }); }); diff --git a/packages/7715-permission-types/test/permissions/caveats/nativeTokenStream.test.ts b/packages/7715-permission-types/test/permissions/caveats/nativeTokenStream.test.ts index 0cd7b524..82be7993 100644 --- a/packages/7715-permission-types/test/permissions/caveats/nativeTokenStream.test.ts +++ b/packages/7715-permission-types/test/permissions/caveats/nativeTokenStream.test.ts @@ -1,477 +1,165 @@ -import { - createNativeTokenStreamingTerms, - createTimestampTerms, -} from '@metamask/delegation-core'; -import type { Hex } from '@metamask/delegation-core'; +import { createNativeTokenStreamingTerms } from '@metamask/delegation-core'; import { CHAIN_ID, DELEGATOR_CONTRACTS, } from '@metamask/delegation-deployments'; - -import { createPermissionDecodersForContracts } from '../../../src/permissions'; - -describe('native-token-stream decoder', () => { +import type { Hex } from '@metamask/utils'; +import { describe, it, expect } from 'vitest'; + +import { makePermissionDecoderConfigs } from '../../../src/permissions'; +import { makeNativeTokenStreamDecoderConfig } from '../../../src/permissions/caveats/nativeTokenStream'; +import { expiryRule } from '../../../src/permissions/rules/expiry'; +import { nativePayeeRuleDecoder } from '../../../src/permissions/rules/payee'; +import { redeemerRuleDecoder } from '../../../src/permissions/rules/redeemer'; +import type { ChecksumCaveat } from '../../../src/permissions/types'; +import { getChecksumEnforcersByChainId } from '../../../src/permissions/utils'; +import { toWord } from '../../test-utils'; + +describe('native-token-stream decoder config', () => { const chainId = CHAIN_ID.sepolia; const contracts = DELEGATOR_CONTRACTS['1.3.0'][chainId]; const { - TimestampEnforcer, - NativeTokenStreamingEnforcer, - ExactCalldataEnforcer, - } = contracts; - const permissionDecoders = createPermissionDecodersForContracts(contracts); - const decoder = permissionDecoders.find( - (candidate) => candidate.permissionType === 'native-token-stream', + timestampEnforcer, + nativeTokenStreamingEnforcer, + exactCalldataEnforcer, + nonceEnforcer, + allowedTargetsEnforcer, + redeemerEnforcer, + } = getChecksumEnforcersByChainId(contracts); + const decoder = makeNativeTokenStreamDecoderConfig( + getChecksumEnforcersByChainId(contracts), ); - if (!decoder) { - throw new Error('Decoder not found'); - } - - const expiryCaveat = { - enforcer: TimestampEnforcer, - terms: createTimestampTerms({ - afterThreshold: 0, - beforeThreshold: 1720000, - }), - args: '0x' as const, - }; - - const exactCalldataCaveat = { - enforcer: ExactCalldataEnforcer, - terms: '0x' as Hex, - args: '0x' as const, + const START_TIME = 1715664; + const VALID_TERMS = createNativeTokenStreamingTerms( + { + initialAmount: 10n, + maxAmount: 100n, + amountPerSecond: 5n, + startTime: START_TIME, + }, + { out: 'hex' }, + ); + const makeTerms = ({ + initialAmount = 10n, + maxAmount = 100n, + amountPerSecond = 5n, + startTime = START_TIME, + }: { + initialAmount?: bigint; + maxAmount?: bigint; + amountPerSecond?: bigint; + startTime?: number; + }): Hex => { + return `0x${toWord(initialAmount)}${toWord(maxAmount)}${toWord(amountPerSecond)}${toWord(startTime)}` as Hex; }; - it('rejects duplicate caveats for same enforcer (e.g. two TimestampEnforcer)', () => { - const caveats = [ - expiryCaveat, - exactCalldataCaveat, - { - enforcer: TimestampEnforcer, - terms: createTimestampTerms({ - afterThreshold: 0, - beforeThreshold: 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 = decoder.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'); + const makeCaveats = ( + nativeTokenStreamingTerms: Hex, + exactCalldataTerms: Hex = '0x', + ): ChecksumCaveat[] => [ + { + enforcer: nativeTokenStreamingEnforcer, + terms: nativeTokenStreamingTerms, + }, + { + enforcer: exactCalldataEnforcer, + terms: exactCalldataTerms, + }, + { + enforcer: nonceEnforcer, + terms: '0x' as const, + }, + ]; + + describe('static configuration', () => { + it('exposes expected required enforcers', () => { + expect(decoder.requiredEnforcers).toStrictEqual({ + [nativeTokenStreamingEnforcer]: 1, + [exactCalldataEnforcer]: 1, + [nonceEnforcer]: 1, + }); + }); + + it('exposes expected optional enforcers', () => { + expect(decoder.optionalEnforcers).toStrictEqual([ + timestampEnforcer, + redeemerEnforcer, + allowedTargetsEnforcer, + ]); + }); + + it('includes expected rule decoders in order', () => { + expect(decoder.rules).toStrictEqual([ + expiryRule, + redeemerRuleDecoder, + nativePayeeRuleDecoder, + ]); + }); }); - 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 = decoder.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 = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); + describe('validateAndDecodeData', () => { + it('provides a validateAndDecodeData function', () => { + expect(typeof decoder.validateAndDecodeData).toBe('function'); + }); - // this is here as a type guard - if (result.isValid) { - throw new Error('Expected invalid result'); - } + it('is included in makePermissionDecoderConfigs', () => { + expect(makePermissionDecoderConfigs(contracts)).toContainEqual(decoder); + }); - expect(result.error.message).toContain( - 'Invalid NativeTokenStreaming terms: must be exactly 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 = decoder.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 NativeTokenStreaming terms: must be exactly 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' }, + it('validateAndDecodeData decodes valid stream terms', () => { + expect( + decoder.validateAndDecodeData( + makeCaveats(VALID_TERMS), + decoder.contractAddresses, ), - args: '0x' as const, - }, - ]; - const result = decoder.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' }, + ).toStrictEqual({ + initialAmount: 10n, + maxAmount: 100n, + amountPerSecond: 5n, + startTime: START_TIME, + }); + }); + + it('validateAndDecodeData rejects exact-calldata terms that are not 0x', () => { + expect(() => + decoder.validateAndDecodeData( + makeCaveats(VALID_TERMS, '0x00'), + decoder.contractAddresses, ), - args: '0x' as const, - }, - ]; - const result = decoder.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' }, + ).toThrow('Invalid exact-calldata terms: must be 0x'); + }); + + it('validateAndDecodeData rejects when maxAmount equals initialAmount', () => { + expect(() => + decoder.validateAndDecodeData( + makeCaveats(makeTerms({ initialAmount: 100n, maxAmount: 100n })), + decoder.contractAddresses, ), - args: '0x' as const, - }, - ]; - - const result = decoder.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({ - afterThreshold: 0, - beforeThreshold: 0, - }), - args: '0x' as const, - }, - { - enforcer: NativeTokenStreamingEnforcer, - terms: createNativeTokenStreamingTerms( - { - initialAmount: 1n, - maxAmount: 2n, - amountPerSecond: 1n, - startTime: 1715664, - }, - { out: 'hex' }, + ).toThrow( + 'Invalid native-token-stream terms: maxAmount must be greater than initialAmount', + ); + }); + + it('validateAndDecodeData rejects when amountPerSecond is zero', () => { + expect(() => + decoder.validateAndDecodeData( + makeCaveats(makeTerms({ amountPerSecond: 0n })), + decoder.contractAddresses, ), - args: '0x' as const, - }, - ]; - - const result = decoder.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({ - afterThreshold: 1, - beforeThreshold: 1720000, - }), - args: '0x' as const, - }, - { - enforcer: NativeTokenStreamingEnforcer, - terms: createNativeTokenStreamingTerms( - { - initialAmount: 1n, - maxAmount: 2n, - amountPerSecond: 1n, - startTime: 1715664, - }, - { out: 'hex' }, + ).toThrow( + 'Invalid native-token-stream terms: amountPerSecond must be a positive number', + ); + }); + + it('validateAndDecodeData rejects when startTime is zero', () => { + expect(() => + decoder.validateAndDecodeData( + makeCaveats(makeTerms({ startTime: 0 })), + decoder.contractAddresses, ), - args: '0x' as const, - }, - ]; - - const result = decoder.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 = decoder.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 when maxAmount equals initialAmount', () => { - const initialAmountAndMaxAmount = 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${initialAmountAndMaxAmount}${initialAmountAndMaxAmount}${amountPerSecondHex}${startTimeHex}` as Hex; - const caveats = [ - expiryCaveat, - exactCalldataCaveat, - { enforcer: NativeTokenStreamingEnforcer, terms, args: '0x' as const }, - ]; - const result = decoder.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 = decoder.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 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 = decoder.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 = decoder.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', - ); + ).toThrow( + 'Invalid native-token-stream terms: startTime must be a positive number', + ); + }); }); }); diff --git a/packages/7715-permission-types/test/permissions/caveats/tokenApprovalRevocation.test.ts b/packages/7715-permission-types/test/permissions/caveats/tokenApprovalRevocation.test.ts index add3bd15..6e26662a 100644 --- a/packages/7715-permission-types/test/permissions/caveats/tokenApprovalRevocation.test.ts +++ b/packages/7715-permission-types/test/permissions/caveats/tokenApprovalRevocation.test.ts @@ -1,184 +1,123 @@ -import { createTimestampTerms } from '@metamask/delegation-core'; import { CHAIN_ID, DELEGATOR_CONTRACTS, } from '@metamask/delegation-deployments'; +import type { Hex } from '@metamask/utils'; +import { describe, it, expect } from 'vitest'; -import { createPermissionDecodersForContracts } from '../../../src/permissions'; +import { makePermissionDecoderConfigs } from '../../../src/permissions'; +import { makeTokenApprovalRevocationDecoderConfig } from '../../../src/permissions/caveats/tokenApprovalRevocation'; +import { expiryRule } from '../../../src/permissions/rules/expiry'; +import type { ChecksumCaveat } from '../../../src/permissions/types'; import { getChecksumEnforcersByChainId } from '../../../src/permissions/utils'; -describe('token-approval-revocation decoder', () => { +describe('token-approval-revocation decoder config', () => { const chainId = CHAIN_ID.sepolia; const contracts = DELEGATOR_CONTRACTS['1.3.0'][chainId]; const { timestampEnforcer, approvalRevocationEnforcer, nonceEnforcer } = getChecksumEnforcersByChainId(contracts); - const permissionDecoders = createPermissionDecodersForContracts(contracts); - const decoder = permissionDecoders.find( - (candidate) => candidate.permissionType === 'token-approval-revocation', + const decoder = makeTokenApprovalRevocationDecoderConfig( + getChecksumEnforcersByChainId(contracts), ); - if (!decoder) { - throw new Error('Decoder not found'); - } - - const expiryCaveat = { - enforcer: timestampEnforcer, - terms: createTimestampTerms({ - afterThreshold: 0, - beforeThreshold: 1720000, - }), - args: '0x' as const, - }; - - it('rejects empty terms', () => { - const caveats = [ - expiryCaveat, - { - enforcer: approvalRevocationEnforcer, - terms: '0x' as const, - args: '0x' as const, - }, - { - enforcer: nonceEnforcer, - terms: '0x' as const, - args: '0x' as const, - }, - ]; - - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error.message).toContain( - 'Invalid ApprovalRevocation terms: must be exactly 1 byte', - ); - }); - - it('rejects 0x00 terms', () => { - const caveats = [ - expiryCaveat, - { - enforcer: approvalRevocationEnforcer, - terms: '0x00' as const, - args: '0x' as const, - }, - { - enforcer: nonceEnforcer, - terms: '0x' as const, - args: '0x' as const, - }, - ]; - - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error.message).toContain( - 'Invalid ApprovalRevocation terms: at least one revocation primitive must be enabled', - ); - }); - - it('rejects terms whose mask exceeds the supported max', () => { - const caveats = [ - expiryCaveat, - { - enforcer: approvalRevocationEnforcer, - terms: '0x40' as const, - args: '0x' as const, - }, - { - enforcer: nonceEnforcer, - terms: '0x' as const, - args: '0x' as const, - }, - ]; - - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); + const makeCaveats = (approvalRevocationTerms: Hex): ChecksumCaveat[] => [ + { + enforcer: approvalRevocationEnforcer, + terms: approvalRevocationTerms, + }, + { + enforcer: nonceEnforcer, + terms: '0x' as const, + }, + ]; + + describe('static configuration', () => { + it('exposes expected required enforcers', () => { + expect(decoder.requiredEnforcers).toStrictEqual({ + [approvalRevocationEnforcer]: 1, + [nonceEnforcer]: 1, + }); + }); - if (result.isValid) { - throw new Error('Expected invalid result'); - } + it('exposes expected optional enforcers', () => { + expect(decoder.optionalEnforcers).toStrictEqual([timestampEnforcer]); + }); - expect(result.error.message).toContain( - 'Invalid ApprovalRevocation terms: reserved bits must be zero (only bits 0-5 are defined)', - ); + it('includes expected rule decoders in order', () => { + expect(decoder.rules).toStrictEqual([expiryRule]); + }); }); - it('successfully decodes valid token-approval-revocation caveats', () => { - const caveats = [ - expiryCaveat, - { - enforcer: approvalRevocationEnforcer, - terms: '0x01' as const, - args: '0x' as const, - }, - { - enforcer: nonceEnforcer, - terms: '0x' as const, - args: '0x' as const, - }, - ]; - - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(true); + describe('validateAndDecodeData', () => { + it('provides a validateAndDecodeData function', () => { + expect(typeof decoder.validateAndDecodeData).toBe('function'); + }); - if (!result.isValid) { - throw new Error('Expected valid result'); - } + it('is included in makePermissionDecoderConfigs', () => { + expect(makePermissionDecoderConfigs(contracts)).toContainEqual(decoder); + }); - expect(result.expiry).toBe(1720000); - expect(result.data).toStrictEqual({ - erc20Approve: true, - erc721Approve: false, - erc721SetApprovalForAll: false, - permit2Approve: false, - permit2Lockdown: false, - permit2InvalidateNonces: false, + it('validateAndDecodeData rejects empty terms', () => { + expect(() => + decoder.validateAndDecodeData( + makeCaveats('0x'), + decoder.contractAddresses, + ), + ).toThrow('Invalid ApprovalRevocation terms: must be exactly 1 byte'); }); - expect(result.rules).toStrictEqual([ - { - type: 'expiry', - data: { timestamp: 1720000 }, - }, - ]); - }); - it('decodes all supported flags from the terms bitmask', () => { - const caveats = [ - expiryCaveat, - { - enforcer: approvalRevocationEnforcer, - terms: '0x3f' as const, - args: '0x' as const, - }, - { - enforcer: nonceEnforcer, - terms: '0x' as const, - args: '0x' as const, - }, - ]; + it('validateAndDecodeData rejects 0x00 terms', () => { + expect(() => + decoder.validateAndDecodeData( + makeCaveats('0x00'), + decoder.contractAddresses, + ), + ).toThrow( + 'Invalid ApprovalRevocation terms: at least one revocation primitive must be enabled', + ); + }); - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(true); + it('validateAndDecodeData rejects terms whose mask exceeds the supported max', () => { + expect(() => + decoder.validateAndDecodeData( + makeCaveats('0x40'), + decoder.contractAddresses, + ), + ).toThrow( + 'Invalid ApprovalRevocation terms: reserved bits must be zero (only bits 0-5 are defined)', + ); + }); - if (!result.isValid) { - throw new Error('Expected valid result'); - } + it('validateAndDecodeData decodes a single enabled flag', () => { + expect( + decoder.validateAndDecodeData( + makeCaveats('0x01'), + decoder.contractAddresses, + ), + ).toStrictEqual({ + erc20Approve: true, + erc721Approve: false, + erc721SetApprovalForAll: false, + permit2Approve: false, + permit2Lockdown: false, + permit2InvalidateNonces: false, + }); + }); - expect(result.data).toStrictEqual({ - erc20Approve: true, - erc721Approve: true, - erc721SetApprovalForAll: true, - permit2Approve: true, - permit2Lockdown: true, - permit2InvalidateNonces: true, + it('validateAndDecodeData decodes all supported flags', () => { + expect( + decoder.validateAndDecodeData( + makeCaveats('0x3f'), + decoder.contractAddresses, + ), + ).toStrictEqual({ + erc20Approve: true, + erc721Approve: true, + erc721SetApprovalForAll: true, + permit2Approve: true, + permit2Lockdown: true, + permit2InvalidateNonces: true, + }); }); }); }); diff --git a/packages/7715-permission-types/test/permissions/rules/erc20Payee.test.ts b/packages/7715-permission-types/test/permissions/rules/erc20Payee.test.ts index 98baf742..736953b1 100644 --- a/packages/7715-permission-types/test/permissions/rules/erc20Payee.test.ts +++ b/packages/7715-permission-types/test/permissions/rules/erc20Payee.test.ts @@ -5,6 +5,7 @@ import { } from '@metamask/delegation-deployments'; import { getChecksumAddress } from '@metamask/utils'; import type { Hex } from '@metamask/utils'; +import { describe, it, expect } from 'vitest'; import { erc20PayeeRuleDecoder } from '../../../src/permissions/rules/payee'; import type { ChecksumCaveat } from '../../../src/permissions/types'; diff --git a/packages/7715-permission-types/test/permissions/rules/expiry.test.ts b/packages/7715-permission-types/test/permissions/rules/expiry.test.ts index 18f3434b..230986cf 100644 --- a/packages/7715-permission-types/test/permissions/rules/expiry.test.ts +++ b/packages/7715-permission-types/test/permissions/rules/expiry.test.ts @@ -4,6 +4,7 @@ import { DELEGATOR_CONTRACTS, } from '@metamask/delegation-deployments'; import type { Hex } from '@metamask/utils'; +import { describe, it, expect } from 'vitest'; import { expiryRule } from '../../../src/permissions/rules/expiry'; import type { ChecksumCaveat } from '../../../src/permissions/types'; diff --git a/packages/7715-permission-types/test/permissions/rules/nativePayee.test.ts b/packages/7715-permission-types/test/permissions/rules/nativePayee.test.ts index 65dce57f..4591544b 100644 --- a/packages/7715-permission-types/test/permissions/rules/nativePayee.test.ts +++ b/packages/7715-permission-types/test/permissions/rules/nativePayee.test.ts @@ -5,6 +5,7 @@ import { } from '@metamask/delegation-deployments'; import { getChecksumAddress } from '@metamask/utils'; import type { Hex } from '@metamask/utils'; +import { describe, it, expect } from 'vitest'; import { nativePayeeRuleDecoder } from '../../../src/permissions/rules/payee'; import type { ChecksumCaveat } from '../../../src/permissions/types'; diff --git a/packages/7715-permission-types/test/permissions/rules/redeemer.test.ts b/packages/7715-permission-types/test/permissions/rules/redeemer.test.ts index aa80df2e..e8ff9ff9 100644 --- a/packages/7715-permission-types/test/permissions/rules/redeemer.test.ts +++ b/packages/7715-permission-types/test/permissions/rules/redeemer.test.ts @@ -5,6 +5,7 @@ import { } from '@metamask/delegation-deployments'; import { getChecksumAddress } from '@metamask/utils'; import type { Hex } from '@metamask/utils'; +import { describe, it, expect } from 'vitest'; import { redeemerRuleDecoder } from '../../../src/permissions/rules/redeemer'; import type { ChecksumCaveat } from '../../../src/permissions/types'; diff --git a/packages/7715-permission-types/test/test-utils.ts b/packages/7715-permission-types/test/test-utils.ts new file mode 100644 index 00000000..980e7b3d --- /dev/null +++ b/packages/7715-permission-types/test/test-utils.ts @@ -0,0 +1,2 @@ +export const toWord = (value: bigint | number): string => + BigInt(value).toString(16).padStart(64, '0'); From e37bee744a4415de651c86fe17567c13e74fcdc3 Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Tue, 9 Jun 2026 11:50:26 +1200 Subject: [PATCH 05/16] Remove more cruft --- packages/7715-permission-types/src/index.ts | 37 +---------------- .../caveats/erc20TokenAllowance.ts | 4 +- .../permissions/caveats/erc20TokenPeriodic.ts | 4 +- .../permissions/caveats/erc20TokenStream.ts | 4 +- .../caveats/nativeTokenAllowance.ts | 4 +- .../caveats/nativeTokenPeriodic.ts | 4 +- .../permissions/caveats/nativeTokenStream.ts | 4 +- .../caveats/tokenApprovalRevocation.ts | 4 +- .../src/permissions/index.ts | 36 ---------------- .../src/permissions/types.ts | 41 ++++--------------- 10 files changed, 24 insertions(+), 118 deletions(-) diff --git a/packages/7715-permission-types/src/index.ts b/packages/7715-permission-types/src/index.ts index 892075ae..90301adc 100644 --- a/packages/7715-permission-types/src/index.ts +++ b/packages/7715-permission-types/src/index.ts @@ -19,39 +19,6 @@ export type { MetaMaskBasePermissionData, } from './types'; -export type { - Caveat, - ChecksumCaveat, - ChecksumEnforcersByChainId, - DeployedContractsByName, - DecodedPermission, - MakePermissionDecoderConfig, - PermissionDecoder, - PermissionDecoderConfig, - PermissionDecoderSpec, - PermissionType, - RuleDecoder, - ValidateAndDecodeResult, - PayeeRule, - RedeemerRule, - ExpiryRule, -} from './permissions'; +export type { PayeeRule, RedeemerRule, ExpiryRule } from './permissions'; -export { - EXECUTION_PERMISSION_EXPIRY_RULE_TYPE, - expiryRule, -} from './permissions'; -export { - EXECUTION_PERMISSION_PAYEE_RULE_TYPE, - erc20PayeeRuleDecoder, - nativePayeeRuleDecoder, - EXECUTION_PERMISSION_REDEEMER_RULE_TYPE, - redeemerRuleDecoder, - makeNativeTokenStreamDecoderConfig, - makeNativeTokenPeriodicDecoderConfig, - makeNativeTokenAllowanceDecoderConfig, - makeErc20TokenStreamDecoderConfig, - makeErc20TokenPeriodicDecoderConfig, - makeErc20TokenAllowanceDecoderConfig, - makeTokenApprovalRevocationDecoderConfig, -} from './permissions'; +export { makePermissionDecoderConfigs } from './permissions'; diff --git a/packages/7715-permission-types/src/permissions/caveats/erc20TokenAllowance.ts b/packages/7715-permission-types/src/permissions/caveats/erc20TokenAllowance.ts index 3871d773..c018c191 100644 --- a/packages/7715-permission-types/src/permissions/caveats/erc20TokenAllowance.ts +++ b/packages/7715-permission-types/src/permissions/caveats/erc20TokenAllowance.ts @@ -4,7 +4,7 @@ import { redeemerRuleDecoder } from '../rules/redeemer'; import type { ChecksumCaveat, ChecksumEnforcersByChainId, - DecodedPermission, + DecodedPermissionData, MakePermissionDecoderConfig, } from '../types'; import { @@ -61,7 +61,7 @@ export function makeErc20TokenAllowanceDecoderConfig( function validateAndDecodeData( caveats: ChecksumCaveat[], contractAddresses: ChecksumEnforcersByChainId, -): DecodedPermission['permission']['data'] { +): DecodedPermissionData { const { erc20PeriodicEnforcer, valueLteEnforcer } = contractAddresses; const valueLteTerms = getTermsByEnforcer({ diff --git a/packages/7715-permission-types/src/permissions/caveats/erc20TokenPeriodic.ts b/packages/7715-permission-types/src/permissions/caveats/erc20TokenPeriodic.ts index ef47872f..43b15ef9 100644 --- a/packages/7715-permission-types/src/permissions/caveats/erc20TokenPeriodic.ts +++ b/packages/7715-permission-types/src/permissions/caveats/erc20TokenPeriodic.ts @@ -6,7 +6,7 @@ import { redeemerRuleDecoder } from '../rules/redeemer'; import type { ChecksumCaveat, ChecksumEnforcersByChainId, - DecodedPermission, + DecodedPermissionData, MakePermissionDecoderConfig, } from '../types'; import { @@ -61,7 +61,7 @@ export function makeErc20TokenPeriodicDecoderConfig( function validateAndDecodeData( caveats: ChecksumCaveat[], contractAddresses: ChecksumEnforcersByChainId, -): DecodedPermission['permission']['data'] { +): DecodedPermissionData { const { erc20PeriodicEnforcer, valueLteEnforcer } = contractAddresses; const valueLteTerms = getTermsByEnforcer({ diff --git a/packages/7715-permission-types/src/permissions/caveats/erc20TokenStream.ts b/packages/7715-permission-types/src/permissions/caveats/erc20TokenStream.ts index 13a0552e..d2b25c80 100644 --- a/packages/7715-permission-types/src/permissions/caveats/erc20TokenStream.ts +++ b/packages/7715-permission-types/src/permissions/caveats/erc20TokenStream.ts @@ -6,7 +6,7 @@ import { redeemerRuleDecoder } from '../rules/redeemer'; import type { ChecksumCaveat, ChecksumEnforcersByChainId, - DecodedPermission, + DecodedPermissionData, MakePermissionDecoderConfig, } from '../types'; import { getTermsByEnforcer, ZERO_32_BYTES } from '../utils'; @@ -57,7 +57,7 @@ export function makeErc20TokenStreamDecoderConfig( function validateAndDecodeData( caveats: ChecksumCaveat[], contractAddresses: ChecksumEnforcersByChainId, -): DecodedPermission['permission']['data'] { +): DecodedPermissionData { const { erc20StreamingEnforcer, valueLteEnforcer } = contractAddresses; const valueLteTerms = getTermsByEnforcer({ diff --git a/packages/7715-permission-types/src/permissions/caveats/nativeTokenAllowance.ts b/packages/7715-permission-types/src/permissions/caveats/nativeTokenAllowance.ts index 35d0f228..aa38559d 100644 --- a/packages/7715-permission-types/src/permissions/caveats/nativeTokenAllowance.ts +++ b/packages/7715-permission-types/src/permissions/caveats/nativeTokenAllowance.ts @@ -6,7 +6,7 @@ import { redeemerRuleDecoder } from '../rules/redeemer'; import type { ChecksumCaveat, ChecksumEnforcersByChainId, - DecodedPermission, + DecodedPermissionData, MakePermissionDecoderConfig, } from '../types'; import { @@ -63,7 +63,7 @@ export function makeNativeTokenAllowanceDecoderConfig( function validateAndDecodeData( caveats: ChecksumCaveat[], contractAddresses: ChecksumEnforcersByChainId, -): DecodedPermission['permission']['data'] { +): DecodedPermissionData { const { nativeTokenPeriodicEnforcer, exactCalldataEnforcer } = contractAddresses; diff --git a/packages/7715-permission-types/src/permissions/caveats/nativeTokenPeriodic.ts b/packages/7715-permission-types/src/permissions/caveats/nativeTokenPeriodic.ts index 62d7039c..17de4caf 100644 --- a/packages/7715-permission-types/src/permissions/caveats/nativeTokenPeriodic.ts +++ b/packages/7715-permission-types/src/permissions/caveats/nativeTokenPeriodic.ts @@ -6,7 +6,7 @@ import { redeemerRuleDecoder } from '../rules/redeemer'; import type { ChecksumCaveat, ChecksumEnforcersByChainId, - DecodedPermission, + DecodedPermissionData, MakePermissionDecoderConfig, } from '../types'; import { getTermsByEnforcer, MAX_PERIOD_DURATION } from '../utils'; @@ -57,7 +57,7 @@ export function makeNativeTokenPeriodicDecoderConfig( function validateAndDecodeData( caveats: ChecksumCaveat[], contractAddresses: ChecksumEnforcersByChainId, -): DecodedPermission['permission']['data'] { +): DecodedPermissionData { const { nativeTokenPeriodicEnforcer, exactCalldataEnforcer } = contractAddresses; diff --git a/packages/7715-permission-types/src/permissions/caveats/nativeTokenStream.ts b/packages/7715-permission-types/src/permissions/caveats/nativeTokenStream.ts index 28314f38..f0431a15 100644 --- a/packages/7715-permission-types/src/permissions/caveats/nativeTokenStream.ts +++ b/packages/7715-permission-types/src/permissions/caveats/nativeTokenStream.ts @@ -6,7 +6,7 @@ import { redeemerRuleDecoder } from '../rules/redeemer'; import type { ChecksumCaveat, ChecksumEnforcersByChainId, - DecodedPermission, + DecodedPermissionData, MakePermissionDecoderConfig, } from '../types'; import { getTermsByEnforcer } from '../utils'; @@ -57,7 +57,7 @@ export function makeNativeTokenStreamDecoderConfig( function validateAndDecodeData( caveats: ChecksumCaveat[], contractAddresses: ChecksumEnforcersByChainId, -): DecodedPermission['permission']['data'] { +): DecodedPermissionData { const { nativeTokenStreamingEnforcer, exactCalldataEnforcer } = contractAddresses; diff --git a/packages/7715-permission-types/src/permissions/caveats/tokenApprovalRevocation.ts b/packages/7715-permission-types/src/permissions/caveats/tokenApprovalRevocation.ts index 85530849..fc622071 100644 --- a/packages/7715-permission-types/src/permissions/caveats/tokenApprovalRevocation.ts +++ b/packages/7715-permission-types/src/permissions/caveats/tokenApprovalRevocation.ts @@ -4,7 +4,7 @@ import { expiryRule } from '../rules/expiry'; import type { ChecksumCaveat, ChecksumEnforcersByChainId, - DecodedPermission, + DecodedPermissionData, MakePermissionDecoderConfig, } from '../types'; import { getTermsByEnforcer } from '../utils'; @@ -46,7 +46,7 @@ export function makeTokenApprovalRevocationDecoderConfig( function validateAndDecodeData( caveats: ChecksumCaveat[], contractAddresses: ChecksumEnforcersByChainId, -): DecodedPermission['permission']['data'] { +): DecodedPermissionData { const { approvalRevocationEnforcer } = contractAddresses; const terms = getTermsByEnforcer({ diff --git a/packages/7715-permission-types/src/permissions/index.ts b/packages/7715-permission-types/src/permissions/index.ts index 5c4f3913..4c891150 100644 --- a/packages/7715-permission-types/src/permissions/index.ts +++ b/packages/7715-permission-types/src/permissions/index.ts @@ -8,46 +8,10 @@ import { makeTokenApprovalRevocationDecoderConfig } from './caveats/tokenApprova import type { DeployedContractsByName, PermissionDecoderConfig } from './types'; import { getChecksumEnforcersByChainId } from './utils'; -export type { - Caveat, - ChecksumCaveat, - ChecksumEnforcersByChainId, - DeployedContractsByName, - DecodedPermission, - MakePermissionDecoderConfig, - PermissionDecoder, - PermissionDecoderConfig, - PermissionDecoderSpec, - PermissionType, - RuleDecoder, - ValidateAndDecodeResult, -} from './types'; - export type { ExpiryRule } from './rules/expiry'; export type { PayeeRule } from './rules/payee'; export type { RedeemerRule } from './rules/redeemer'; -export { - EXECUTION_PERMISSION_EXPIRY_RULE_TYPE, - expiryRule, -} from './rules/expiry'; -export { - EXECUTION_PERMISSION_PAYEE_RULE_TYPE, - erc20PayeeRuleDecoder, - nativePayeeRuleDecoder, -} from './rules/payee'; -export { - EXECUTION_PERMISSION_REDEEMER_RULE_TYPE, - redeemerRuleDecoder, -} from './rules/redeemer'; -export { makeNativeTokenStreamDecoderConfig } from './caveats/nativeTokenStream'; -export { makeNativeTokenPeriodicDecoderConfig } from './caveats/nativeTokenPeriodic'; -export { makeNativeTokenAllowanceDecoderConfig } from './caveats/nativeTokenAllowance'; -export { makeErc20TokenStreamDecoderConfig } from './caveats/erc20TokenStream'; -export { makeErc20TokenPeriodicDecoderConfig } from './caveats/erc20TokenPeriodic'; -export { makeErc20TokenAllowanceDecoderConfig } from './caveats/erc20TokenAllowance'; -export { makeTokenApprovalRevocationDecoderConfig } from './caveats/tokenApprovalRevocation'; - /** * Builds the canonical set of permission decoders for a chain. * diff --git a/packages/7715-permission-types/src/permissions/types.ts b/packages/7715-permission-types/src/permissions/types.ts index 8dc338d8..5574fb57 100644 --- a/packages/7715-permission-types/src/permissions/types.ts +++ b/packages/7715-permission-types/src/permissions/types.ts @@ -1,12 +1,6 @@ -import type { Hex, PermissionRequest, PermissionTypes, Rule } from '../types'; +import type { Caveat } from '@metamask/delegation-core'; -/** - * Minimal caveat representation used by permission decoders. - */ -export type Caveat = { - enforcer: TEnforcer; - terms: Hex; -}; +import type { Hex, PermissionTypes, Rule } from '../types'; /** * Checksummed enforcer contract addresses for a chain (from getChecksumEnforcersByChainId). @@ -27,36 +21,17 @@ export type ChecksumEnforcersByChainId = { }; /** Caveat with checksummed enforcer address; used by rule decode functions. */ -export type ChecksumCaveat = Caveat; +export type ChecksumCaveat = Caveat; /** - * A partially reconstructed permission object decoded from a permission context. + * Type of the `data` parameter of a decoded permission. */ -export type DecodedPermission = Pick< - PermissionRequest, - 'chainId' | 'from' | 'to' -> & { - permission: Omit< - PermissionRequest['permission'], - 'isAdjustmentAllowed' | 'type' | 'data' - > & { - type: PermissionTypes['type']; - data: PermissionTypes['data']; - justification?: string; - }; - /** - * @deprecated Use `rules` instead. - */ - expiry: number | null; - origin: string; - /** Rules recovered from caveats (e.g. redeemer allowlist). */ - rules?: Rule[]; -}; +export type DecodedPermissionData = PermissionTypes['data']; /** * Supported permission type identifiers that can be decoded from a permission context. */ -export type PermissionType = DecodedPermission['permission']['type']; +export type PermissionType = PermissionTypes['type']; /** * A function that inspects checksummed caveats and optionally produces a Rule. @@ -79,7 +54,7 @@ export type PermissionDecoderConfig = { validateAndDecodeData: ( caveats: ChecksumCaveat[], contractAddresses: ChecksumEnforcersByChainId, - ) => DecodedPermission['permission']['data']; + ) => DecodedPermissionData; }; /** @@ -98,7 +73,7 @@ export type ValidateAndDecodeResult = | { isValid: true; expiry: number | null; - data: DecodedPermission['permission']['data']; + data: DecodedPermissionData; rules?: Rule[]; } | { isValid: false; error: Error }; From 64b17135c8f9b5b027030b47d1914641234fcd4e Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Tue, 9 Jun 2026 14:49:58 +1200 Subject: [PATCH 06/16] Add changelog --- packages/7715-permission-types/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/7715-permission-types/CHANGELOG.md b/packages/7715-permission-types/CHANGELOG.md index 257fba08..c7993153 100644 --- a/packages/7715-permission-types/CHANGELOG.md +++ b/packages/7715-permission-types/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `makePermissionDecoderConfigs` to resolve the `PermissionDecoderConfig`s to be used with @metamask/gator-permissions-controller ([#259](https://github.com/MetaMask/smart-accounts-kit/pull/259)) + ## [0.7.1] ### Fixed From 09f520e8589c3ac97f907f836076593d182e11cf Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Wed, 10 Jun 2026 10:41:30 +1200 Subject: [PATCH 07/16] All permission decoders are now permission-type specific typed, ensuring returned data is appropriately typed. --- .../src/permissions/caveats/erc20TokenAllowance.ts | 3 ++- .../src/permissions/caveats/erc20TokenPeriodic.ts | 11 +++++++++-- .../src/permissions/caveats/erc20TokenStream.ts | 10 ++++++---- .../src/permissions/caveats/nativeTokenAllowance.ts | 3 ++- .../src/permissions/caveats/nativeTokenPeriodic.ts | 10 ++++++++-- .../src/permissions/caveats/nativeTokenStream.ts | 11 +++++++++-- .../permissions/caveats/tokenApprovalRevocation.ts | 3 ++- .../7715-permission-types/src/permissions/types.ts | 4 +++- .../permissions/caveats/erc20TokenPeriodic.test.ts | 6 +++--- .../test/permissions/caveats/erc20TokenStream.test.ts | 8 ++++---- .../permissions/caveats/nativeTokenPeriodic.test.ts | 6 +++--- .../permissions/caveats/nativeTokenStream.test.ts | 8 ++++---- 12 files changed, 55 insertions(+), 28 deletions(-) diff --git a/packages/7715-permission-types/src/permissions/caveats/erc20TokenAllowance.ts b/packages/7715-permission-types/src/permissions/caveats/erc20TokenAllowance.ts index c018c191..f20c55a1 100644 --- a/packages/7715-permission-types/src/permissions/caveats/erc20TokenAllowance.ts +++ b/packages/7715-permission-types/src/permissions/caveats/erc20TokenAllowance.ts @@ -1,3 +1,4 @@ +import type { Erc20TokenAllowancePermission } from '../../types'; import { expiryRule } from '../rules/expiry'; import { erc20PayeeRuleDecoder } from '../rules/payee'; import { redeemerRuleDecoder } from '../rules/redeemer'; @@ -61,7 +62,7 @@ export function makeErc20TokenAllowanceDecoderConfig( function validateAndDecodeData( caveats: ChecksumCaveat[], contractAddresses: ChecksumEnforcersByChainId, -): DecodedPermissionData { +): DecodedPermissionData { const { erc20PeriodicEnforcer, valueLteEnforcer } = contractAddresses; const valueLteTerms = getTermsByEnforcer({ diff --git a/packages/7715-permission-types/src/permissions/caveats/erc20TokenPeriodic.ts b/packages/7715-permission-types/src/permissions/caveats/erc20TokenPeriodic.ts index 43b15ef9..b16e39a0 100644 --- a/packages/7715-permission-types/src/permissions/caveats/erc20TokenPeriodic.ts +++ b/packages/7715-permission-types/src/permissions/caveats/erc20TokenPeriodic.ts @@ -1,5 +1,7 @@ import { decodeERC20TokenPeriodTransferTerms } from '@metamask/delegation-core'; +import { bigIntToHex } from '@metamask/utils'; +import type { Erc20TokenPeriodicPermission } from '../../types'; import { expiryRule } from '../rules/expiry'; import { erc20PayeeRuleDecoder } from '../rules/payee'; import { redeemerRuleDecoder } from '../rules/redeemer'; @@ -61,7 +63,7 @@ export function makeErc20TokenPeriodicDecoderConfig( function validateAndDecodeData( caveats: ChecksumCaveat[], contractAddresses: ChecksumEnforcersByChainId, -): DecodedPermissionData { +): DecodedPermissionData { const { erc20PeriodicEnforcer, valueLteEnforcer } = contractAddresses; const valueLteTerms = getTermsByEnforcer({ @@ -108,5 +110,10 @@ function validateAndDecodeData( ); } - return { tokenAddress, periodAmount, periodDuration, startTime }; + return { + tokenAddress, + periodAmount: bigIntToHex(periodAmount), + periodDuration, + startTime, + }; } diff --git a/packages/7715-permission-types/src/permissions/caveats/erc20TokenStream.ts b/packages/7715-permission-types/src/permissions/caveats/erc20TokenStream.ts index d2b25c80..72f6ca16 100644 --- a/packages/7715-permission-types/src/permissions/caveats/erc20TokenStream.ts +++ b/packages/7715-permission-types/src/permissions/caveats/erc20TokenStream.ts @@ -1,5 +1,7 @@ import { decodeERC20StreamingTerms } from '@metamask/delegation-core'; +import { bigIntToHex } from '@metamask/utils'; +import type { Erc20TokenStreamPermission } from '../../types'; import { expiryRule } from '../rules/expiry'; import { erc20PayeeRuleDecoder } from '../rules/payee'; import { redeemerRuleDecoder } from '../rules/redeemer'; @@ -57,7 +59,7 @@ export function makeErc20TokenStreamDecoderConfig( function validateAndDecodeData( caveats: ChecksumCaveat[], contractAddresses: ChecksumEnforcersByChainId, -): DecodedPermissionData { +): DecodedPermissionData { const { erc20StreamingEnforcer, valueLteEnforcer } = contractAddresses; const valueLteTerms = getTermsByEnforcer({ @@ -96,9 +98,9 @@ function validateAndDecodeData( return { tokenAddress, - initialAmount, - maxAmount, - amountPerSecond, + initialAmount: bigIntToHex(initialAmount), + maxAmount: bigIntToHex(maxAmount), + amountPerSecond: bigIntToHex(amountPerSecond), startTime, }; } diff --git a/packages/7715-permission-types/src/permissions/caveats/nativeTokenAllowance.ts b/packages/7715-permission-types/src/permissions/caveats/nativeTokenAllowance.ts index aa38559d..b80a3788 100644 --- a/packages/7715-permission-types/src/permissions/caveats/nativeTokenAllowance.ts +++ b/packages/7715-permission-types/src/permissions/caveats/nativeTokenAllowance.ts @@ -1,5 +1,6 @@ import { hexToNumber } from '@metamask/utils'; +import type { NativeTokenAllowancePermission } from '../../types'; import { expiryRule } from '../rules/expiry'; import { nativePayeeRuleDecoder } from '../rules/payee'; import { redeemerRuleDecoder } from '../rules/redeemer'; @@ -63,7 +64,7 @@ export function makeNativeTokenAllowanceDecoderConfig( function validateAndDecodeData( caveats: ChecksumCaveat[], contractAddresses: ChecksumEnforcersByChainId, -): DecodedPermissionData { +): DecodedPermissionData { const { nativeTokenPeriodicEnforcer, exactCalldataEnforcer } = contractAddresses; diff --git a/packages/7715-permission-types/src/permissions/caveats/nativeTokenPeriodic.ts b/packages/7715-permission-types/src/permissions/caveats/nativeTokenPeriodic.ts index 17de4caf..d9915b8c 100644 --- a/packages/7715-permission-types/src/permissions/caveats/nativeTokenPeriodic.ts +++ b/packages/7715-permission-types/src/permissions/caveats/nativeTokenPeriodic.ts @@ -1,5 +1,7 @@ import { decodeNativeTokenPeriodTransferTerms } from '@metamask/delegation-core'; +import { bigIntToHex } from '@metamask/utils'; +import type { NativeTokenPeriodicPermission } from '../../types'; import { expiryRule } from '../rules/expiry'; import { nativePayeeRuleDecoder } from '../rules/payee'; import { redeemerRuleDecoder } from '../rules/redeemer'; @@ -57,7 +59,7 @@ export function makeNativeTokenPeriodicDecoderConfig( function validateAndDecodeData( caveats: ChecksumCaveat[], contractAddresses: ChecksumEnforcersByChainId, -): DecodedPermissionData { +): DecodedPermissionData { const { nativeTokenPeriodicEnforcer, exactCalldataEnforcer } = contractAddresses; @@ -104,5 +106,9 @@ function validateAndDecodeData( ); } - return { periodAmount, periodDuration, startTime }; + return { + periodAmount: bigIntToHex(periodAmount), + periodDuration, + startTime, + }; } diff --git a/packages/7715-permission-types/src/permissions/caveats/nativeTokenStream.ts b/packages/7715-permission-types/src/permissions/caveats/nativeTokenStream.ts index f0431a15..fd7f5b79 100644 --- a/packages/7715-permission-types/src/permissions/caveats/nativeTokenStream.ts +++ b/packages/7715-permission-types/src/permissions/caveats/nativeTokenStream.ts @@ -1,5 +1,7 @@ import { decodeNativeTokenStreamingTerms } from '@metamask/delegation-core'; +import { bigIntToHex } from '@metamask/utils'; +import type { NativeTokenStreamPermission } from '../../types'; import { expiryRule } from '../rules/expiry'; import { nativePayeeRuleDecoder } from '../rules/payee'; import { redeemerRuleDecoder } from '../rules/redeemer'; @@ -57,7 +59,7 @@ export function makeNativeTokenStreamDecoderConfig( function validateAndDecodeData( caveats: ChecksumCaveat[], contractAddresses: ChecksumEnforcersByChainId, -): DecodedPermissionData { +): DecodedPermissionData { const { nativeTokenStreamingEnforcer, exactCalldataEnforcer } = contractAddresses; @@ -95,5 +97,10 @@ function validateAndDecodeData( ); } - return { initialAmount, maxAmount, amountPerSecond, startTime }; + return { + initialAmount: bigIntToHex(initialAmount), + maxAmount: bigIntToHex(maxAmount), + amountPerSecond: bigIntToHex(amountPerSecond), + startTime, + }; } diff --git a/packages/7715-permission-types/src/permissions/caveats/tokenApprovalRevocation.ts b/packages/7715-permission-types/src/permissions/caveats/tokenApprovalRevocation.ts index fc622071..b5ab4c7d 100644 --- a/packages/7715-permission-types/src/permissions/caveats/tokenApprovalRevocation.ts +++ b/packages/7715-permission-types/src/permissions/caveats/tokenApprovalRevocation.ts @@ -1,5 +1,6 @@ import { decodeApprovalRevocationTerms } from '@metamask/delegation-core'; +import type { TokenApprovalRevocationPermission } from '../../types'; import { expiryRule } from '../rules/expiry'; import type { ChecksumCaveat, @@ -46,7 +47,7 @@ export function makeTokenApprovalRevocationDecoderConfig( function validateAndDecodeData( caveats: ChecksumCaveat[], contractAddresses: ChecksumEnforcersByChainId, -): DecodedPermissionData { +): DecodedPermissionData { const { approvalRevocationEnforcer } = contractAddresses; const terms = getTermsByEnforcer({ diff --git a/packages/7715-permission-types/src/permissions/types.ts b/packages/7715-permission-types/src/permissions/types.ts index 5574fb57..93b17360 100644 --- a/packages/7715-permission-types/src/permissions/types.ts +++ b/packages/7715-permission-types/src/permissions/types.ts @@ -26,7 +26,9 @@ export type ChecksumCaveat = Caveat; /** * Type of the `data` parameter of a decoded permission. */ -export type DecodedPermissionData = PermissionTypes['data']; +export type DecodedPermissionData< + TPermissionType extends PermissionTypes = PermissionTypes, +> = TPermissionType['data']; /** * Supported permission type identifiers that can be decoded from a permission context. diff --git a/packages/7715-permission-types/test/permissions/caveats/erc20TokenPeriodic.test.ts b/packages/7715-permission-types/test/permissions/caveats/erc20TokenPeriodic.test.ts index 5a13678f..dfd87218 100644 --- a/packages/7715-permission-types/test/permissions/caveats/erc20TokenPeriodic.test.ts +++ b/packages/7715-permission-types/test/permissions/caveats/erc20TokenPeriodic.test.ts @@ -3,7 +3,7 @@ import { CHAIN_ID, DELEGATOR_CONTRACTS, } from '@metamask/delegation-deployments'; -import type { Hex } from '@metamask/utils'; +import { bigIntToHex, type Hex } from '@metamask/utils'; import { describe, it, expect } from 'vitest'; import { makePermissionDecoderConfigs } from '../../../src/permissions'; @@ -113,7 +113,7 @@ describe('erc20-token-periodic decoder config', () => { ), ).toStrictEqual({ tokenAddress: TOKEN_ADDRESS, - periodAmount: 100n, + periodAmount: bigIntToHex(100n), periodDuration: 86400, startTime: START_TIME, }); @@ -165,7 +165,7 @@ describe('erc20-token-periodic decoder config', () => { ), ).toStrictEqual({ tokenAddress: TOKEN_ADDRESS, - periodAmount: 100n, + periodAmount: bigIntToHex(100n), periodDuration: MAX_PERIOD_DURATION, startTime: START_TIME, }); diff --git a/packages/7715-permission-types/test/permissions/caveats/erc20TokenStream.test.ts b/packages/7715-permission-types/test/permissions/caveats/erc20TokenStream.test.ts index 1450a030..e5cfe4dd 100644 --- a/packages/7715-permission-types/test/permissions/caveats/erc20TokenStream.test.ts +++ b/packages/7715-permission-types/test/permissions/caveats/erc20TokenStream.test.ts @@ -3,7 +3,7 @@ import { CHAIN_ID, DELEGATOR_CONTRACTS, } from '@metamask/delegation-deployments'; -import type { Hex } from '@metamask/utils'; +import { bigIntToHex, type Hex } from '@metamask/utils'; import { describe, it, expect } from 'vitest'; import { makePermissionDecoderConfigs } from '../../../src/permissions'; @@ -122,9 +122,9 @@ describe('erc20-token-stream decoder config', () => { ), ).toStrictEqual({ tokenAddress: TOKEN_ADDRESS, - initialAmount: 10n, - maxAmount: 100n, - amountPerSecond: 5n, + initialAmount: bigIntToHex(10n), + maxAmount: bigIntToHex(100n), + amountPerSecond: bigIntToHex(5n), startTime: START_TIME, }); }); diff --git a/packages/7715-permission-types/test/permissions/caveats/nativeTokenPeriodic.test.ts b/packages/7715-permission-types/test/permissions/caveats/nativeTokenPeriodic.test.ts index 30da7896..2cbb49e6 100644 --- a/packages/7715-permission-types/test/permissions/caveats/nativeTokenPeriodic.test.ts +++ b/packages/7715-permission-types/test/permissions/caveats/nativeTokenPeriodic.test.ts @@ -3,7 +3,7 @@ import { CHAIN_ID, DELEGATOR_CONTRACTS, } from '@metamask/delegation-deployments'; -import type { Hex } from '@metamask/utils'; +import { bigIntToHex, type Hex } from '@metamask/utils'; import { describe, it, expect } from 'vitest'; import { makePermissionDecoderConfigs } from '../../../src/permissions'; @@ -113,7 +113,7 @@ describe('native-token-periodic decoder config', () => { decoder.contractAddresses, ), ).toStrictEqual({ - periodAmount: 100n, + periodAmount: bigIntToHex(100n), periodDuration: 86400, startTime: START_TIME, }); @@ -168,7 +168,7 @@ describe('native-token-periodic decoder config', () => { decoder.contractAddresses, ), ).toStrictEqual({ - periodAmount: 100n, + periodAmount: bigIntToHex(100n), periodDuration: MAX_PERIOD_DURATION, startTime: START_TIME, }); diff --git a/packages/7715-permission-types/test/permissions/caveats/nativeTokenStream.test.ts b/packages/7715-permission-types/test/permissions/caveats/nativeTokenStream.test.ts index 82be7993..38c665c7 100644 --- a/packages/7715-permission-types/test/permissions/caveats/nativeTokenStream.test.ts +++ b/packages/7715-permission-types/test/permissions/caveats/nativeTokenStream.test.ts @@ -3,7 +3,7 @@ import { CHAIN_ID, DELEGATOR_CONTRACTS, } from '@metamask/delegation-deployments'; -import type { Hex } from '@metamask/utils'; +import { bigIntToHex, type Hex } from '@metamask/utils'; import { describe, it, expect } from 'vitest'; import { makePermissionDecoderConfigs } from '../../../src/permissions'; @@ -113,9 +113,9 @@ describe('native-token-stream decoder config', () => { decoder.contractAddresses, ), ).toStrictEqual({ - initialAmount: 10n, - maxAmount: 100n, - amountPerSecond: 5n, + initialAmount: bigIntToHex(10n), + maxAmount: bigIntToHex(100n), + amountPerSecond: bigIntToHex(5n), startTime: START_TIME, }); }); From 1930bd9c285cf797a5c8045266cb38613d1ee118 Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Wed, 10 Jun 2026 11:13:23 +1200 Subject: [PATCH 08/16] Use hexToNumber to parse startTime in erc20TokenAllowance decoder --- .../src/permissions/caveats/erc20TokenAllowance.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/7715-permission-types/src/permissions/caveats/erc20TokenAllowance.ts b/packages/7715-permission-types/src/permissions/caveats/erc20TokenAllowance.ts index f20c55a1..f814761d 100644 --- a/packages/7715-permission-types/src/permissions/caveats/erc20TokenAllowance.ts +++ b/packages/7715-permission-types/src/permissions/caveats/erc20TokenAllowance.ts @@ -1,3 +1,5 @@ +import { hexToNumber } from '@metamask/utils'; + import type { Erc20TokenAllowancePermission } from '../../types'; import { expiryRule } from '../rules/expiry'; import { erc20PayeeRuleDecoder } from '../rules/payee'; @@ -101,7 +103,7 @@ function validateAndDecodeData( ); } - const startTime = Number.parseInt(startTimeRaw, 16); + const startTime = hexToNumber(startTimeRaw); if (startTime === 0) { throw new Error( From 0e9745c96ac806fc41efa94b95727d684c7a95eb Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Mon, 15 Jun 2026 08:58:51 +1200 Subject: [PATCH 09/16] Construct valid terms directly in tests, rather than depending on @metamask/delegation-core encoders --- .../caveats/erc20TokenPeriodic.test.ts | 26 +++++++++----- .../caveats/erc20TokenStream.test.ts | 35 +++++-------------- .../caveats/nativeTokenPeriodic.test.ts | 13 ++----- .../caveats/nativeTokenStream.test.ts | 14 ++------ 4 files changed, 30 insertions(+), 58 deletions(-) diff --git a/packages/7715-permission-types/test/permissions/caveats/erc20TokenPeriodic.test.ts b/packages/7715-permission-types/test/permissions/caveats/erc20TokenPeriodic.test.ts index dfd87218..2bb0e645 100644 --- a/packages/7715-permission-types/test/permissions/caveats/erc20TokenPeriodic.test.ts +++ b/packages/7715-permission-types/test/permissions/caveats/erc20TokenPeriodic.test.ts @@ -1,4 +1,3 @@ -import { createERC20TokenPeriodTransferTerms } from '@metamask/delegation-core'; import { CHAIN_ID, DELEGATOR_CONTRACTS, @@ -46,11 +45,13 @@ describe('erc20-token-periodic decoder config', () => { periodAmount?: bigint; periodDuration?: number; startDate?: number; - } = {}): Hex => - createERC20TokenPeriodTransferTerms( - { tokenAddress, periodAmount, periodDuration, startDate }, - { out: 'hex' }, - ); + } = {}): Hex => { + const periodAmountHex = periodAmount.toString(16).padStart(64, '0'); + const periodDurationHex = periodDuration.toString(16).padStart(64, '0'); + const startDateHex = startDate.toString(16).padStart(64, '0'); + + return `${tokenAddress}${periodAmountHex}${periodDurationHex}${startDateHex}`; + }; const makeCaveats = ( terms: Hex, @@ -59,14 +60,17 @@ describe('erc20-token-periodic decoder config', () => { { enforcer: erc20PeriodicEnforcer, terms, + args: '0x', }, { enforcer: valueLteEnforcer, terms: valueLteTerms, + args: '0x', }, { enforcer: nonceEnforcer, - terms: '0x' as const, + terms: '0x', + args: '0x', }, ]; @@ -134,7 +138,9 @@ describe('erc20-token-periodic decoder config', () => { makeCaveats(makeTerms({ periodDuration: 0 })), decoder.contractAddresses, ), - ).toThrow('Invalid periodDuration: must be a positive number'); + ).toThrow( + 'Invalid erc20-token-periodic terms: periodDuration must be a positive number', + ); }); it('validateAndDecodeData rejects when periodAmount is zero', () => { @@ -143,7 +149,9 @@ describe('erc20-token-periodic decoder config', () => { makeCaveats(makeTerms({ periodAmount: 0n })), decoder.contractAddresses, ), - ).toThrow('Invalid periodAmount: must be a positive number'); + ).toThrow( + 'Invalid erc20-token-periodic terms: periodAmount must be a positive number', + ); }); it('validateAndDecodeData rejects when periodDuration exceeds MAX_PERIOD_DURATION', () => { diff --git a/packages/7715-permission-types/test/permissions/caveats/erc20TokenStream.test.ts b/packages/7715-permission-types/test/permissions/caveats/erc20TokenStream.test.ts index e5cfe4dd..aab19439 100644 --- a/packages/7715-permission-types/test/permissions/caveats/erc20TokenStream.test.ts +++ b/packages/7715-permission-types/test/permissions/caveats/erc20TokenStream.test.ts @@ -1,4 +1,3 @@ -import { createERC20StreamingTerms } from '@metamask/delegation-core'; import { CHAIN_ID, DELEGATOR_CONTRACTS, @@ -34,17 +33,7 @@ describe('erc20-token-stream decoder config', () => { ); const TOKEN_ADDRESS = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex; const START_TIME = 1715664; - const VALID_TERMS = createERC20StreamingTerms( - { - tokenAddress: TOKEN_ADDRESS, - initialAmount: 10n, - maxAmount: 100n, - amountPerSecond: 5n, - startTime: START_TIME, - }, - { out: 'hex' }, - ); - const makeRawTerms = ({ + const makeTerms = ({ tokenAddress = TOKEN_ADDRESS, initialAmount = 10n, maxAmount = 100n, @@ -117,7 +106,7 @@ describe('erc20-token-stream decoder config', () => { it('validateAndDecodeData decodes valid stream terms', () => { expect( decoder.validateAndDecodeData( - makeCaveats(VALID_TERMS), + makeCaveats(makeTerms({})), decoder.contractAddresses, ), ).toStrictEqual({ @@ -132,23 +121,17 @@ describe('erc20-token-stream decoder config', () => { it('validateAndDecodeData rejects non-zero value-lte terms', () => { expect(() => decoder.validateAndDecodeData( - makeCaveats(VALID_TERMS, `0x${'0'.repeat(63)}1` as Hex), + makeCaveats(makeTerms({}), `0x${'0'.repeat(63)}1` as Hex), decoder.contractAddresses, ), ).toThrow(`Invalid value-lte terms: must be ${ZERO_32_BYTES}`); }); it('validateAndDecodeData rejects when maxAmount equals initialAmount', () => { - const invalidTerms = createERC20StreamingTerms( - { - tokenAddress: TOKEN_ADDRESS, - initialAmount: 100n, - maxAmount: 100n, - amountPerSecond: 5n, - startTime: START_TIME, - }, - { out: 'hex' }, - ); + const invalidTerms = makeTerms({ + initialAmount: 100n, + maxAmount: 100n, + }); expect(() => decoder.validateAndDecodeData( @@ -161,7 +144,7 @@ describe('erc20-token-stream decoder config', () => { }); it('validateAndDecodeData rejects when amountPerSecond is zero', () => { - const invalidTerms = makeRawTerms({ amountPerSecond: 0n }); + const invalidTerms = makeTerms({ amountPerSecond: 0n }); expect(() => decoder.validateAndDecodeData( @@ -174,7 +157,7 @@ describe('erc20-token-stream decoder config', () => { }); it('validateAndDecodeData rejects when startTime is zero', () => { - const invalidTerms = makeRawTerms({ startTime: 0 }); + const invalidTerms = makeTerms({ startTime: 0 }); expect(() => decoder.validateAndDecodeData( diff --git a/packages/7715-permission-types/test/permissions/caveats/nativeTokenPeriodic.test.ts b/packages/7715-permission-types/test/permissions/caveats/nativeTokenPeriodic.test.ts index 2cbb49e6..2282a0d8 100644 --- a/packages/7715-permission-types/test/permissions/caveats/nativeTokenPeriodic.test.ts +++ b/packages/7715-permission-types/test/permissions/caveats/nativeTokenPeriodic.test.ts @@ -1,4 +1,3 @@ -import { createNativeTokenPeriodTransferTerms } from '@metamask/delegation-core'; import { CHAIN_ID, DELEGATOR_CONTRACTS, @@ -33,14 +32,6 @@ describe('native-token-periodic decoder config', () => { getChecksumEnforcersByChainId(contracts), ); const START_TIME = 1715664; - const VALID_TERMS = createNativeTokenPeriodTransferTerms( - { - periodAmount: 100n, - periodDuration: 86400, - startDate: START_TIME, - }, - { out: 'hex' }, - ); const makeTerms = ({ periodAmount = 100n, periodDuration = 86400, @@ -109,7 +100,7 @@ describe('native-token-periodic decoder config', () => { it('validateAndDecodeData decodes valid periodic terms', () => { expect( decoder.validateAndDecodeData( - makeCaveats(VALID_TERMS), + makeCaveats(makeTerms({})), decoder.contractAddresses, ), ).toStrictEqual({ @@ -122,7 +113,7 @@ describe('native-token-periodic decoder config', () => { it('validateAndDecodeData rejects exact-calldata terms that are not 0x', () => { expect(() => decoder.validateAndDecodeData( - makeCaveats(VALID_TERMS, '0x00'), + makeCaveats(makeTerms({}), '0x00'), decoder.contractAddresses, ), ).toThrow('Invalid exact-calldata terms: must be 0x'); diff --git a/packages/7715-permission-types/test/permissions/caveats/nativeTokenStream.test.ts b/packages/7715-permission-types/test/permissions/caveats/nativeTokenStream.test.ts index 38c665c7..a8f9c9ec 100644 --- a/packages/7715-permission-types/test/permissions/caveats/nativeTokenStream.test.ts +++ b/packages/7715-permission-types/test/permissions/caveats/nativeTokenStream.test.ts @@ -1,4 +1,3 @@ -import { createNativeTokenStreamingTerms } from '@metamask/delegation-core'; import { CHAIN_ID, DELEGATOR_CONTRACTS, @@ -30,15 +29,6 @@ describe('native-token-stream decoder config', () => { getChecksumEnforcersByChainId(contracts), ); const START_TIME = 1715664; - const VALID_TERMS = createNativeTokenStreamingTerms( - { - initialAmount: 10n, - maxAmount: 100n, - amountPerSecond: 5n, - startTime: START_TIME, - }, - { out: 'hex' }, - ); const makeTerms = ({ initialAmount = 10n, maxAmount = 100n, @@ -109,7 +99,7 @@ describe('native-token-stream decoder config', () => { it('validateAndDecodeData decodes valid stream terms', () => { expect( decoder.validateAndDecodeData( - makeCaveats(VALID_TERMS), + makeCaveats(makeTerms({})), decoder.contractAddresses, ), ).toStrictEqual({ @@ -123,7 +113,7 @@ describe('native-token-stream decoder config', () => { it('validateAndDecodeData rejects exact-calldata terms that are not 0x', () => { expect(() => decoder.validateAndDecodeData( - makeCaveats(VALID_TERMS, '0x00'), + makeCaveats(makeTerms({}), '0x00'), decoder.contractAddresses, ), ).toThrow('Invalid exact-calldata terms: must be 0x'); From 60e34c6afeaca3a2a7cf796d7597366b1970b422 Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Mon, 15 Jun 2026 09:58:13 +1200 Subject: [PATCH 10/16] Rename expiryRule to expiryRuleDecoder Update payeeRuleDecoder to use getTermsByEnforcer utility --- .../caveats/erc20TokenAllowance.ts | 4 +- .../permissions/caveats/erc20TokenPeriodic.ts | 4 +- .../permissions/caveats/erc20TokenStream.ts | 4 +- .../caveats/nativeTokenAllowance.ts | 4 +- .../caveats/nativeTokenPeriodic.ts | 4 +- .../permissions/caveats/nativeTokenStream.ts | 4 +- .../caveats/tokenApprovalRevocation.ts | 4 +- .../src/permissions/rules/expiry.ts | 5 +- .../src/permissions/rules/payee.ts | 52 ++++++------------- .../src/permissions/utils.ts | 13 +++-- .../caveats/erc20TokenAllowance.test.ts | 9 ++-- .../caveats/erc20TokenPeriodic.test.ts | 4 +- .../caveats/erc20TokenStream.test.ts | 9 ++-- .../caveats/nativeTokenAllowance.test.ts | 9 ++-- .../caveats/nativeTokenPeriodic.test.ts | 9 ++-- .../caveats/nativeTokenStream.test.ts | 9 ++-- .../caveats/tokenApprovalRevocation.test.ts | 8 +-- .../test/permissions/rules/erc20Payee.test.ts | 2 +- .../test/permissions/rules/expiry.test.ts | 44 ++++++++++++++-- .../permissions/rules/nativePayee.test.ts | 4 +- 20 files changed, 124 insertions(+), 81 deletions(-) diff --git a/packages/7715-permission-types/src/permissions/caveats/erc20TokenAllowance.ts b/packages/7715-permission-types/src/permissions/caveats/erc20TokenAllowance.ts index f814761d..ac9bebbc 100644 --- a/packages/7715-permission-types/src/permissions/caveats/erc20TokenAllowance.ts +++ b/packages/7715-permission-types/src/permissions/caveats/erc20TokenAllowance.ts @@ -1,7 +1,7 @@ import { hexToNumber } from '@metamask/utils'; import type { Erc20TokenAllowancePermission } from '../../types'; -import { expiryRule } from '../rules/expiry'; +import { expiryRuleDecoder } from '../rules/expiry'; import { erc20PayeeRuleDecoder } from '../rules/payee'; import { redeemerRuleDecoder } from '../rules/redeemer'; import type { @@ -49,7 +49,7 @@ export function makeErc20TokenAllowanceDecoderConfig( [valueLteEnforcer]: 1, [nonceEnforcer]: 1, }, - rules: [expiryRule, redeemerRuleDecoder, erc20PayeeRuleDecoder], + rules: [expiryRuleDecoder, redeemerRuleDecoder, erc20PayeeRuleDecoder], validateAndDecodeData, }; } diff --git a/packages/7715-permission-types/src/permissions/caveats/erc20TokenPeriodic.ts b/packages/7715-permission-types/src/permissions/caveats/erc20TokenPeriodic.ts index b16e39a0..406ad93d 100644 --- a/packages/7715-permission-types/src/permissions/caveats/erc20TokenPeriodic.ts +++ b/packages/7715-permission-types/src/permissions/caveats/erc20TokenPeriodic.ts @@ -2,7 +2,7 @@ import { decodeERC20TokenPeriodTransferTerms } from '@metamask/delegation-core'; import { bigIntToHex } from '@metamask/utils'; import type { Erc20TokenPeriodicPermission } from '../../types'; -import { expiryRule } from '../rules/expiry'; +import { expiryRuleDecoder } from '../rules/expiry'; import { erc20PayeeRuleDecoder } from '../rules/payee'; import { redeemerRuleDecoder } from '../rules/redeemer'; import type { @@ -48,7 +48,7 @@ export function makeErc20TokenPeriodicDecoderConfig( [valueLteEnforcer]: 1, [nonceEnforcer]: 1, }, - rules: [expiryRule, redeemerRuleDecoder, erc20PayeeRuleDecoder], + rules: [expiryRuleDecoder, redeemerRuleDecoder, erc20PayeeRuleDecoder], validateAndDecodeData, }; } diff --git a/packages/7715-permission-types/src/permissions/caveats/erc20TokenStream.ts b/packages/7715-permission-types/src/permissions/caveats/erc20TokenStream.ts index 72f6ca16..ef61021a 100644 --- a/packages/7715-permission-types/src/permissions/caveats/erc20TokenStream.ts +++ b/packages/7715-permission-types/src/permissions/caveats/erc20TokenStream.ts @@ -2,7 +2,7 @@ import { decodeERC20StreamingTerms } from '@metamask/delegation-core'; import { bigIntToHex } from '@metamask/utils'; import type { Erc20TokenStreamPermission } from '../../types'; -import { expiryRule } from '../rules/expiry'; +import { expiryRuleDecoder } from '../rules/expiry'; import { erc20PayeeRuleDecoder } from '../rules/payee'; import { redeemerRuleDecoder } from '../rules/redeemer'; import type { @@ -44,7 +44,7 @@ export function makeErc20TokenStreamDecoderConfig( [valueLteEnforcer]: 1, [nonceEnforcer]: 1, }, - rules: [expiryRule, redeemerRuleDecoder, erc20PayeeRuleDecoder], + rules: [expiryRuleDecoder, redeemerRuleDecoder, erc20PayeeRuleDecoder], validateAndDecodeData, }; } diff --git a/packages/7715-permission-types/src/permissions/caveats/nativeTokenAllowance.ts b/packages/7715-permission-types/src/permissions/caveats/nativeTokenAllowance.ts index b80a3788..2035d61b 100644 --- a/packages/7715-permission-types/src/permissions/caveats/nativeTokenAllowance.ts +++ b/packages/7715-permission-types/src/permissions/caveats/nativeTokenAllowance.ts @@ -1,7 +1,7 @@ import { hexToNumber } from '@metamask/utils'; import type { NativeTokenAllowancePermission } from '../../types'; -import { expiryRule } from '../rules/expiry'; +import { expiryRuleDecoder } from '../rules/expiry'; import { nativePayeeRuleDecoder } from '../rules/payee'; import { redeemerRuleDecoder } from '../rules/redeemer'; import type { @@ -49,7 +49,7 @@ export function makeNativeTokenAllowanceDecoderConfig( [exactCalldataEnforcer]: 1, [nonceEnforcer]: 1, }, - rules: [expiryRule, redeemerRuleDecoder, nativePayeeRuleDecoder], + rules: [expiryRuleDecoder, redeemerRuleDecoder, nativePayeeRuleDecoder], validateAndDecodeData, }; } diff --git a/packages/7715-permission-types/src/permissions/caveats/nativeTokenPeriodic.ts b/packages/7715-permission-types/src/permissions/caveats/nativeTokenPeriodic.ts index d9915b8c..98b2cccf 100644 --- a/packages/7715-permission-types/src/permissions/caveats/nativeTokenPeriodic.ts +++ b/packages/7715-permission-types/src/permissions/caveats/nativeTokenPeriodic.ts @@ -2,7 +2,7 @@ import { decodeNativeTokenPeriodTransferTerms } from '@metamask/delegation-core' import { bigIntToHex } from '@metamask/utils'; import type { NativeTokenPeriodicPermission } from '../../types'; -import { expiryRule } from '../rules/expiry'; +import { expiryRuleDecoder } from '../rules/expiry'; import { nativePayeeRuleDecoder } from '../rules/payee'; import { redeemerRuleDecoder } from '../rules/redeemer'; import type { @@ -44,7 +44,7 @@ export function makeNativeTokenPeriodicDecoderConfig( [exactCalldataEnforcer]: 1, [nonceEnforcer]: 1, }, - rules: [expiryRule, redeemerRuleDecoder, nativePayeeRuleDecoder], + rules: [expiryRuleDecoder, redeemerRuleDecoder, nativePayeeRuleDecoder], validateAndDecodeData, }; } diff --git a/packages/7715-permission-types/src/permissions/caveats/nativeTokenStream.ts b/packages/7715-permission-types/src/permissions/caveats/nativeTokenStream.ts index fd7f5b79..1c7a4ee7 100644 --- a/packages/7715-permission-types/src/permissions/caveats/nativeTokenStream.ts +++ b/packages/7715-permission-types/src/permissions/caveats/nativeTokenStream.ts @@ -2,7 +2,7 @@ import { decodeNativeTokenStreamingTerms } from '@metamask/delegation-core'; import { bigIntToHex } from '@metamask/utils'; import type { NativeTokenStreamPermission } from '../../types'; -import { expiryRule } from '../rules/expiry'; +import { expiryRuleDecoder } from '../rules/expiry'; import { nativePayeeRuleDecoder } from '../rules/payee'; import { redeemerRuleDecoder } from '../rules/redeemer'; import type { @@ -44,7 +44,7 @@ export function makeNativeTokenStreamDecoderConfig( [exactCalldataEnforcer]: 1, [nonceEnforcer]: 1, }, - rules: [expiryRule, redeemerRuleDecoder, nativePayeeRuleDecoder], + rules: [expiryRuleDecoder, redeemerRuleDecoder, nativePayeeRuleDecoder], validateAndDecodeData, }; } diff --git a/packages/7715-permission-types/src/permissions/caveats/tokenApprovalRevocation.ts b/packages/7715-permission-types/src/permissions/caveats/tokenApprovalRevocation.ts index b5ab4c7d..54e74ca0 100644 --- a/packages/7715-permission-types/src/permissions/caveats/tokenApprovalRevocation.ts +++ b/packages/7715-permission-types/src/permissions/caveats/tokenApprovalRevocation.ts @@ -1,7 +1,7 @@ import { decodeApprovalRevocationTerms } from '@metamask/delegation-core'; import type { TokenApprovalRevocationPermission } from '../../types'; -import { expiryRule } from '../rules/expiry'; +import { expiryRuleDecoder } from '../rules/expiry'; import type { ChecksumCaveat, ChecksumEnforcersByChainId, @@ -32,7 +32,7 @@ export function makeTokenApprovalRevocationDecoderConfig( [approvalRevocationEnforcer]: 1, [nonceEnforcer]: 1, }, - rules: [expiryRule], + rules: [expiryRuleDecoder], validateAndDecodeData, }; } diff --git a/packages/7715-permission-types/src/permissions/rules/expiry.ts b/packages/7715-permission-types/src/permissions/rules/expiry.ts index 8e3ae0de..c39d8b7b 100644 --- a/packages/7715-permission-types/src/permissions/rules/expiry.ts +++ b/packages/7715-permission-types/src/permissions/rules/expiry.ts @@ -24,7 +24,10 @@ export type ExpiryRule = { * @param options0.caveats - Checksummed caveats from the delegation. * @returns The decoded expiry rule when present, otherwise `null`. */ -export const expiryRule: RuleDecoder = ({ contractAddresses, caveats }) => { +export const expiryRuleDecoder: RuleDecoder = ({ + contractAddresses, + caveats, +}) => { const { timestampEnforcer } = contractAddresses; const expiryTerms = getTermsByEnforcer({ diff --git a/packages/7715-permission-types/src/permissions/rules/payee.ts b/packages/7715-permission-types/src/permissions/rules/payee.ts index e2e8ed68..532d662f 100644 --- a/packages/7715-permission-types/src/permissions/rules/payee.ts +++ b/packages/7715-permission-types/src/permissions/rules/payee.ts @@ -6,7 +6,7 @@ import { getChecksumAddress } from '@metamask/utils'; import type { Hex } from '../../types'; import type { RuleDecoder } from '../types'; -import { getByteLength } from '../utils'; +import { getByteLength, getTermsByEnforcer } from '../utils'; export const EXECUTION_PERMISSION_PAYEE_RULE_TYPE = 'payee' as const; @@ -46,28 +46,17 @@ export const erc20PayeeRuleDecoder: RuleDecoder = ({ ); } - const matchingCaveats = caveats.filter( - (caveat) => caveat.enforcer === allowedCalldataEnforcer, - ); + const terms = getTermsByEnforcer({ + caveats, + enforcer: allowedCalldataEnforcer, + throwIfNotFound: false, + }); - if (matchingCaveats.length === 0) { + if (!terms) { return null; } - if (matchingCaveats.length > 1) { - throw new Error( - 'Invalid payee caveats: multiple AllowedCalldataEnforcer caveats', - ); - } - - const [caveat] = matchingCaveats; - if (!caveat) { - throw new Error( - 'Invalid payee caveats: multiple AllowedCalldataEnforcer caveats', - ); - } - - const decoded = decodeAllowedCalldataTerms(caveat.terms); + const decoded = decodeAllowedCalldataTerms(terms); if (decoded.startIndex !== ERC20_TRANSFER_PAYEE_START_INDEX) { throw new Error( @@ -111,28 +100,17 @@ export const nativePayeeRuleDecoder: RuleDecoder = ({ ); } - const matchingCaveats = caveats.filter( - (caveat) => caveat.enforcer === allowedTargetsEnforcer, - ); + const terms = getTermsByEnforcer({ + caveats, + enforcer: allowedTargetsEnforcer, + throwIfNotFound: false, + }); - if (matchingCaveats.length === 0) { + if (!terms) { return null; } - if (matchingCaveats.length > 1) { - throw new Error( - 'Invalid payee caveats: multiple AllowedTargetsEnforcer caveats', - ); - } - - const [caveat] = matchingCaveats; - if (!caveat) { - throw new Error( - 'Invalid payee caveats: multiple AllowedTargetsEnforcer caveats', - ); - } - - const decoded = decodeAllowedTargetsTerms(caveat.terms); + const decoded = decodeAllowedTargetsTerms(terms); return { type: EXECUTION_PERMISSION_PAYEE_RULE_TYPE, diff --git a/packages/7715-permission-types/src/permissions/utils.ts b/packages/7715-permission-types/src/permissions/utils.ts index 9b75fcdd..17bb66ce 100644 --- a/packages/7715-permission-types/src/permissions/utils.ts +++ b/packages/7715-permission-types/src/permissions/utils.ts @@ -65,18 +65,25 @@ export function getTermsByEnforcer({ if (matchingCaveats.length === 0) { if (throwIfNotFound ?? true) { - throw new Error('Invalid caveats'); + throw new Error( + `Invalid caveats: no caveat found matching enforcer ${enforcer}`, + ); } return null as TThrowIfNotFound extends true ? Hex : Hex | null; } if (matchingCaveats.length > 1) { - throw new Error('Invalid caveats'); + throw new Error( + `Invalid caveats: multiple caveats found matching enforcer ${enforcer}`, + ); } const [caveat] = matchingCaveats; + if (!caveat) { - throw new Error('Invalid caveats'); + throw new Error( + `Invalid caveats: no caveat found matching enforcer ${enforcer}`, + ); } return caveat.terms; diff --git a/packages/7715-permission-types/test/permissions/caveats/erc20TokenAllowance.test.ts b/packages/7715-permission-types/test/permissions/caveats/erc20TokenAllowance.test.ts index c4ad2468..69054c27 100644 --- a/packages/7715-permission-types/test/permissions/caveats/erc20TokenAllowance.test.ts +++ b/packages/7715-permission-types/test/permissions/caveats/erc20TokenAllowance.test.ts @@ -7,7 +7,7 @@ import { describe, it, expect } from 'vitest'; import { makePermissionDecoderConfigs } from '../../../src/permissions'; import { makeErc20TokenAllowanceDecoderConfig } from '../../../src/permissions/caveats/erc20TokenAllowance'; -import { expiryRule } from '../../../src/permissions/rules/expiry'; +import { expiryRuleDecoder } from '../../../src/permissions/rules/expiry'; import { erc20PayeeRuleDecoder } from '../../../src/permissions/rules/payee'; import { redeemerRuleDecoder } from '../../../src/permissions/rules/redeemer'; import type { ChecksumCaveat } from '../../../src/permissions/types'; @@ -46,14 +46,17 @@ describe('erc20-token-allowance decoder config', () => { { enforcer: erc20PeriodicEnforcer, terms: erc20PeriodicTerms, + args: '0x', }, { enforcer: valueLteEnforcer, terms: valueLteTerms, + args: '0x', }, { enforcer: nonceEnforcer, - terms: '0x' as const, + terms: '0x', + args: '0x', }, ]; @@ -76,7 +79,7 @@ describe('erc20-token-allowance decoder config', () => { it('includes expected rule decoders in order', () => { expect(decoder.rules).toStrictEqual([ - expiryRule, + expiryRuleDecoder, redeemerRuleDecoder, erc20PayeeRuleDecoder, ]); diff --git a/packages/7715-permission-types/test/permissions/caveats/erc20TokenPeriodic.test.ts b/packages/7715-permission-types/test/permissions/caveats/erc20TokenPeriodic.test.ts index 2bb0e645..d8040d58 100644 --- a/packages/7715-permission-types/test/permissions/caveats/erc20TokenPeriodic.test.ts +++ b/packages/7715-permission-types/test/permissions/caveats/erc20TokenPeriodic.test.ts @@ -7,7 +7,7 @@ import { describe, it, expect } from 'vitest'; import { makePermissionDecoderConfigs } from '../../../src/permissions'; import { makeErc20TokenPeriodicDecoderConfig } from '../../../src/permissions/caveats/erc20TokenPeriodic'; -import { expiryRule } from '../../../src/permissions/rules/expiry'; +import { expiryRuleDecoder } from '../../../src/permissions/rules/expiry'; import { erc20PayeeRuleDecoder } from '../../../src/permissions/rules/payee'; import { redeemerRuleDecoder } from '../../../src/permissions/rules/redeemer'; import type { ChecksumCaveat } from '../../../src/permissions/types'; @@ -93,7 +93,7 @@ describe('erc20-token-periodic decoder config', () => { it('includes expected rule decoders in order', () => { expect(decoder.rules).toStrictEqual([ - expiryRule, + expiryRuleDecoder, redeemerRuleDecoder, erc20PayeeRuleDecoder, ]); diff --git a/packages/7715-permission-types/test/permissions/caveats/erc20TokenStream.test.ts b/packages/7715-permission-types/test/permissions/caveats/erc20TokenStream.test.ts index aab19439..db4fd7a9 100644 --- a/packages/7715-permission-types/test/permissions/caveats/erc20TokenStream.test.ts +++ b/packages/7715-permission-types/test/permissions/caveats/erc20TokenStream.test.ts @@ -7,7 +7,7 @@ import { describe, it, expect } from 'vitest'; import { makePermissionDecoderConfigs } from '../../../src/permissions'; import { makeErc20TokenStreamDecoderConfig } from '../../../src/permissions/caveats/erc20TokenStream'; -import { expiryRule } from '../../../src/permissions/rules/expiry'; +import { expiryRuleDecoder } from '../../../src/permissions/rules/expiry'; import { erc20PayeeRuleDecoder } from '../../../src/permissions/rules/payee'; import { redeemerRuleDecoder } from '../../../src/permissions/rules/redeemer'; import type { ChecksumCaveat } from '../../../src/permissions/types'; @@ -57,14 +57,17 @@ describe('erc20-token-stream decoder config', () => { { enforcer: erc20StreamingEnforcer, terms: erc20StreamingTerms, + args: '0x', }, { enforcer: valueLteEnforcer, terms: valueLteTerms, + args: '0x', }, { enforcer: nonceEnforcer, - terms: '0x' as const, + terms: '0x', + args: '0x', }, ]; @@ -87,7 +90,7 @@ describe('erc20-token-stream decoder config', () => { it('includes expected rule decoders in order', () => { expect(decoder.rules).toStrictEqual([ - expiryRule, + expiryRuleDecoder, redeemerRuleDecoder, erc20PayeeRuleDecoder, ]); diff --git a/packages/7715-permission-types/test/permissions/caveats/nativeTokenAllowance.test.ts b/packages/7715-permission-types/test/permissions/caveats/nativeTokenAllowance.test.ts index 55ec5006..01e870c7 100644 --- a/packages/7715-permission-types/test/permissions/caveats/nativeTokenAllowance.test.ts +++ b/packages/7715-permission-types/test/permissions/caveats/nativeTokenAllowance.test.ts @@ -7,7 +7,7 @@ import { describe, it, expect } from 'vitest'; import { makePermissionDecoderConfigs } from '../../../src/permissions'; import { makeNativeTokenAllowanceDecoderConfig } from '../../../src/permissions/caveats/nativeTokenAllowance'; -import { expiryRule } from '../../../src/permissions/rules/expiry'; +import { expiryRuleDecoder } from '../../../src/permissions/rules/expiry'; import { nativePayeeRuleDecoder } from '../../../src/permissions/rules/payee'; import { redeemerRuleDecoder } from '../../../src/permissions/rules/redeemer'; import type { ChecksumCaveat } from '../../../src/permissions/types'; @@ -45,14 +45,17 @@ describe('native-token-allowance decoder config', () => { { enforcer: nativeTokenPeriodicEnforcer, terms: nativeTokenPeriodicTerms, + args: '0x', }, { enforcer: exactCalldataEnforcer, terms: exactCalldataTerms, + args: '0x', }, { enforcer: nonceEnforcer, - terms: '0x' as const, + terms: '0x', + args: '0x', }, ]; @@ -75,7 +78,7 @@ describe('native-token-allowance decoder config', () => { it('includes expected rule decoders in order', () => { expect(decoder.rules).toStrictEqual([ - expiryRule, + expiryRuleDecoder, redeemerRuleDecoder, nativePayeeRuleDecoder, ]); diff --git a/packages/7715-permission-types/test/permissions/caveats/nativeTokenPeriodic.test.ts b/packages/7715-permission-types/test/permissions/caveats/nativeTokenPeriodic.test.ts index 2282a0d8..4ee97f1a 100644 --- a/packages/7715-permission-types/test/permissions/caveats/nativeTokenPeriodic.test.ts +++ b/packages/7715-permission-types/test/permissions/caveats/nativeTokenPeriodic.test.ts @@ -7,7 +7,7 @@ import { describe, it, expect } from 'vitest'; import { makePermissionDecoderConfigs } from '../../../src/permissions'; import { makeNativeTokenPeriodicDecoderConfig } from '../../../src/permissions/caveats/nativeTokenPeriodic'; -import { expiryRule } from '../../../src/permissions/rules/expiry'; +import { expiryRuleDecoder } from '../../../src/permissions/rules/expiry'; import { nativePayeeRuleDecoder } from '../../../src/permissions/rules/payee'; import { redeemerRuleDecoder } from '../../../src/permissions/rules/redeemer'; import type { ChecksumCaveat } from '../../../src/permissions/types'; @@ -51,14 +51,17 @@ describe('native-token-periodic decoder config', () => { { enforcer: nativeTokenPeriodicEnforcer, terms: nativeTokenPeriodicTerms, + args: '0x', }, { enforcer: exactCalldataEnforcer, terms: exactCalldataTerms, + args: '0x', }, { enforcer: nonceEnforcer, - terms: '0x' as const, + terms: '0x', + args: '0x', }, ]; @@ -81,7 +84,7 @@ describe('native-token-periodic decoder config', () => { it('includes expected rule decoders in order', () => { expect(decoder.rules).toStrictEqual([ - expiryRule, + expiryRuleDecoder, redeemerRuleDecoder, nativePayeeRuleDecoder, ]); diff --git a/packages/7715-permission-types/test/permissions/caveats/nativeTokenStream.test.ts b/packages/7715-permission-types/test/permissions/caveats/nativeTokenStream.test.ts index a8f9c9ec..5b198b41 100644 --- a/packages/7715-permission-types/test/permissions/caveats/nativeTokenStream.test.ts +++ b/packages/7715-permission-types/test/permissions/caveats/nativeTokenStream.test.ts @@ -7,7 +7,7 @@ import { describe, it, expect } from 'vitest'; import { makePermissionDecoderConfigs } from '../../../src/permissions'; import { makeNativeTokenStreamDecoderConfig } from '../../../src/permissions/caveats/nativeTokenStream'; -import { expiryRule } from '../../../src/permissions/rules/expiry'; +import { expiryRuleDecoder } from '../../../src/permissions/rules/expiry'; import { nativePayeeRuleDecoder } from '../../../src/permissions/rules/payee'; import { redeemerRuleDecoder } from '../../../src/permissions/rules/redeemer'; import type { ChecksumCaveat } from '../../../src/permissions/types'; @@ -50,14 +50,17 @@ describe('native-token-stream decoder config', () => { { enforcer: nativeTokenStreamingEnforcer, terms: nativeTokenStreamingTerms, + args: '0x', }, { enforcer: exactCalldataEnforcer, terms: exactCalldataTerms, + args: '0x', }, { enforcer: nonceEnforcer, - terms: '0x' as const, + terms: '0x', + args: '0x', }, ]; @@ -80,7 +83,7 @@ describe('native-token-stream decoder config', () => { it('includes expected rule decoders in order', () => { expect(decoder.rules).toStrictEqual([ - expiryRule, + expiryRuleDecoder, redeemerRuleDecoder, nativePayeeRuleDecoder, ]); diff --git a/packages/7715-permission-types/test/permissions/caveats/tokenApprovalRevocation.test.ts b/packages/7715-permission-types/test/permissions/caveats/tokenApprovalRevocation.test.ts index 6e26662a..cdffdd04 100644 --- a/packages/7715-permission-types/test/permissions/caveats/tokenApprovalRevocation.test.ts +++ b/packages/7715-permission-types/test/permissions/caveats/tokenApprovalRevocation.test.ts @@ -7,7 +7,7 @@ import { describe, it, expect } from 'vitest'; import { makePermissionDecoderConfigs } from '../../../src/permissions'; import { makeTokenApprovalRevocationDecoderConfig } from '../../../src/permissions/caveats/tokenApprovalRevocation'; -import { expiryRule } from '../../../src/permissions/rules/expiry'; +import { expiryRuleDecoder } from '../../../src/permissions/rules/expiry'; import type { ChecksumCaveat } from '../../../src/permissions/types'; import { getChecksumEnforcersByChainId } from '../../../src/permissions/utils'; @@ -24,10 +24,12 @@ describe('token-approval-revocation decoder config', () => { { enforcer: approvalRevocationEnforcer, terms: approvalRevocationTerms, + args: '0x', }, { enforcer: nonceEnforcer, - terms: '0x' as const, + terms: '0x', + args: '0x', }, ]; @@ -44,7 +46,7 @@ describe('token-approval-revocation decoder config', () => { }); it('includes expected rule decoders in order', () => { - expect(decoder.rules).toStrictEqual([expiryRule]); + expect(decoder.rules).toStrictEqual([expiryRuleDecoder]); }); }); diff --git a/packages/7715-permission-types/test/permissions/rules/erc20Payee.test.ts b/packages/7715-permission-types/test/permissions/rules/erc20Payee.test.ts index 736953b1..c709c533 100644 --- a/packages/7715-permission-types/test/permissions/rules/erc20Payee.test.ts +++ b/packages/7715-permission-types/test/permissions/rules/erc20Payee.test.ts @@ -102,7 +102,7 @@ describe('erc20PayeeRuleDecoder', () => { requiredEnforcers, }), ).toThrow( - 'Invalid payee caveats: multiple AllowedCalldataEnforcer caveats', + `Invalid caveats: multiple caveats found matching enforcer ${allowedCalldataEnforcer}`, ); }); diff --git a/packages/7715-permission-types/test/permissions/rules/expiry.test.ts b/packages/7715-permission-types/test/permissions/rules/expiry.test.ts index 230986cf..65ac4d51 100644 --- a/packages/7715-permission-types/test/permissions/rules/expiry.test.ts +++ b/packages/7715-permission-types/test/permissions/rules/expiry.test.ts @@ -6,7 +6,7 @@ import { import type { Hex } from '@metamask/utils'; import { describe, it, expect } from 'vitest'; -import { expiryRule } from '../../../src/permissions/rules/expiry'; +import { expiryRuleDecoder } from '../../../src/permissions/rules/expiry'; import type { ChecksumCaveat } from '../../../src/permissions/types'; import { getChecksumEnforcersByChainId } from '../../../src/permissions/utils'; @@ -22,7 +22,7 @@ describe('expiryRule', () => { ]; expect( - expiryRule({ contractAddresses, caveats, requiredEnforcers }), + expiryRuleDecoder({ contractAddresses, caveats, requiredEnforcers }), ).toBeNull(); }); @@ -40,7 +40,7 @@ describe('expiryRule', () => { ]; expect( - expiryRule({ contractAddresses, caveats, requiredEnforcers }), + expiryRuleDecoder({ contractAddresses, caveats, requiredEnforcers }), ).toStrictEqual({ type: 'expiry', data: { timestamp: beforeThreshold }, @@ -62,10 +62,46 @@ describe('expiryRule', () => { ]; expect( - expiryRule({ contractAddresses, caveats, requiredEnforcers }), + expiryRuleDecoder({ contractAddresses, caveats, requiredEnforcers }), ).toStrictEqual({ type: 'expiry', data: { timestamp: beforeThreshold }, }); }); + + it('throws when timestampBeforeThreshold is 0', () => { + const caveats: ChecksumCaveat[] = [ + { + enforcer: timestampEnforcer, + terms: createTimestampTerms({ + afterThreshold: 0, + beforeThreshold: 0, + }), + args: '0x' as Hex, + }, + ]; + + expect(() => + expiryRuleDecoder({ contractAddresses, caveats, requiredEnforcers }), + ).toThrow( + 'Invalid expiry: timestampBeforeThreshold must be greater than 0', + ); + }); + + it('throws when timestampAfterThreshold is non-zero', () => { + const caveats: ChecksumCaveat[] = [ + { + enforcer: timestampEnforcer, + terms: createTimestampTerms({ + afterThreshold: 1, + beforeThreshold: 1_750_000_000, + }), + args: '0x' as Hex, + }, + ]; + + expect(() => + expiryRuleDecoder({ contractAddresses, caveats, requiredEnforcers }), + ).toThrow('Invalid expiry: timestampAfterThreshold must be 0'); + }); }); diff --git a/packages/7715-permission-types/test/permissions/rules/nativePayee.test.ts b/packages/7715-permission-types/test/permissions/rules/nativePayee.test.ts index 4591544b..e8365b84 100644 --- a/packages/7715-permission-types/test/permissions/rules/nativePayee.test.ts +++ b/packages/7715-permission-types/test/permissions/rules/nativePayee.test.ts @@ -125,6 +125,8 @@ describe('nativePayeeRuleDecoder', () => { expect(() => nativePayeeRuleDecoder({ contractAddresses, caveats, requiredEnforcers }), - ).toThrow('Invalid payee caveats: multiple AllowedTargetsEnforcer caveats'); + ).toThrow( + `Invalid caveats: multiple caveats found matching enforcer ${allowedTargetsEnforcer}`, + ); }); }); From 862842f639e2509324fb049e18ccb3d2ed5b2112 Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Mon, 15 Jun 2026 10:13:03 +1200 Subject: [PATCH 11/16] Refactor getTermsByEnforcer for readability --- .../src/permissions/utils.ts | 68 +++++++++++++------ 1 file changed, 47 insertions(+), 21 deletions(-) diff --git a/packages/7715-permission-types/src/permissions/utils.ts b/packages/7715-permission-types/src/permissions/utils.ts index 17bb66ce..dd5f7203 100644 --- a/packages/7715-permission-types/src/permissions/utils.ts +++ b/packages/7715-permission-types/src/permissions/utils.ts @@ -50,43 +50,69 @@ const ENFORCER_CONTRACT_NAMES = { * @param options0.throwIfNotFound - Whether to throw if no match is found. * @returns The matched terms, or `null` when disabled and no caveat is found. */ -export function getTermsByEnforcer({ +export function getTermsByEnforcer({ caveats, enforcer, throwIfNotFound, }: { caveats: ChecksumCaveat[]; enforcer: Hex; - throwIfNotFound?: TThrowIfNotFound; -}): TThrowIfNotFound extends true ? Hex : Hex | null { - const matchingCaveats = caveats.filter( - (caveat) => caveat.enforcer === enforcer, - ); + throwIfNotFound?: true; +}): Hex; +export function getTermsByEnforcer({ + caveats, + enforcer, + throwIfNotFound, +}: { + caveats: ChecksumCaveat[]; + enforcer: Hex; + throwIfNotFound: false; +}): Hex | null; +/** + * Implementation signature for getTermsByEnforcer overloads. + * + * @param options0 - Terms lookup arguments. + * @param options0.caveats - Caveats to search. + * @param options0.enforcer - Enforcer address to match. + * @param options0.throwIfNotFound - Whether to throw if no match is found. + * @returns The matched terms, or `null` when disabled and no caveat is found. + */ +export function getTermsByEnforcer({ + caveats, + enforcer, + throwIfNotFound = true, +}: { + caveats: ChecksumCaveat[]; + enforcer: Hex; + throwIfNotFound?: boolean; +}): Hex | null { + let matchingCaveat: ChecksumCaveat | undefined; + + for (const caveat of caveats) { + if (caveat.enforcer !== enforcer) { + continue; + } - if (matchingCaveats.length === 0) { - if (throwIfNotFound ?? true) { + if (matchingCaveat) { throw new Error( - `Invalid caveats: no caveat found matching enforcer ${enforcer}`, + `Invalid caveats: multiple caveats found matching enforcer ${enforcer}`, ); } - return null as TThrowIfNotFound extends true ? Hex : Hex | null; - } - if (matchingCaveats.length > 1) { - throw new Error( - `Invalid caveats: multiple caveats found matching enforcer ${enforcer}`, - ); + matchingCaveat = caveat; } - const [caveat] = matchingCaveats; + if (!matchingCaveat) { + if (throwIfNotFound) { + throw new Error( + `Invalid caveats: no caveat found matching enforcer ${enforcer}`, + ); + } - if (!caveat) { - throw new Error( - `Invalid caveats: no caveat found matching enforcer ${enforcer}`, - ); + return null; } - return caveat.terms; + return matchingCaveat.terms; } /** From 7243adc8b0be44b08d912952fb35710f7b4217ae Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Mon, 15 Jun 2026 10:25:43 +1200 Subject: [PATCH 12/16] Tidy up redeemerDecoder --- .../src/permissions/rules/redeemer.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/7715-permission-types/src/permissions/rules/redeemer.ts b/packages/7715-permission-types/src/permissions/rules/redeemer.ts index 4099e13f..0c372b7f 100644 --- a/packages/7715-permission-types/src/permissions/rules/redeemer.ts +++ b/packages/7715-permission-types/src/permissions/rules/redeemer.ts @@ -32,21 +32,24 @@ export const redeemerRuleDecoder: RuleDecoder = ({ }) => { const { redeemerEnforcer } = contractAddresses; - const redeemerTerms = getTermsByEnforcer({ + const terms = getTermsByEnforcer({ caveats, enforcer: redeemerEnforcer, throwIfNotFound: false, }); - if (!redeemerTerms) { + if (!terms) { return null; } + const { redeemers } = decodeRedeemerTerms(terms); + + const addresses = redeemers.map(getChecksumAddress); + return { type: EXECUTION_PERMISSION_REDEEMER_RULE_TYPE, data: { - addresses: - decodeRedeemerTerms(redeemerTerms).redeemers.map(getChecksumAddress), + addresses, }, }; }; From 20aabd3d43c8b172fb327ea3fbb988f72ddae1e5 Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Mon, 15 Jun 2026 10:30:47 +1200 Subject: [PATCH 13/16] Normalize tests a little bit --- .../caveats/erc20TokenAllowance.test.ts | 21 ++++++++++++++++--- .../caveats/erc20TokenPeriodic.test.ts | 18 +++++++++++++--- .../caveats/nativeTokenAllowance.test.ts | 14 +++++++++++++ .../caveats/nativeTokenPeriodic.test.ts | 11 ++++++++++ 4 files changed, 58 insertions(+), 6 deletions(-) diff --git a/packages/7715-permission-types/test/permissions/caveats/erc20TokenAllowance.test.ts b/packages/7715-permission-types/test/permissions/caveats/erc20TokenAllowance.test.ts index 69054c27..c8dc3e61 100644 --- a/packages/7715-permission-types/test/permissions/caveats/erc20TokenAllowance.test.ts +++ b/packages/7715-permission-types/test/permissions/caveats/erc20TokenAllowance.test.ts @@ -97,9 +97,10 @@ describe('erc20-token-allowance decoder config', () => { it('validateAndDecodeData decodes valid allowance terms', () => { expect( - decoder.validateAndDecodeData(makeCaveats(VALID_ALLOWANCE_TERMS), { - ...decoder.contractAddresses, - }), + decoder.validateAndDecodeData( + makeCaveats(VALID_ALLOWANCE_TERMS), + decoder.contractAddresses, + ), ).toStrictEqual({ tokenAddress: `0x${TOKEN_ADDRESS_HEX}`, allowanceAmount: `0x${ALLOWANCE_AMOUNT_HEX}`, @@ -131,6 +132,20 @@ describe('erc20-token-allowance decoder config', () => { ); }); + it('validateAndDecodeData rejects when startTime is zero', () => { + const invalidTerms = + `0x${TOKEN_ADDRESS_HEX}${ALLOWANCE_AMOUNT_HEX}${UINT256_MAX.slice(2)}${toWord(0)}` as Hex; + + expect(() => + decoder.validateAndDecodeData( + makeCaveats(invalidTerms), + decoder.contractAddresses, + ), + ).toThrow( + 'Invalid erc20-token-allowance terms: startTime must be a positive number', + ); + }); + it('validateAndDecodeData rejects zero allowanceAmount', () => { const zeroAllowanceAmount = '0'.repeat(64); const invalidTerms = diff --git a/packages/7715-permission-types/test/permissions/caveats/erc20TokenPeriodic.test.ts b/packages/7715-permission-types/test/permissions/caveats/erc20TokenPeriodic.test.ts index d8040d58..bc01703f 100644 --- a/packages/7715-permission-types/test/permissions/caveats/erc20TokenPeriodic.test.ts +++ b/packages/7715-permission-types/test/permissions/caveats/erc20TokenPeriodic.test.ts @@ -16,6 +16,7 @@ import { MAX_PERIOD_DURATION, ZERO_32_BYTES, } from '../../../src/permissions/utils'; +import { toWord } from '../../test-utils'; describe('erc20-token-periodic decoder config', () => { const chainId = CHAIN_ID.sepolia; @@ -46,9 +47,9 @@ describe('erc20-token-periodic decoder config', () => { periodDuration?: number; startDate?: number; } = {}): Hex => { - const periodAmountHex = periodAmount.toString(16).padStart(64, '0'); - const periodDurationHex = periodDuration.toString(16).padStart(64, '0'); - const startDateHex = startDate.toString(16).padStart(64, '0'); + const periodAmountHex = toWord(periodAmount); + const periodDurationHex = toWord(periodDuration); + const startDateHex = toWord(startDate); return `${tokenAddress}${periodAmountHex}${periodDurationHex}${startDateHex}`; }; @@ -154,6 +155,17 @@ describe('erc20-token-periodic decoder config', () => { ); }); + it('validateAndDecodeData rejects when startTime is zero', () => { + expect(() => + decoder.validateAndDecodeData( + makeCaveats(makeTerms({ startDate: 0 })), + decoder.contractAddresses, + ), + ).toThrow( + 'Invalid erc20-token-periodic terms: startTime must be a positive number', + ); + }); + it('validateAndDecodeData rejects when periodDuration exceeds MAX_PERIOD_DURATION', () => { expect(() => decoder.validateAndDecodeData( diff --git a/packages/7715-permission-types/test/permissions/caveats/nativeTokenAllowance.test.ts b/packages/7715-permission-types/test/permissions/caveats/nativeTokenAllowance.test.ts index 01e870c7..190580f7 100644 --- a/packages/7715-permission-types/test/permissions/caveats/nativeTokenAllowance.test.ts +++ b/packages/7715-permission-types/test/permissions/caveats/nativeTokenAllowance.test.ts @@ -130,6 +130,20 @@ describe('native-token-allowance decoder config', () => { ); }); + it('validateAndDecodeData rejects when startTime is zero', () => { + const invalidTerms = + `0x${ALLOWANCE_AMOUNT_HEX}${UINT256_MAX.slice(2)}${toWord(0)}` as Hex; + + expect(() => + decoder.validateAndDecodeData( + makeCaveats(invalidTerms), + decoder.contractAddresses, + ), + ).toThrow( + 'Invalid native-token-allowance terms: startTime must be a positive number', + ); + }); + it('validateAndDecodeData rejects zero allowanceAmount', () => { const zeroAllowanceAmount = '0'.repeat(64); const invalidTerms = diff --git a/packages/7715-permission-types/test/permissions/caveats/nativeTokenPeriodic.test.ts b/packages/7715-permission-types/test/permissions/caveats/nativeTokenPeriodic.test.ts index 4ee97f1a..2d2cb904 100644 --- a/packages/7715-permission-types/test/permissions/caveats/nativeTokenPeriodic.test.ts +++ b/packages/7715-permission-types/test/permissions/caveats/nativeTokenPeriodic.test.ts @@ -144,6 +144,17 @@ describe('native-token-periodic decoder config', () => { ); }); + it('validateAndDecodeData rejects when startTime is zero', () => { + expect(() => + decoder.validateAndDecodeData( + makeCaveats(makeTerms({ startDate: 0 })), + decoder.contractAddresses, + ), + ).toThrow( + 'Invalid native-token-periodic terms: startTime must be a positive number', + ); + }); + it('validateAndDecodeData rejects when periodDuration exceeds MAX_PERIOD_DURATION', () => { expect(() => decoder.validateAndDecodeData( From 5227579164479babe0dd5783b1fdcb9754a47bd7 Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Thu, 18 Jun 2026 12:22:15 +1200 Subject: [PATCH 14/16] Move @metamask/delegation-deployments to devDependencies --- packages/7715-permission-types/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/7715-permission-types/package.json b/packages/7715-permission-types/package.json index 78e3176d..dc484871 100644 --- a/packages/7715-permission-types/package.json +++ b/packages/7715-permission-types/package.json @@ -57,6 +57,7 @@ }, "devDependencies": { "@metamask/auto-changelog": "^6.1.1", + "@metamask/delegation-deployments": "^1.4.0", "@types/node": "^20.19.0", "compare-versions": "^6.1.1", "eslint": "^9.39.2", @@ -67,7 +68,6 @@ }, "dependencies": { "@metamask/delegation-core": "^2.2.1", - "@metamask/delegation-deployments": "^1.4.0", "@metamask/utils": "^11.4.0" } } From 2c7dbd9a7803d0d634b4fbeb3474bd1fbb5d5445 Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Thu, 18 Jun 2026 12:33:25 +1200 Subject: [PATCH 15/16] splitHex now returns a correctly sized Hex[] array --- .../src/permissions/caveats/erc20TokenAllowance.ts | 8 -------- .../permissions/caveats/nativeTokenAllowance.ts | 3 --- .../7715-permission-types/src/permissions/utils.ts | 14 ++++++++++++-- 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/packages/7715-permission-types/src/permissions/caveats/erc20TokenAllowance.ts b/packages/7715-permission-types/src/permissions/caveats/erc20TokenAllowance.ts index ac9bebbc..4fd9add8 100644 --- a/packages/7715-permission-types/src/permissions/caveats/erc20TokenAllowance.ts +++ b/packages/7715-permission-types/src/permissions/caveats/erc20TokenAllowance.ts @@ -88,14 +88,6 @@ function validateAndDecodeData( const [tokenAddress, allowanceAmount, periodDurationRaw, startTimeRaw] = splitHex(terms, [20, 32, 32, 32]); - if ( - !tokenAddress || - !allowanceAmount || - !periodDurationRaw || - !startTimeRaw - ) { - throw new Error('Invalid erc20-token-allowance terms'); - } if (periodDurationRaw.toLowerCase() !== UINT256_MAX) { throw new Error( diff --git a/packages/7715-permission-types/src/permissions/caveats/nativeTokenAllowance.ts b/packages/7715-permission-types/src/permissions/caveats/nativeTokenAllowance.ts index 2035d61b..c3b141ec 100644 --- a/packages/7715-permission-types/src/permissions/caveats/nativeTokenAllowance.ts +++ b/packages/7715-permission-types/src/permissions/caveats/nativeTokenAllowance.ts @@ -92,9 +92,6 @@ function validateAndDecodeData( terms, [32, 32, 32], ); - if (!allowanceAmount || !periodDurationRaw || !startTimeRaw) { - throw new Error('Invalid native-token-allowance terms'); - } if (periodDurationRaw.toLowerCase() !== UINT256_MAX) { throw new Error( diff --git a/packages/7715-permission-types/src/permissions/utils.ts b/packages/7715-permission-types/src/permissions/utils.ts index dd5f7203..67f7a518 100644 --- a/packages/7715-permission-types/src/permissions/utils.ts +++ b/packages/7715-permission-types/src/permissions/utils.ts @@ -125,6 +125,13 @@ export const getByteLength = (hexString: Hex): number => { return (hexString.length - 2) / 2; }; +/** + * A tuple of TElement, the same length as the specified type TInput + */ +type Tuple = { + [K in keyof TInput]: TElement; +}; + /** * Splits a 0x-prefixed hex string into parts according to the provided byte lengths. * @@ -132,7 +139,10 @@ export const getByteLength = (hexString: Hex): number => { * @param lengths - Segment lengths in bytes. * @returns The split hex segments, each with `0x` prefix. */ -export function splitHex(value: Hex, lengths: number[]): Hex[] { +export function splitHex( + value: Hex, + lengths: TLengths, +): Tuple { let start = 2; const parts: Hex[] = []; for (const partLength of lengths) { @@ -141,7 +151,7 @@ export function splitHex(value: Hex, lengths: number[]): Hex[] { start += partCharLength; parts.push(`0x${part}` as const); } - return parts; + return parts as Tuple; } /** From ec575aca06e2500073950f8e5c9fbf6788b26901 Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Thu, 18 Jun 2026 12:42:54 +1200 Subject: [PATCH 16/16] Export DeployedContractsByName and PermissionDecoderConfig types --- packages/7715-permission-types/src/index.ts | 7 +++++-- packages/7715-permission-types/src/permissions/index.ts | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/7715-permission-types/src/index.ts b/packages/7715-permission-types/src/index.ts index 90301adc..fa595af4 100644 --- a/packages/7715-permission-types/src/index.ts +++ b/packages/7715-permission-types/src/index.ts @@ -20,5 +20,8 @@ export type { } from './types'; export type { PayeeRule, RedeemerRule, ExpiryRule } from './permissions'; - -export { makePermissionDecoderConfigs } from './permissions'; +export { + makePermissionDecoderConfigs, + type DeployedContractsByName, + type PermissionDecoderConfig, +} from './permissions'; diff --git a/packages/7715-permission-types/src/permissions/index.ts b/packages/7715-permission-types/src/permissions/index.ts index 4c891150..12aeec06 100644 --- a/packages/7715-permission-types/src/permissions/index.ts +++ b/packages/7715-permission-types/src/permissions/index.ts @@ -11,7 +11,7 @@ import { getChecksumEnforcersByChainId } from './utils'; export type { ExpiryRule } from './rules/expiry'; export type { PayeeRule } from './rules/payee'; export type { RedeemerRule } from './rules/redeemer'; - +export type { DeployedContractsByName, PermissionDecoderConfig }; /** * Builds the canonical set of permission decoders for a chain. *