Skip to content

Commit 9114503

Browse files
committed
Add decoders for token-approval-revocation permission type
1 parent b7c2567 commit 9114503

7 files changed

Lines changed: 384 additions & 5 deletions

File tree

packages/gator-permissions-controller/src/decodePermission/decodePermission.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import type {
1717
DeployedContractsByName,
1818
PermissionDecoder,
1919
} from './types';
20+
import { getChecksumEnforcersByChainId } from './utils';
2021

2122
// These tests use the live deployments table for version 1.3.0 to
2223
// construct deterministic caveat address sets for a known chain.
@@ -37,6 +38,8 @@ describe('decodePermission', () => {
3738
NonceEnforcer,
3839
RedeemerEnforcer,
3940
} = contracts;
41+
const { approvalRevocationEnforcer } =
42+
getChecksumEnforcersByChainId(contracts);
4043

4144
describe('findDecodersWithMatchingCaveatAddresses()', () => {
4245
const zeroAddress = '0x0000000000000000000000000000000000000000' as Hex;
@@ -621,6 +624,68 @@ describe('decodePermission', () => {
621624
).toThrow('Contract not found: AllowedCalldataEnforcer');
622625
});
623626
});
627+
628+
describe('token-approval-revocation', () => {
629+
const expectedPermissionType = 'token-approval-revocation';
630+
631+
it('matches with ApprovalRevocationEnforcer and NonceEnforcer', () => {
632+
const enforcers = [approvalRevocationEnforcer, NonceEnforcer];
633+
const result = findDecoderWithMatchingCaveatAddresses({
634+
enforcers,
635+
permissionDecoders: createPermissionDecodersForContracts(contracts),
636+
});
637+
expect(result.permissionType).toBe(expectedPermissionType);
638+
});
639+
640+
it('allows TimestampEnforcer as extra', () => {
641+
const enforcers = [
642+
approvalRevocationEnforcer,
643+
NonceEnforcer,
644+
TimestampEnforcer,
645+
];
646+
const result = findDecoderWithMatchingCaveatAddresses({
647+
enforcers,
648+
permissionDecoders: createPermissionDecodersForContracts(contracts),
649+
});
650+
expect(result.permissionType).toBe(expectedPermissionType);
651+
});
652+
653+
it('rejects when NonceEnforcer is missing', () => {
654+
const enforcers = [approvalRevocationEnforcer];
655+
expect(() =>
656+
findDecoderWithMatchingCaveatAddresses({
657+
enforcers,
658+
permissionDecoders: createPermissionDecodersForContracts(contracts),
659+
}),
660+
).toThrow('Unable to identify permission type');
661+
});
662+
663+
it('rejects forbidden extra caveat', () => {
664+
const enforcers = [
665+
approvalRevocationEnforcer,
666+
NonceEnforcer,
667+
ValueLteEnforcer,
668+
];
669+
expect(() =>
670+
findDecoderWithMatchingCaveatAddresses({
671+
enforcers,
672+
permissionDecoders: createPermissionDecodersForContracts(contracts),
673+
}),
674+
).toThrow('Unable to identify permission type');
675+
});
676+
677+
it('accepts lowercased addresses', () => {
678+
const enforcers: Hex[] = [
679+
approvalRevocationEnforcer.toLowerCase() as unknown as Hex,
680+
NonceEnforcer.toLowerCase() as unknown as Hex,
681+
];
682+
const result = findDecoderWithMatchingCaveatAddresses({
683+
enforcers,
684+
permissionDecoders: createPermissionDecodersForContracts(contracts),
685+
});
686+
expect(result.permissionType).toBe(expectedPermissionType);
687+
});
688+
});
624689
});
625690

626691
describe('reconstructDecodedPermission', () => {

packages/gator-permissions-controller/src/decodePermission/decoders/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { makePermissionDecoder } from './makePermissionDecoder';
88
import { makeNativeTokenAllowanceDecoderConfig } from './nativeTokenAllowance';
99
import { makeNativeTokenPeriodicDecoderConfig } from './nativeTokenPeriodic';
1010
import { makeNativeTokenStreamDecoderConfig } from './nativeTokenStream';
11+
import { makeTokenApprovalRevocationDecoderConfig } from './tokenApprovalRevocation';
1112

1213
/**
1314
* Builds the canonical set of permission decoders for a chain.
@@ -32,5 +33,6 @@ export const createPermissionDecodersForContracts = (
3233
makeErc20TokenPeriodicDecoderConfig(contractAddresses),
3334
makeErc20TokenAllowanceDecoderConfig(contractAddresses),
3435
makeErc20TokenRevocationDecoderConfig(contractAddresses),
36+
makeTokenApprovalRevocationDecoderConfig(contractAddresses),
3537
].map(makePermissionDecoder);
3638
};
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import { createTimestampTerms } from '@metamask/delegation-core';
2+
import {
3+
CHAIN_ID,
4+
DELEGATOR_CONTRACTS,
5+
} from '@metamask/delegation-deployments';
6+
7+
import { createPermissionDecodersForContracts } from '.';
8+
import { getChecksumEnforcersByChainId } from '../utils';
9+
10+
describe('token-approval-revocation decoder', () => {
11+
const chainId = CHAIN_ID.sepolia;
12+
const contracts = DELEGATOR_CONTRACTS['1.3.0'][chainId];
13+
const { timestampEnforcer, approvalRevocationEnforcer, nonceEnforcer } =
14+
getChecksumEnforcersByChainId(contracts);
15+
const permissionDecoders = createPermissionDecodersForContracts(contracts);
16+
const decoder = permissionDecoders.find(
17+
(candidate) => candidate.permissionType === 'token-approval-revocation',
18+
);
19+
20+
if (!decoder) {
21+
throw new Error('Decoder not found');
22+
}
23+
24+
const expiryCaveat = {
25+
enforcer: timestampEnforcer,
26+
terms: createTimestampTerms({
27+
afterThreshold: 0,
28+
beforeThreshold: 1720000,
29+
}),
30+
args: '0x' as const,
31+
};
32+
33+
it('rejects empty terms', () => {
34+
const caveats = [
35+
expiryCaveat,
36+
{
37+
enforcer: approvalRevocationEnforcer,
38+
terms: '0x' as const,
39+
args: '0x' as const,
40+
},
41+
{
42+
enforcer: nonceEnforcer,
43+
terms: '0x' as const,
44+
args: '0x' as const,
45+
},
46+
];
47+
48+
const result = decoder.validateAndDecodePermission(caveats);
49+
expect(result.isValid).toBe(false);
50+
51+
if (result.isValid) {
52+
throw new Error('Expected invalid result');
53+
}
54+
55+
expect(result.error.message).toContain(
56+
'Invalid ApprovalRevocation terms: must be greater than 0',
57+
);
58+
});
59+
60+
it('rejects terms whose mask exceeds the supported max', () => {
61+
const caveats = [
62+
expiryCaveat,
63+
{
64+
enforcer: approvalRevocationEnforcer,
65+
terms: '0x40' as const,
66+
args: '0x' as const,
67+
},
68+
{
69+
enforcer: nonceEnforcer,
70+
terms: '0x' as const,
71+
args: '0x' as const,
72+
},
73+
];
74+
75+
const result = decoder.validateAndDecodePermission(caveats);
76+
expect(result.isValid).toBe(false);
77+
78+
if (result.isValid) {
79+
throw new Error('Expected invalid result');
80+
}
81+
82+
expect(result.error.message).toContain(
83+
'Invalid ApprovalRevocation terms: must be less than or equal to 63',
84+
);
85+
});
86+
87+
it('successfully decodes valid token-approval-revocation caveats', () => {
88+
const caveats = [
89+
expiryCaveat,
90+
{
91+
enforcer: approvalRevocationEnforcer,
92+
terms: '0x01' as const,
93+
args: '0x' as const,
94+
},
95+
{
96+
enforcer: nonceEnforcer,
97+
terms: '0x' as const,
98+
args: '0x' as const,
99+
},
100+
];
101+
102+
const result = decoder.validateAndDecodePermission(caveats);
103+
expect(result.isValid).toBe(true);
104+
105+
if (!result.isValid) {
106+
throw new Error('Expected valid result');
107+
}
108+
109+
expect(result.expiry).toBe(1720000);
110+
expect(result.data).toStrictEqual({
111+
erc20Approve: true,
112+
erc721Approve: false,
113+
erc721SetApprovalForAll: false,
114+
permit2Approve: false,
115+
permit2Lockdown: false,
116+
permit2InvalidateNonces: false,
117+
});
118+
expect(result.rules).toStrictEqual([
119+
{
120+
type: 'expiry',
121+
data: { timestamp: 1720000 },
122+
},
123+
]);
124+
});
125+
126+
it('decodes all supported flags from the terms bitmask', () => {
127+
const caveats = [
128+
expiryCaveat,
129+
{
130+
enforcer: approvalRevocationEnforcer,
131+
terms: '0x3f' as const,
132+
args: '0x' as const,
133+
},
134+
{
135+
enforcer: nonceEnforcer,
136+
terms: '0x' as const,
137+
args: '0x' as const,
138+
},
139+
];
140+
141+
const result = decoder.validateAndDecodePermission(caveats);
142+
expect(result.isValid).toBe(true);
143+
144+
if (!result.isValid) {
145+
throw new Error('Expected valid result');
146+
}
147+
148+
expect(result.data).toStrictEqual({
149+
erc20Approve: true,
150+
erc721Approve: true,
151+
erc721SetApprovalForAll: true,
152+
permit2Approve: true,
153+
permit2Lockdown: true,
154+
permit2InvalidateNonces: true,
155+
});
156+
});
157+
});
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import type {
2+
ChecksumCaveat,
3+
ChecksumEnforcersByChainId,
4+
DecodedPermission,
5+
} from '../types';
6+
import { hexToNumber } from '@metamask/utils';
7+
import { getTermsByEnforcer } from '../utils';
8+
import { expiryRule } from './expiryRule';
9+
import type { MakePermissionDecoderConfig } from './makePermissionDecoder';
10+
11+
enum ApprovalRevocationFlag {
12+
Erc20Approve = 0x01,
13+
Erc721Approve = 0x02,
14+
Erc721SetApprovalForAll = 0x04,
15+
Permit2Approve = 0x08,
16+
Permit2Lockdown = 0x10,
17+
Permit2InvalidateNonces = 0x20,
18+
}
19+
20+
// eslint-disable-next-line no-bitwise
21+
const MAX_APPROVAL_REVOCATION_MASK = ApprovalRevocationFlag.Permit2InvalidateNonces | ApprovalRevocationFlag.Permit2Lockdown | ApprovalRevocationFlag.Permit2Approve | ApprovalRevocationFlag.Erc721SetApprovalForAll | ApprovalRevocationFlag.Erc721Approve | ApprovalRevocationFlag.Erc20Approve;
22+
23+
/**
24+
* Builds the configuration for the token-approval-revocation permission decoder.
25+
*
26+
* @param contractAddresses - Checksummed enforcer addresses for the chain.
27+
* @returns The token-approval-revocation permission decoder configuration.
28+
*/
29+
export function makeTokenApprovalRevocationDecoderConfig(
30+
contractAddresses: ChecksumEnforcersByChainId,
31+
): MakePermissionDecoderConfig {
32+
const { timestampEnforcer, approvalRevocationEnforcer, nonceEnforcer } =
33+
contractAddresses;
34+
35+
return {
36+
permissionType: 'token-approval-revocation',
37+
contractAddresses,
38+
optionalEnforcers: [
39+
timestampEnforcer, // expiry rule
40+
],
41+
requiredEnforcers: {
42+
[approvalRevocationEnforcer]: 1,
43+
[nonceEnforcer]: 1,
44+
},
45+
rules: [expiryRule],
46+
validateAndDecodeData,
47+
};
48+
}
49+
50+
/**
51+
* Decodes token-approval-revocation permission data from caveats; throws on invalid.
52+
*
53+
* @param caveats - Caveats from the permission context (checksummed).
54+
* @param contractAddresses - Checksummed enforcer addresses for the chain.
55+
* @returns Decoded approval-revocation capability flags.
56+
*/
57+
function validateAndDecodeData(
58+
caveats: ChecksumCaveat[],
59+
contractAddresses: ChecksumEnforcersByChainId,
60+
): DecodedPermission['permission']['data'] {
61+
const { approvalRevocationEnforcer } = contractAddresses;
62+
63+
const terms = getTermsByEnforcer({
64+
caveats,
65+
enforcer: approvalRevocationEnforcer,
66+
});
67+
68+
const mask = hexToNumber(terms);
69+
70+
if (mask > MAX_APPROVAL_REVOCATION_MASK) {
71+
throw new Error(`Invalid ApprovalRevocation terms: must be less than or equal to ${MAX_APPROVAL_REVOCATION_MASK}`);
72+
}
73+
74+
if (mask === 0) {
75+
throw new Error('Invalid ApprovalRevocation terms: must be greater than 0');
76+
}
77+
78+
return {
79+
erc20Approve: isFlagEnabled(mask, ApprovalRevocationFlag.Erc20Approve),
80+
erc721Approve: isFlagEnabled(mask, ApprovalRevocationFlag.Erc721Approve),
81+
erc721SetApprovalForAll: isFlagEnabled(
82+
mask,
83+
ApprovalRevocationFlag.Erc721SetApprovalForAll,
84+
),
85+
permit2Approve: isFlagEnabled(mask, ApprovalRevocationFlag.Permit2Approve),
86+
permit2Lockdown: isFlagEnabled(
87+
mask,
88+
ApprovalRevocationFlag.Permit2Lockdown,
89+
),
90+
permit2InvalidateNonces: isFlagEnabled(
91+
mask,
92+
ApprovalRevocationFlag.Permit2InvalidateNonces,
93+
),
94+
};
95+
}
96+
97+
function isFlagEnabled(mask: number, flag: number): boolean {
98+
// eslint-disable-next-line no-bitwise
99+
return (mask & flag) === flag;
100+
}

0 commit comments

Comments
 (0)