Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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.3.0` to `^65.4.0` ([#8796](https://github.com/MetaMask/core/pull/8796))
Expand Down
6 changes: 3 additions & 3 deletions packages/gator-permissions-controller/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,11 @@
"test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch"
},
"dependencies": {
"@metamask/7715-permission-types": "^0.6.0",
"@metamask/7715-permission-types": "^0.7.0",
"@metamask/abi-utils": "^2.0.3",
"@metamask/base-controller": "^9.1.0",
"@metamask/delegation-core": "^2.0.0",
"@metamask/delegation-deployments": "^1.3.0",
"@metamask/delegation-core": "^2.2.0",
"@metamask/delegation-deployments": "^1.4.0",
"@metamask/messenger": "^1.2.0",
"@metamask/network-controller": "^32.0.0",
"@metamask/snaps-controllers": "^19.0.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,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 @@ -37,6 +38,8 @@ describe('decodePermission', () => {
NonceEnforcer,
RedeemerEnforcer,
} = contracts;
const { approvalRevocationEnforcer } =
getChecksumEnforcersByChainId(contracts);

describe('findDecodersWithMatchingCaveatAddresses()', () => {
const zeroAddress = '0x0000000000000000000000000000000000000000' as Hex;
Expand Down Expand Up @@ -621,6 +624,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 |
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');
}
Comment thread
cursor[bot] marked this conversation as resolved.

return {
erc20Approve: isFlagEnabled(mask, ApprovalRevocationFlag.Erc20Approve),
erc721Approve: isFlagEnabled(mask, ApprovalRevocationFlag.Erc721Approve),
erc721SetApprovalForAll: isFlagEnabled(
mask,
ApprovalRevocationFlag.Erc721SetApprovalForAll,
),
permit2Approve: isFlagEnabled(mask, ApprovalRevocationFlag.Permit2Approve),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same question here, the name of Permit2Approve is different between @metamask/7715-permission-types and here

permit2Lockdown: isFlagEnabled(
mask,
ApprovalRevocationFlag.Permit2Lockdown,
),
permit2InvalidateNonces: isFlagEnabled(
mask,
ApprovalRevocationFlag.Permit2InvalidateNonces,
),
};
}

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