Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/gator-permissions-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Add `token-approval-revocation` execution permission type decoding ([8823](https://github.com/MetaMask/core/pull/8823))

### Changed

- Bump `@metamask/transaction-controller` from `^65.1.0` to `^65.2.0` ([#8722](https://github.com/MetaMask/core/pull/8722))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type {
DeployedContractsByName,
PermissionDecoder,
} from './types';
import { getChecksumEnforcersByChainId } from './utils';

// These tests use the live deployments table for version 1.3.0 to
// construct deterministic caveat address sets for a known chain.
Expand All @@ -38,6 +39,8 @@ describe('decodePermission', () => {
NonceEnforcer,
RedeemerEnforcer,
} = contracts;
const { approvalRevocationEnforcer } =
getChecksumEnforcersByChainId(contracts);

describe('getPermissionDecoderMatchingCaveatTypes()', () => {
const zeroAddress = '0x0000000000000000000000000000000000000000' as Hex;
Expand Down Expand Up @@ -634,6 +637,68 @@ describe('decodePermission', () => {
).toThrow('Contract not found: AllowedCalldataEnforcer');
});
});

describe('token-approval-revocation', () => {
const expectedPermissionType = 'token-approval-revocation';

it('matches with ApprovalRevocationEnforcer and NonceEnforcer', () => {
const enforcers = [approvalRevocationEnforcer, NonceEnforcer];
const result = findDecoderWithMatchingCaveatAddresses({
enforcers,
permissionDecoders: createPermissionDecodersForContracts(contracts),
});
expect(result.permissionType).toBe(expectedPermissionType);
});

it('allows TimestampEnforcer as extra', () => {
const enforcers = [
approvalRevocationEnforcer,
NonceEnforcer,
TimestampEnforcer,
];
const result = findDecoderWithMatchingCaveatAddresses({
enforcers,
permissionDecoders: createPermissionDecodersForContracts(contracts),
});
expect(result.permissionType).toBe(expectedPermissionType);
});

it('rejects when NonceEnforcer is missing', () => {
const enforcers = [approvalRevocationEnforcer];
expect(() =>
findDecoderWithMatchingCaveatAddresses({
enforcers,
permissionDecoders: createPermissionDecodersForContracts(contracts),
}),
).toThrow('Unable to identify permission type');
});

it('rejects forbidden extra caveat', () => {
const enforcers = [
approvalRevocationEnforcer,
NonceEnforcer,
ValueLteEnforcer,
];
expect(() =>
findDecoderWithMatchingCaveatAddresses({
enforcers,
permissionDecoders: createPermissionDecodersForContracts(contracts),
}),
).toThrow('Unable to identify permission type');
});

it('accepts lowercased addresses', () => {
const enforcers: Hex[] = [
approvalRevocationEnforcer.toLowerCase() as unknown as Hex,
NonceEnforcer.toLowerCase() as unknown as Hex,
];
const result = findDecoderWithMatchingCaveatAddresses({
enforcers,
permissionDecoders: createPermissionDecodersForContracts(contracts),
});
expect(result.permissionType).toBe(expectedPermissionType);
});
});
});

describe('reconstructDecodedPermission', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { makePermissionDecoder } from './makePermissionDecoder';
import { makeNativeTokenAllowanceDecoderConfig } from './nativeTokenAllowance';
import { makeNativeTokenPeriodicDecoderConfig } from './nativeTokenPeriodic';
import { makeNativeTokenStreamDecoderConfig } from './nativeTokenStream';
import { makeTokenApprovalRevocationDecoderConfig } from './tokenApprovalRevocation';

/**
* Builds the canonical set of permission decoders for a chain.
Expand All @@ -32,5 +33,6 @@ export const createPermissionDecodersForContracts = (
makeErc20TokenPeriodicDecoderConfig(contractAddresses),
makeErc20TokenAllowanceDecoderConfig(contractAddresses),
makeErc20TokenRevocationDecoderConfig(contractAddresses),
makeTokenApprovalRevocationDecoderConfig(contractAddresses),
].map(makePermissionDecoder);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { createTimestampTerms } from '@metamask/delegation-core';
import {
CHAIN_ID,
DELEGATOR_CONTRACTS,
} from '@metamask/delegation-deployments';

import { createPermissionDecodersForContracts } from '.';
import { getChecksumEnforcersByChainId } from '../utils';

describe('token-approval-revocation decoder', () => {
const chainId = CHAIN_ID.sepolia;
const contracts = DELEGATOR_CONTRACTS['1.3.0'][chainId];
const { timestampEnforcer, approvalRevocationEnforcer, nonceEnforcer } =
getChecksumEnforcersByChainId(contracts);
const permissionDecoders = createPermissionDecodersForContracts(contracts);
const decoder = permissionDecoders.find(
(candidate) => candidate.permissionType === 'token-approval-revocation',
);

if (!decoder) {
throw new Error('Decoder not found');
}

const expiryCaveat = {
enforcer: timestampEnforcer,
terms: createTimestampTerms({
afterThreshold: 0,
beforeThreshold: 1720000,
}),
args: '0x' as const,
};

it('rejects empty terms', () => {
const caveats = [
expiryCaveat,
{
enforcer: approvalRevocationEnforcer,
terms: '0x' as const,
args: '0x' as const,
},
{
enforcer: nonceEnforcer,
terms: '0x' as const,
args: '0x' as const,
},
];

const result = decoder.validateAndDecodePermission(caveats);
expect(result.isValid).toBe(false);

if (result.isValid) {
throw new Error('Expected invalid result');
}

expect(result.error.message).toContain(
'Invalid ApprovalRevocation terms: must be greater than 0',
);
});

it('rejects terms whose mask exceeds the supported max', () => {
const caveats = [
expiryCaveat,
{
enforcer: approvalRevocationEnforcer,
terms: '0x40' as const,
args: '0x' as const,
},
{
enforcer: nonceEnforcer,
terms: '0x' as const,
args: '0x' as const,
},
];

const result = decoder.validateAndDecodePermission(caveats);
expect(result.isValid).toBe(false);

if (result.isValid) {
throw new Error('Expected invalid result');
}

expect(result.error.message).toContain(
'Invalid ApprovalRevocation terms: must be less than or equal to 63',
);
});

it('successfully decodes valid token-approval-revocation caveats', () => {
const caveats = [
expiryCaveat,
{
enforcer: approvalRevocationEnforcer,
terms: '0x01' as const,
args: '0x' as const,
},
{
enforcer: nonceEnforcer,
terms: '0x' as const,
args: '0x' as const,
},
];

const result = decoder.validateAndDecodePermission(caveats);
expect(result.isValid).toBe(true);

if (!result.isValid) {
throw new Error('Expected valid result');
}

expect(result.expiry).toBe(1720000);
expect(result.data).toStrictEqual({
erc20Approve: true,
erc721Approve: false,
erc721SetApprovalForAll: false,
permit2Approve: false,
permit2Lockdown: false,
permit2InvalidateNonces: false,
});
expect(result.rules).toStrictEqual([
{
type: 'expiry',
data: { timestamp: 1720000 },
},
]);
});

it('decodes all supported flags from the terms bitmask', () => {
const caveats = [
expiryCaveat,
{
enforcer: approvalRevocationEnforcer,
terms: '0x3f' as const,
args: '0x' as const,
},
{
enforcer: nonceEnforcer,
terms: '0x' as const,
args: '0x' as const,
},
];

const result = decoder.validateAndDecodePermission(caveats);
expect(result.isValid).toBe(true);

if (!result.isValid) {
throw new Error('Expected valid result');
}

expect(result.data).toStrictEqual({
erc20Approve: true,
erc721Approve: true,
erc721SetApprovalForAll: true,
permit2Approve: true,
permit2Lockdown: true,
permit2InvalidateNonces: true,
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// eslint-disable no-bitwise
import { hexToNumber } from '@metamask/utils';

import type {
ChecksumCaveat,
ChecksumEnforcersByChainId,
DecodedPermission,
} from '../types';
import { getTermsByEnforcer } from '../utils';
import { expiryRule } from './expiryRule';
import type { MakePermissionDecoderConfig } from './makePermissionDecoder';

enum ApprovalRevocationFlag {
Erc20Approve = 0x01,
Erc721Approve = 0x02,
Erc721SetApprovalForAll = 0x04,
Permit2Approve = 0x08,
Permit2Lockdown = 0x10,
Permit2InvalidateNonces = 0x20,
}

const MAX_APPROVAL_REVOCATION_MASK =
ApprovalRevocationFlag.Permit2InvalidateNonces |

Check failure on line 23 in packages/gator-permissions-controller/src/decodePermission/decoders/tokenApprovalRevocation.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (lint:eslint)

Unexpected use of '|'

Check failure on line 23 in packages/gator-permissions-controller/src/decodePermission/decoders/tokenApprovalRevocation.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (lint:eslint)

Unexpected use of '|'

Check failure on line 23 in packages/gator-permissions-controller/src/decodePermission/decoders/tokenApprovalRevocation.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (lint:eslint)

Unexpected use of '|'

Check failure on line 23 in packages/gator-permissions-controller/src/decodePermission/decoders/tokenApprovalRevocation.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (lint:eslint)

Unexpected use of '|'

Check failure on line 23 in packages/gator-permissions-controller/src/decodePermission/decoders/tokenApprovalRevocation.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (lint:eslint)

Unexpected use of '|'
ApprovalRevocationFlag.Permit2Lockdown |
ApprovalRevocationFlag.Permit2Approve |
ApprovalRevocationFlag.Erc721SetApprovalForAll |
ApprovalRevocationFlag.Erc721Approve |
ApprovalRevocationFlag.Erc20Approve;

/**
* Builds the configuration for the token-approval-revocation permission decoder.
*
* @param contractAddresses - Checksummed enforcer addresses for the chain.
* @returns The token-approval-revocation permission decoder configuration.
*/
export function makeTokenApprovalRevocationDecoderConfig(
contractAddresses: ChecksumEnforcersByChainId,
): MakePermissionDecoderConfig {
const { timestampEnforcer, approvalRevocationEnforcer, nonceEnforcer } =
contractAddresses;

return {
permissionType: 'token-approval-revocation',
contractAddresses,
optionalEnforcers: [
timestampEnforcer, // expiry rule
],
requiredEnforcers: {
[approvalRevocationEnforcer]: 1,
[nonceEnforcer]: 1,
},
rules: [expiryRule],
validateAndDecodeData,
};
}

/**
* Decodes token-approval-revocation permission data from caveats; throws on invalid.
*
* @param caveats - Caveats from the permission context (checksummed).
* @param contractAddresses - Checksummed enforcer addresses for the chain.
* @returns Decoded approval-revocation capability flags.
*/
function validateAndDecodeData(
caveats: ChecksumCaveat[],
contractAddresses: ChecksumEnforcersByChainId,
): DecodedPermission['permission']['data'] {
const { approvalRevocationEnforcer } = contractAddresses;

const terms = getTermsByEnforcer({
caveats,
enforcer: approvalRevocationEnforcer,
});

const mask = hexToNumber(terms);

if (mask > MAX_APPROVAL_REVOCATION_MASK) {
throw new Error(
`Invalid ApprovalRevocation terms: must be less than or equal to ${MAX_APPROVAL_REVOCATION_MASK}`,
);
}

if (mask === 0) {
throw new Error('Invalid ApprovalRevocation terms: must be greater than 0');
}

return {
erc20Approve: isFlagEnabled(mask, ApprovalRevocationFlag.Erc20Approve),
erc721Approve: isFlagEnabled(mask, ApprovalRevocationFlag.Erc721Approve),
erc721SetApprovalForAll: isFlagEnabled(
mask,
ApprovalRevocationFlag.Erc721SetApprovalForAll,
),
permit2Approve: isFlagEnabled(mask, ApprovalRevocationFlag.Permit2Approve),
permit2Lockdown: isFlagEnabled(
mask,
ApprovalRevocationFlag.Permit2Lockdown,
),
permit2InvalidateNonces: isFlagEnabled(
mask,
ApprovalRevocationFlag.Permit2InvalidateNonces,
),
};
}

function isFlagEnabled(mask: number, flag: number): boolean {
return (mask & flag) === flag;

Check failure on line 107 in packages/gator-permissions-controller/src/decodePermission/decoders/tokenApprovalRevocation.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (lint:eslint)

Unexpected use of '&'
}
Loading
Loading