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 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..dc484871 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", + "@metamask/delegation-deployments": "^1.4.0", + "@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/utils": "^11.4.0" } } diff --git a/packages/7715-permission-types/src/index.ts b/packages/7715-permission-types/src/index.ts index cb4553a4..fa595af4 100644 --- a/packages/7715-permission-types/src/index.ts +++ b/packages/7715-permission-types/src/index.ts @@ -18,3 +18,10 @@ export type { RevokeExecutionPermissionResponseResult, MetaMaskBasePermissionData, } from './types'; + +export type { PayeeRule, RedeemerRule, ExpiryRule } from './permissions'; +export { + makePermissionDecoderConfigs, + type DeployedContractsByName, + type PermissionDecoderConfig, +} from './permissions'; diff --git a/packages/7715-permission-types/src/permissions/caveats/erc20TokenAllowance.ts b/packages/7715-permission-types/src/permissions/caveats/erc20TokenAllowance.ts new file mode 100644 index 00000000..4fd9add8 --- /dev/null +++ b/packages/7715-permission-types/src/permissions/caveats/erc20TokenAllowance.ts @@ -0,0 +1,113 @@ +import { hexToNumber } from '@metamask/utils'; + +import type { Erc20TokenAllowancePermission } from '../../types'; +import { expiryRuleDecoder } from '../rules/expiry'; +import { erc20PayeeRuleDecoder } from '../rules/payee'; +import { redeemerRuleDecoder } from '../rules/redeemer'; +import type { + ChecksumCaveat, + ChecksumEnforcersByChainId, + DecodedPermissionData, + 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: [expiryRuleDecoder, 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, +): DecodedPermissionData { + 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 (periodDurationRaw.toLowerCase() !== UINT256_MAX) { + throw new Error( + 'Invalid erc20-token-allowance terms: periodDuration must be UINT256_MAX', + ); + } + + const startTime = hexToNumber(startTimeRaw); + + if (startTime === 0) { + throw new Error( + 'Invalid erc20-token-allowance terms: startTime must be a positive number', + ); + } + + if (allowanceAmount === ZERO_32_BYTES) { + throw new Error( + 'Invalid erc20-token-allowance terms: allowanceAmount must be a positive number', + ); + } + + return { tokenAddress, allowanceAmount, startTime }; +} diff --git a/packages/7715-permission-types/src/permissions/caveats/erc20TokenPeriodic.ts b/packages/7715-permission-types/src/permissions/caveats/erc20TokenPeriodic.ts new file mode 100644 index 00000000..406ad93d --- /dev/null +++ b/packages/7715-permission-types/src/permissions/caveats/erc20TokenPeriodic.ts @@ -0,0 +1,119 @@ +import { decodeERC20TokenPeriodTransferTerms } from '@metamask/delegation-core'; +import { bigIntToHex } from '@metamask/utils'; + +import type { Erc20TokenPeriodicPermission } from '../../types'; +import { expiryRuleDecoder } from '../rules/expiry'; +import { erc20PayeeRuleDecoder } from '../rules/payee'; +import { redeemerRuleDecoder } from '../rules/redeemer'; +import type { + ChecksumCaveat, + ChecksumEnforcersByChainId, + DecodedPermissionData, + MakePermissionDecoderConfig, +} from '../types'; +import { + getTermsByEnforcer, + MAX_PERIOD_DURATION, + 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: [expiryRuleDecoder, 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, +): DecodedPermissionData { + 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 { + tokenAddress, + periodAmount, + periodDuration, + startDate: startTime, + } = decodeERC20TokenPeriodTransferTerms(terms); + + if (periodAmount === 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: 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 new file mode 100644 index 00000000..ef61021a --- /dev/null +++ b/packages/7715-permission-types/src/permissions/caveats/erc20TokenStream.ts @@ -0,0 +1,106 @@ +import { decodeERC20StreamingTerms } from '@metamask/delegation-core'; +import { bigIntToHex } from '@metamask/utils'; + +import type { Erc20TokenStreamPermission } from '../../types'; +import { expiryRuleDecoder } from '../rules/expiry'; +import { erc20PayeeRuleDecoder } from '../rules/payee'; +import { redeemerRuleDecoder } from '../rules/redeemer'; +import type { + ChecksumCaveat, + ChecksumEnforcersByChainId, + DecodedPermissionData, + MakePermissionDecoderConfig, +} from '../types'; +import { getTermsByEnforcer, 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: [expiryRuleDecoder, 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, +): DecodedPermissionData { + 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 { tokenAddress, initialAmount, maxAmount, amountPerSecond, startTime } = + decodeERC20StreamingTerms(terms); + + if (maxAmount <= initialAmount) { + throw new Error( + 'Invalid erc20-token-stream terms: maxAmount must be greater than initialAmount', + ); + } + + if (amountPerSecond === 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: 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 new file mode 100644 index 00000000..c3b141ec --- /dev/null +++ b/packages/7715-permission-types/src/permissions/caveats/nativeTokenAllowance.ts @@ -0,0 +1,117 @@ +import { hexToNumber } from '@metamask/utils'; + +import type { NativeTokenAllowancePermission } from '../../types'; +import { expiryRuleDecoder } from '../rules/expiry'; +import { nativePayeeRuleDecoder } from '../rules/payee'; +import { redeemerRuleDecoder } from '../rules/redeemer'; +import type { + ChecksumCaveat, + ChecksumEnforcersByChainId, + DecodedPermissionData, + 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: [expiryRuleDecoder, 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, +): DecodedPermissionData { + 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 (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/caveats/nativeTokenPeriodic.ts b/packages/7715-permission-types/src/permissions/caveats/nativeTokenPeriodic.ts new file mode 100644 index 00000000..98b2cccf --- /dev/null +++ b/packages/7715-permission-types/src/permissions/caveats/nativeTokenPeriodic.ts @@ -0,0 +1,114 @@ +import { decodeNativeTokenPeriodTransferTerms } from '@metamask/delegation-core'; +import { bigIntToHex } from '@metamask/utils'; + +import type { NativeTokenPeriodicPermission } from '../../types'; +import { expiryRuleDecoder } from '../rules/expiry'; +import { nativePayeeRuleDecoder } from '../rules/payee'; +import { redeemerRuleDecoder } from '../rules/redeemer'; +import type { + ChecksumCaveat, + ChecksumEnforcersByChainId, + DecodedPermissionData, + MakePermissionDecoderConfig, +} from '../types'; +import { getTermsByEnforcer, MAX_PERIOD_DURATION } 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: [expiryRuleDecoder, 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, +): DecodedPermissionData { + 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 { + periodAmount, + periodDuration, + startDate: startTime, + } = decodeNativeTokenPeriodTransferTerms(terms); + + if (periodAmount === 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: 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 new file mode 100644 index 00000000..1c7a4ee7 --- /dev/null +++ b/packages/7715-permission-types/src/permissions/caveats/nativeTokenStream.ts @@ -0,0 +1,106 @@ +import { decodeNativeTokenStreamingTerms } from '@metamask/delegation-core'; +import { bigIntToHex } from '@metamask/utils'; + +import type { NativeTokenStreamPermission } from '../../types'; +import { expiryRuleDecoder } from '../rules/expiry'; +import { nativePayeeRuleDecoder } from '../rules/payee'; +import { redeemerRuleDecoder } from '../rules/redeemer'; +import type { + ChecksumCaveat, + ChecksumEnforcersByChainId, + DecodedPermissionData, + MakePermissionDecoderConfig, +} from '../types'; +import { getTermsByEnforcer } 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: [expiryRuleDecoder, 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, +): DecodedPermissionData { + 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 { initialAmount, maxAmount, amountPerSecond, startTime } = + decodeNativeTokenStreamingTerms(terms); + + if (maxAmount <= initialAmount) { + throw new Error( + 'Invalid native-token-stream terms: maxAmount must be greater than initialAmount', + ); + } + + if (amountPerSecond === 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: 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 new file mode 100644 index 00000000..54e74ca0 --- /dev/null +++ b/packages/7715-permission-types/src/permissions/caveats/tokenApprovalRevocation.ts @@ -0,0 +1,75 @@ +import { decodeApprovalRevocationTerms } from '@metamask/delegation-core'; + +import type { TokenApprovalRevocationPermission } from '../../types'; +import { expiryRuleDecoder } from '../rules/expiry'; +import type { + ChecksumCaveat, + ChecksumEnforcersByChainId, + DecodedPermissionData, + MakePermissionDecoderConfig, +} from '../types'; +import { getTermsByEnforcer } from '../utils'; + +/** + * 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: [expiryRuleDecoder], + 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, +): DecodedPermissionData { + const { approvalRevocationEnforcer } = contractAddresses; + + const terms = getTermsByEnforcer({ + caveats, + enforcer: approvalRevocationEnforcer, + }); + + const { + erc20Approve, + erc721Approve, + erc721SetApprovalForAll, + permit2Approve, + permit2Lockdown, + permit2InvalidateNonces, + } = decodeApprovalRevocationTerms(terms); + + return { + erc20Approve, + erc721Approve, + erc721SetApprovalForAll, + permit2Approve, + permit2Lockdown, + permit2InvalidateNonces, + }; +} 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..12aeec06 --- /dev/null +++ b/packages/7715-permission-types/src/permissions/index.ts @@ -0,0 +1,35 @@ +import { makeErc20TokenAllowanceDecoderConfig } from './caveats/erc20TokenAllowance'; +import { makeErc20TokenPeriodicDecoderConfig } from './caveats/erc20TokenPeriodic'; +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 { DeployedContractsByName, PermissionDecoderConfig } from './types'; +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. + * + * @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), + makeTokenApprovalRevocationDecoderConfig(contractAddresses), + ]; +}; diff --git a/packages/7715-permission-types/src/permissions/rules/expiry.ts b/packages/7715-permission-types/src/permissions/rules/expiry.ts new file mode 100644 index 00000000..c39d8b7b --- /dev/null +++ b/packages/7715-permission-types/src/permissions/rules/expiry.ts @@ -0,0 +1,65 @@ +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; + +/** + * 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 expiryRuleDecoder: RuleDecoder = ({ + contractAddresses, + caveats, +}) => { + const { timestampEnforcer } = contractAddresses; + + const expiryTerms = getTermsByEnforcer({ + caveats, + enforcer: timestampEnforcer, + throwIfNotFound: false, + }); + + if (!expiryTerms) { + 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: timestampBeforeThreshold }, + }; +}; diff --git a/packages/7715-permission-types/src/permissions/rules/payee.ts b/packages/7715-permission-types/src/permissions/rules/payee.ts new file mode 100644 index 00000000..532d662f --- /dev/null +++ b/packages/7715-permission-types/src/permissions/rules/payee.ts @@ -0,0 +1,119 @@ +import { + decodeAllowedCalldataTerms, + decodeAllowedTargetsTerms, +} from '@metamask/delegation-core'; +import { getChecksumAddress } from '@metamask/utils'; + +import type { Hex } from '../../types'; +import type { RuleDecoder } from '../types'; +import { getByteLength, getTermsByEnforcer } 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. + * + * @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 terms = getTermsByEnforcer({ + caveats, + enforcer: allowedCalldataEnforcer, + throwIfNotFound: false, + }); + + if (!terms) { + return null; + } + + const decoded = decodeAllowedCalldataTerms(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)] }, + }; +}; + +/** + * 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 terms = getTermsByEnforcer({ + caveats, + enforcer: allowedTargetsEnforcer, + throwIfNotFound: false, + }); + + if (!terms) { + return null; + } + + const decoded = decodeAllowedTargetsTerms(terms); + + return { + type: EXECUTION_PERMISSION_PAYEE_RULE_TYPE, + data: { addresses: decoded.targets.map(getChecksumAddress) }, + }; +}; diff --git a/packages/7715-permission-types/src/permissions/rules/redeemer.ts b/packages/7715-permission-types/src/permissions/rules/redeemer.ts new file mode 100644 index 00000000..0c372b7f --- /dev/null +++ b/packages/7715-permission-types/src/permissions/rules/redeemer.ts @@ -0,0 +1,55 @@ +import { decodeRedeemerTerms } from '@metamask/delegation-core'; +import { getChecksumAddress } from '@metamask/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. + * + * @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 terms = getTermsByEnforcer({ + caveats, + enforcer: redeemerEnforcer, + throwIfNotFound: false, + }); + + if (!terms) { + return null; + } + + const { redeemers } = decodeRedeemerTerms(terms); + + const addresses = redeemers.map(getChecksumAddress); + + return { + type: EXECUTION_PERMISSION_REDEEMER_RULE_TYPE, + data: { + addresses, + }, + }; +}; 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..93b17360 --- /dev/null +++ b/packages/7715-permission-types/src/permissions/types.ts @@ -0,0 +1,97 @@ +import type { Caveat } from '@metamask/delegation-core'; + +import type { Hex, PermissionTypes, Rule } from '../types'; + +/** + * 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; + +/** + * Type of the `data` parameter of a decoded permission. + */ +export type DecodedPermissionData< + TPermissionType extends PermissionTypes = PermissionTypes, +> = TPermissionType['data']; + +/** + * Supported permission type identifiers that can be decoded from a permission context. + */ +export type PermissionType = PermissionTypes['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, + ) => DecodedPermissionData; +}; + +/** + * 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: DecodedPermissionData; + 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..67f7a518 --- /dev/null +++ b/packages/7715-permission-types/src/permissions/utils.ts @@ -0,0 +1,227 @@ +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?: 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 (matchingCaveat) { + throw new Error( + `Invalid caveats: multiple caveats found matching enforcer ${enforcer}`, + ); + } + + matchingCaveat = caveat; + } + + if (!matchingCaveat) { + if (throwIfNotFound) { + throw new Error( + `Invalid caveats: no caveat found matching enforcer ${enforcer}`, + ); + } + + return null; + } + + return matchingCaveat.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; +}; + +/** + * 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. + * + * @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: TLengths, +): Tuple { + 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 as Tuple; +} + +/** + * 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/permissions/caveats/erc20TokenAllowance.test.ts b/packages/7715-permission-types/test/permissions/caveats/erc20TokenAllowance.test.ts new file mode 100644 index 00000000..c8dc3e61 --- /dev/null +++ b/packages/7715-permission-types/test/permissions/caveats/erc20TokenAllowance.test.ts @@ -0,0 +1,164 @@ +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 { 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'; +import { + getChecksumEnforcersByChainId, + UINT256_MAX, + ZERO_32_BYTES, +} from '../../../src/permissions/utils'; +import { toWord } from '../../test-utils'; + +describe('erc20-token-allowance decoder config', () => { + const chainId = CHAIN_ID.sepolia; + const contracts = DELEGATOR_CONTRACTS['1.3.0'][chainId]; + const { + timestampEnforcer, + erc20PeriodicEnforcer, + valueLteEnforcer, + nonceEnforcer, + allowedCalldataEnforcer, + redeemerEnforcer, + } = getChecksumEnforcersByChainId(contracts); + const decoder = makeErc20TokenAllowanceDecoderConfig( + getChecksumEnforcersByChainId(contracts), + ); + const TOKEN_ADDRESS_HEX = 'aa'.repeat(20); + 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, + args: '0x', + }, + { + enforcer: valueLteEnforcer, + terms: valueLteTerms, + args: '0x', + }, + { + enforcer: nonceEnforcer, + terms: '0x', + args: '0x', + }, + ]; + + 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([ + expiryRuleDecoder, + redeemerRuleDecoder, + erc20PayeeRuleDecoder, + ]); + }); + }); + + describe('validateAndDecodeData', () => { + it('provides a validateAndDecodeData function', () => { + expect(typeof decoder.validateAndDecodeData).toBe('function'); + }); + + it('is included in makePermissionDecoderConfigs', () => { + expect(makePermissionDecoderConfigs(contracts)).toContainEqual(decoder); + }); + + 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, + }); + }); + + 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}`); + }); + + 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', + ); + }); + + 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 = + `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 new file mode 100644 index 00000000..bc01703f --- /dev/null +++ b/packages/7715-permission-types/test/permissions/caveats/erc20TokenPeriodic.test.ts @@ -0,0 +1,194 @@ +import { + CHAIN_ID, + DELEGATOR_CONTRACTS, +} from '@metamask/delegation-deployments'; +import { bigIntToHex, type Hex } from '@metamask/utils'; +import { describe, it, expect } from 'vitest'; + +import { makePermissionDecoderConfigs } from '../../../src/permissions'; +import { makeErc20TokenPeriodicDecoderConfig } from '../../../src/permissions/caveats/erc20TokenPeriodic'; +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'; +import { + getChecksumEnforcersByChainId, + 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; + const contracts = DELEGATOR_CONTRACTS['1.3.0'][chainId]; + const { + timestampEnforcer, + erc20PeriodicEnforcer, + valueLteEnforcer, + nonceEnforcer, + allowedCalldataEnforcer, + redeemerEnforcer, + } = getChecksumEnforcersByChainId(contracts); + const decoder = makeErc20TokenPeriodicDecoderConfig( + getChecksumEnforcersByChainId(contracts), + ); + + 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 => { + const periodAmountHex = toWord(periodAmount); + const periodDurationHex = toWord(periodDuration); + const startDateHex = toWord(startDate); + + return `${tokenAddress}${periodAmountHex}${periodDurationHex}${startDateHex}`; + }; + + const makeCaveats = ( + terms: Hex, + valueLteTerms: Hex = ZERO_32_BYTES, + ): ChecksumCaveat[] => [ + { + enforcer: erc20PeriodicEnforcer, + terms, + args: '0x', + }, + { + enforcer: valueLteEnforcer, + terms: valueLteTerms, + args: '0x', + }, + { + enforcer: nonceEnforcer, + terms: '0x', + args: '0x', + }, + ]; + + 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([ + expiryRuleDecoder, + redeemerRuleDecoder, + erc20PayeeRuleDecoder, + ]); + }); + }); + + describe('validateAndDecodeData', () => { + it('provides a validateAndDecodeData function', () => { + expect(typeof decoder.validateAndDecodeData).toBe('function'); + }); + + it('is included in makePermissionDecoderConfigs', () => { + expect(makePermissionDecoderConfigs(contracts)).toContainEqual(decoder); + }); + + it('validateAndDecodeData decodes valid periodic terms', () => { + expect( + decoder.validateAndDecodeData( + makeCaveats(makeTerms()), + decoder.contractAddresses, + ), + ).toStrictEqual({ + tokenAddress: TOKEN_ADDRESS, + periodAmount: bigIntToHex(100n), + periodDuration: 86400, + 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, + ), + ).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 erc20-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, + ), + ).toThrow( + 'Invalid erc20-token-periodic terms: periodAmount must be a positive number', + ); + }); + + 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( + 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: 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 new file mode 100644 index 00000000..db4fd7a9 --- /dev/null +++ b/packages/7715-permission-types/test/permissions/caveats/erc20TokenStream.test.ts @@ -0,0 +1,175 @@ +import { + CHAIN_ID, + DELEGATOR_CONTRACTS, +} from '@metamask/delegation-deployments'; +import { bigIntToHex, type Hex } from '@metamask/utils'; +import { describe, it, expect } from 'vitest'; + +import { makePermissionDecoderConfigs } from '../../../src/permissions'; +import { makeErc20TokenStreamDecoderConfig } from '../../../src/permissions/caveats/erc20TokenStream'; +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'; +import { + getChecksumEnforcersByChainId, + ZERO_32_BYTES, +} from '../../../src/permissions/utils'; +import { toWord } from '../../test-utils'; + +describe('erc20-token-stream decoder config', () => { + const chainId = CHAIN_ID.sepolia; + const contracts = DELEGATOR_CONTRACTS['1.3.0'][chainId]; + const { + timestampEnforcer, + erc20StreamingEnforcer, + valueLteEnforcer, + nonceEnforcer, + allowedCalldataEnforcer, + redeemerEnforcer, + } = getChecksumEnforcersByChainId(contracts); + const decoder = makeErc20TokenStreamDecoderConfig( + getChecksumEnforcersByChainId(contracts), + ); + const TOKEN_ADDRESS = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex; + const START_TIME = 1715664; + const makeTerms = ({ + 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, + args: '0x', + }, + { + enforcer: valueLteEnforcer, + terms: valueLteTerms, + args: '0x', + }, + { + enforcer: nonceEnforcer, + terms: '0x', + args: '0x', + }, + ]; + + 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([ + expiryRuleDecoder, + redeemerRuleDecoder, + erc20PayeeRuleDecoder, + ]); + }); + }); + + describe('validateAndDecodeData', () => { + it('provides a validateAndDecodeData function', () => { + expect(typeof decoder.validateAndDecodeData).toBe('function'); + }); + + it('is included in makePermissionDecoderConfigs', () => { + expect(makePermissionDecoderConfigs(contracts)).toContainEqual(decoder); + }); + + it('validateAndDecodeData decodes valid stream terms', () => { + expect( + decoder.validateAndDecodeData( + makeCaveats(makeTerms({})), + decoder.contractAddresses, + ), + ).toStrictEqual({ + tokenAddress: TOKEN_ADDRESS, + initialAmount: bigIntToHex(10n), + maxAmount: bigIntToHex(100n), + amountPerSecond: bigIntToHex(5n), + 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, + ), + ).toThrow(`Invalid value-lte terms: must be ${ZERO_32_BYTES}`); + }); + + it('validateAndDecodeData rejects when maxAmount equals initialAmount', () => { + const invalidTerms = makeTerms({ + initialAmount: 100n, + maxAmount: 100n, + }); + + expect(() => + decoder.validateAndDecodeData( + makeCaveats(invalidTerms), + decoder.contractAddresses, + ), + ).toThrow( + 'Invalid erc20-token-stream terms: maxAmount must be greater than initialAmount', + ); + }); + + it('validateAndDecodeData rejects when amountPerSecond is zero', () => { + const invalidTerms = makeTerms({ 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 = makeTerms({ 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 new file mode 100644 index 00000000..190580f7 --- /dev/null +++ b/packages/7715-permission-types/test/permissions/caveats/nativeTokenAllowance.test.ts @@ -0,0 +1,162 @@ +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 { 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'; +import { + getChecksumEnforcersByChainId, + UINT256_MAX, +} from '../../../src/permissions/utils'; +import { toWord } from '../../test-utils'; + +describe('native-token-allowance decoder config', () => { + const chainId = CHAIN_ID.sepolia; + const contracts = DELEGATOR_CONTRACTS['1.3.0'][chainId]; + const { + timestampEnforcer, + nativeTokenPeriodicEnforcer, + exactCalldataEnforcer, + nonceEnforcer, + allowedTargetsEnforcer, + redeemerEnforcer, + } = getChecksumEnforcersByChainId(contracts); + const decoder = makeNativeTokenAllowanceDecoderConfig( + getChecksumEnforcersByChainId(contracts), + ); + + 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, + args: '0x', + }, + { + enforcer: exactCalldataEnforcer, + terms: exactCalldataTerms, + args: '0x', + }, + { + enforcer: nonceEnforcer, + terms: '0x', + args: '0x', + }, + ]; + + 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([ + expiryRuleDecoder, + redeemerRuleDecoder, + nativePayeeRuleDecoder, + ]); + }); + }); + + describe('validateAndDecodeData', () => { + it('provides a validateAndDecodeData function', () => { + expect(typeof decoder.validateAndDecodeData).toBe('function'); + }); + + it('is included in makePermissionDecoderConfigs', () => { + expect(makePermissionDecoderConfigs(contracts)).toContainEqual(decoder); + }); + + it('validateAndDecodeData decodes valid allowance terms', () => { + expect( + decoder.validateAndDecodeData( + makeCaveats(VALID_ALLOWANCE_TERMS), + decoder.contractAddresses, + ), + ).toStrictEqual({ + allowanceAmount: `0x${ALLOWANCE_AMOUNT_HEX}`, + startTime: START_TIME, + }); + }); + + 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'); + }); + + 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', + ); + }); + + 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 = + `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 new file mode 100644 index 00000000..2d2cb904 --- /dev/null +++ b/packages/7715-permission-types/test/permissions/caveats/nativeTokenPeriodic.test.ts @@ -0,0 +1,182 @@ +import { + CHAIN_ID, + DELEGATOR_CONTRACTS, +} from '@metamask/delegation-deployments'; +import { bigIntToHex, type Hex } from '@metamask/utils'; +import { describe, it, expect } from 'vitest'; + +import { makePermissionDecoderConfigs } from '../../../src/permissions'; +import { makeNativeTokenPeriodicDecoderConfig } from '../../../src/permissions/caveats/nativeTokenPeriodic'; +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'; +import { + getChecksumEnforcersByChainId, + MAX_PERIOD_DURATION, +} from '../../../src/permissions/utils'; +import { toWord } from '../../test-utils'; + +describe('native-token-periodic decoder config', () => { + const chainId = CHAIN_ID.sepolia; + const contracts = DELEGATOR_CONTRACTS['1.3.0'][chainId]; + const { + timestampEnforcer, + nativeTokenPeriodicEnforcer, + exactCalldataEnforcer, + nonceEnforcer, + allowedTargetsEnforcer, + redeemerEnforcer, + } = getChecksumEnforcersByChainId(contracts); + const decoder = makeNativeTokenPeriodicDecoderConfig( + getChecksumEnforcersByChainId(contracts), + ); + const START_TIME = 1715664; + 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; + }; + + const makeCaveats = ( + nativeTokenPeriodicTerms: Hex, + exactCalldataTerms: Hex = '0x', + ): ChecksumCaveat[] => [ + { + enforcer: nativeTokenPeriodicEnforcer, + terms: nativeTokenPeriodicTerms, + args: '0x', + }, + { + enforcer: exactCalldataEnforcer, + terms: exactCalldataTerms, + args: '0x', + }, + { + enforcer: nonceEnforcer, + terms: '0x', + args: '0x', + }, + ]; + + 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([ + expiryRuleDecoder, + redeemerRuleDecoder, + nativePayeeRuleDecoder, + ]); + }); + }); + + describe('validateAndDecodeData', () => { + it('provides a validateAndDecodeData function', () => { + expect(typeof decoder.validateAndDecodeData).toBe('function'); + }); + + it('is included in makePermissionDecoderConfigs', () => { + expect(makePermissionDecoderConfigs(contracts)).toContainEqual(decoder); + }); + + it('validateAndDecodeData decodes valid periodic terms', () => { + expect( + decoder.validateAndDecodeData( + makeCaveats(makeTerms({})), + decoder.contractAddresses, + ), + ).toStrictEqual({ + periodAmount: bigIntToHex(100n), + periodDuration: 86400, + startTime: START_TIME, + }); + }); + + it('validateAndDecodeData rejects exact-calldata terms that are not 0x', () => { + expect(() => + decoder.validateAndDecodeData( + makeCaveats(makeTerms({}), '0x00'), + decoder.contractAddresses, + ), + ).toThrow('Invalid exact-calldata terms: must be 0x'); + }); + + it('validateAndDecodeData rejects when periodDuration is zero', () => { + expect(() => + decoder.validateAndDecodeData( + makeCaveats(makeTerms({ periodDuration: 0 })), + decoder.contractAddresses, + ), + ).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, + ), + ).toThrow( + 'Invalid native-token-periodic terms: periodAmount must be a positive number', + ); + }); + + 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( + 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: 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 new file mode 100644 index 00000000..5b198b41 --- /dev/null +++ b/packages/7715-permission-types/test/permissions/caveats/nativeTokenStream.test.ts @@ -0,0 +1,158 @@ +import { + CHAIN_ID, + DELEGATOR_CONTRACTS, +} from '@metamask/delegation-deployments'; +import { bigIntToHex, type Hex } from '@metamask/utils'; +import { describe, it, expect } from 'vitest'; + +import { makePermissionDecoderConfigs } from '../../../src/permissions'; +import { makeNativeTokenStreamDecoderConfig } from '../../../src/permissions/caveats/nativeTokenStream'; +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'; +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, + nonceEnforcer, + allowedTargetsEnforcer, + redeemerEnforcer, + } = getChecksumEnforcersByChainId(contracts); + const decoder = makeNativeTokenStreamDecoderConfig( + getChecksumEnforcersByChainId(contracts), + ); + const START_TIME = 1715664; + 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; + }; + + const makeCaveats = ( + nativeTokenStreamingTerms: Hex, + exactCalldataTerms: Hex = '0x', + ): ChecksumCaveat[] => [ + { + enforcer: nativeTokenStreamingEnforcer, + terms: nativeTokenStreamingTerms, + args: '0x', + }, + { + enforcer: exactCalldataEnforcer, + terms: exactCalldataTerms, + args: '0x', + }, + { + enforcer: nonceEnforcer, + terms: '0x', + args: '0x', + }, + ]; + + 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([ + expiryRuleDecoder, + redeemerRuleDecoder, + nativePayeeRuleDecoder, + ]); + }); + }); + + describe('validateAndDecodeData', () => { + it('provides a validateAndDecodeData function', () => { + expect(typeof decoder.validateAndDecodeData).toBe('function'); + }); + + it('is included in makePermissionDecoderConfigs', () => { + expect(makePermissionDecoderConfigs(contracts)).toContainEqual(decoder); + }); + + it('validateAndDecodeData decodes valid stream terms', () => { + expect( + decoder.validateAndDecodeData( + makeCaveats(makeTerms({})), + decoder.contractAddresses, + ), + ).toStrictEqual({ + initialAmount: bigIntToHex(10n), + maxAmount: bigIntToHex(100n), + amountPerSecond: bigIntToHex(5n), + startTime: START_TIME, + }); + }); + + it('validateAndDecodeData rejects exact-calldata terms that are not 0x', () => { + expect(() => + decoder.validateAndDecodeData( + makeCaveats(makeTerms({}), '0x00'), + decoder.contractAddresses, + ), + ).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, + ), + ).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, + ), + ).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, + ), + ).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 new file mode 100644 index 00000000..cdffdd04 --- /dev/null +++ b/packages/7715-permission-types/test/permissions/caveats/tokenApprovalRevocation.test.ts @@ -0,0 +1,125 @@ +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 { makeTokenApprovalRevocationDecoderConfig } from '../../../src/permissions/caveats/tokenApprovalRevocation'; +import { expiryRuleDecoder } from '../../../src/permissions/rules/expiry'; +import type { ChecksumCaveat } from '../../../src/permissions/types'; +import { getChecksumEnforcersByChainId } from '../../../src/permissions/utils'; + +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 decoder = makeTokenApprovalRevocationDecoderConfig( + getChecksumEnforcersByChainId(contracts), + ); + + const makeCaveats = (approvalRevocationTerms: Hex): ChecksumCaveat[] => [ + { + enforcer: approvalRevocationEnforcer, + terms: approvalRevocationTerms, + args: '0x', + }, + { + enforcer: nonceEnforcer, + terms: '0x', + args: '0x', + }, + ]; + + describe('static configuration', () => { + it('exposes expected required enforcers', () => { + expect(decoder.requiredEnforcers).toStrictEqual({ + [approvalRevocationEnforcer]: 1, + [nonceEnforcer]: 1, + }); + }); + + it('exposes expected optional enforcers', () => { + expect(decoder.optionalEnforcers).toStrictEqual([timestampEnforcer]); + }); + + it('includes expected rule decoders in order', () => { + expect(decoder.rules).toStrictEqual([expiryRuleDecoder]); + }); + }); + + describe('validateAndDecodeData', () => { + it('provides a validateAndDecodeData function', () => { + expect(typeof decoder.validateAndDecodeData).toBe('function'); + }); + + it('is included in makePermissionDecoderConfigs', () => { + expect(makePermissionDecoderConfigs(contracts)).toContainEqual(decoder); + }); + + it('validateAndDecodeData rejects empty terms', () => { + expect(() => + decoder.validateAndDecodeData( + makeCaveats('0x'), + decoder.contractAddresses, + ), + ).toThrow('Invalid ApprovalRevocation terms: must be exactly 1 byte'); + }); + + it('validateAndDecodeData rejects 0x00 terms', () => { + expect(() => + decoder.validateAndDecodeData( + makeCaveats('0x00'), + decoder.contractAddresses, + ), + ).toThrow( + 'Invalid ApprovalRevocation terms: at least one revocation primitive must be enabled', + ); + }); + + 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)', + ); + }); + + 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, + }); + }); + + 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 new file mode 100644 index 00000000..c709c533 --- /dev/null +++ b/packages/7715-permission-types/test/permissions/rules/erc20Payee.test.ts @@ -0,0 +1,151 @@ +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 { describe, it, expect } from 'vitest'; + +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]; + 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 caveats: multiple caveats found matching enforcer ${allowedCalldataEnforcer}`, + ); + }); + + 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/permissions/rules/expiry.test.ts b/packages/7715-permission-types/test/permissions/rules/expiry.test.ts new file mode 100644 index 00000000..65ac4d51 --- /dev/null +++ b/packages/7715-permission-types/test/permissions/rules/expiry.test.ts @@ -0,0 +1,107 @@ +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 { expiryRuleDecoder } 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]; + 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( + expiryRuleDecoder({ 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( + expiryRuleDecoder({ 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( + 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 new file mode 100644 index 00000000..e8365b84 --- /dev/null +++ b/packages/7715-permission-types/test/permissions/rules/nativePayee.test.ts @@ -0,0 +1,132 @@ +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 { describe, it, expect } from 'vitest'; + +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]; + 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 caveats: multiple caveats found matching enforcer ${allowedTargetsEnforcer}`, + ); + }); +}); diff --git a/packages/7715-permission-types/test/permissions/rules/redeemer.test.ts b/packages/7715-permission-types/test/permissions/rules/redeemer.test.ts new file mode 100644 index 00000000..e8ff9ff9 --- /dev/null +++ b/packages/7715-permission-types/test/permissions/rules/redeemer.test.ts @@ -0,0 +1,103 @@ +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 { describe, it, expect } from 'vitest'; + +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]; + 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/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'); 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