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
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,15 @@ import { createWalletGetSupportedExecutionPermissionsHandler } from './wallet-ge
const RESULT_MOCK: GetSupportedExecutionPermissionsResult = {
'native-token-allowance': {
chainIds: ['0x123', '0x345'] as Hex[],
ruleTypes: ['expiry'],
ruleTypes: ['expiry', 'redeemer'],
},
'erc20-token-allowance': {
chainIds: ['0x123'] as Hex[],
ruleTypes: [],
},
'erc721-token-allowance': {
chainIds: ['0x123'] as Hex[],
ruleTypes: ['expiry'],
ruleTypes: ['expiry', 'redeemer'],
},
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,34 @@ describe('wallet_requestExecutionPermissions', () => {
);
});

it('accepts redeemer rule with valid checksum addresses', async () => {
params[0].rules = [
{
type: 'redeemer',
data: { addresses: [FROM_ADDRESS_MOCK, TO_ADDRESS_MOCK] },
},
];

await callMethod();

expect(processRequestExecutionPermissionsMock).toHaveBeenCalledWith(
params,
request,
context,
);
});

it('rejects redeemer rule with invalid address entry', async () => {
params[0].rules = [
{
type: 'redeemer',
data: { addresses: ['0x123'] },
},
];

await expect(callMethod()).rejects.toThrow('Invalid params');
});

it('throws if no hook', async () => {
await expect(
createWalletRequestExecutionPermissionsHandler({})({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,20 @@ const PermissionStruct = object({
data: record(string(), unknown()),
});

const RuleStruct = object({
const RedeemerRuleStruct = object({
type: literal('redeemer'),
data: object({
addresses: array(HexChecksumAddressStruct),
}),
});

const GenericRuleStruct = object({
type: string(),
data: record(string(), unknown()),
});

const RuleStruct = union([RedeemerRuleStruct, GenericRuleStruct]);

const PermissionRequestStruct = object({
chainId: StrictHexStruct,
from: optional(HexChecksumAddressStruct),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -608,7 +608,7 @@ export class GatorPermissionsController extends BaseController<
});
}

const { expiry, data } = decodeResult;
const { expiry, data, rules } = decodeResult;

const permission = reconstructDecodedPermission({
chainId,
Expand All @@ -620,6 +620,7 @@ export class GatorPermissionsController extends BaseController<
data,
justification,
specifiedOrigin,
rules,
});

return permission;
Expand Down
7 changes: 7 additions & 0 deletions packages/gator-permissions-controller/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,10 @@
* contract addresses from `@metamask/delegation-deployments`.
*/
export const DELEGATION_FRAMEWORK_VERSION = '1.3.0';

/**
* `Rule.type` / `wallet_getSupportedExecutionPermissions` `ruleTypes` entry for
* redeemer allowlists (RedeemerEnforcer). Hosts should advertise this for every
* supported execution permission type.
*/
export const EXECUTION_PERMISSION_REDEEMER_RULE_TYPE = 'redeemer' as const;
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ describe('decodePermission', () => {
NativeTokenStreamingEnforcer,
NativeTokenPeriodTransferEnforcer,
NonceEnforcer,
RedeemerEnforcer,
} = contracts;

describe('getPermissionRuleMatchingCaveatTypes()', () => {
Expand Down Expand Up @@ -87,6 +88,35 @@ describe('decodePermission', () => {
expect(result.permissionType).toBe(expectedPermissionType);
});

it('allows RedeemerEnforcer as extra', () => {
const enforcers = [
NativeTokenStreamingEnforcer,
ExactCalldataEnforcer,
NonceEnforcer,
RedeemerEnforcer,
];
const result = findRuleWithMatchingCaveatAddresses({
enforcers,
permissionRules: createPermissionRulesForContracts(contracts),
});
expect(result.permissionType).toBe(expectedPermissionType);
});

it('allows TimestampEnforcer and RedeemerEnforcer as extras', () => {
const enforcers = [
NativeTokenStreamingEnforcer,
ExactCalldataEnforcer,
NonceEnforcer,
TimestampEnforcer,
RedeemerEnforcer,
];
const result = findRuleWithMatchingCaveatAddresses({
enforcers,
permissionRules: createPermissionRulesForContracts(contracts),
});
expect(result.permissionType).toBe(expectedPermissionType);
});

it('rejects forbidden extra caveat', () => {
const enforcers = [
NativeTokenStreamingEnforcer,
Expand Down Expand Up @@ -589,6 +619,41 @@ describe('decodePermission', () => {
expect(result.origin).toBe(specifiedOrigin);
});

it('includes rules when provided', () => {
const permissionType = 'native-token-stream' as const;
const data: DecodedPermission['permission']['data'] = {
initialAmount: '0x01',
maxAmount: '0x02',
amountPerSecond: '0x03',
startTime: 1715664,
} as const;
const rules = [
{
type: 'redeemer' as const,
data: {
addresses: [
'0x1111111111111111111111111111111111111111' as Hex,
],
},
},
];

const result = reconstructDecodedPermission({
chainId,
permissionType,
delegator,
delegate,
authority: ROOT_AUTHORITY,
expiry: null,
data,
justification,
specifiedOrigin,
rules,
});

expect(result.rules).toStrictEqual(rules);
});

it('constructs DecodedPermission with null expiry', () => {
const permissionType = 'erc20-token-periodic' as const;
const data: DecodedPermission['permission']['data'] = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,8 @@ export const findRuleWithMatchingCaveatAddresses = ({
* @param args.data - Permission-specific decoded data payload.
* @param args.justification - Human-readable justification for the permission.
* @param args.specifiedOrigin - The origin reported in the request metadata.
*
* @param args.rules - Rules recovered from caveats (e.g. redeemer allowlist).
*
* @returns The reconstructed {@link DecodedPermission}.
*/
export const reconstructDecodedPermission = ({
Expand All @@ -76,6 +77,7 @@ export const reconstructDecodedPermission = ({
data,
justification,
specifiedOrigin,
rules,
}: {
chainId: number;
permissionType: PermissionType;
Expand All @@ -86,6 +88,7 @@ export const reconstructDecodedPermission = ({
data: DecodedPermission['permission']['data'];
justification: string;
specifiedOrigin: string;
rules?: DecodedPermission['rules'];
}): DecodedPermission => {
if (authority !== ROOT_AUTHORITY) {
throw new Error('Invalid authority');
Expand All @@ -102,6 +105,7 @@ export const reconstructDecodedPermission = ({
},
expiry,
origin: specifiedOrigin,
...(rules !== undefined ? { rules } : {}),
};

return permission;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { Hex } from '@metamask/utils';
import { getChecksumAddress } from '@metamask/utils';

import { decodeRedeemerEnforcerTerms } from './redeemer';

describe('decodeRedeemerEnforcerTerms', () => {
it('decodes a single packed address', () => {
const raw =
'1111111111111111111111111111111111111111' as const;
const terms = `0x${raw}` as Hex;
expect(decodeRedeemerEnforcerTerms(terms)).toStrictEqual([
getChecksumAddress(`0x${raw}` as Hex),
]);
});

it('decodes two concatenated addresses', () => {
const a = '1111111111111111111111111111111111111111';
const b = '2222222222222222222222222222222222222222';
const terms = `0x${a}${b}` as Hex;
expect(decodeRedeemerEnforcerTerms(terms)).toStrictEqual([
getChecksumAddress(`0x${a}` as Hex),
getChecksumAddress(`0x${b}` as Hex),
]);
});

it('rejects empty payload', () => {
expect(() => decodeRedeemerEnforcerTerms('0x' as Hex)).toThrow(
'Invalid redeemer enforcer terms: empty payload',
);
});

it('rejects length not divisible by 20 bytes', () => {
expect(() =>
decodeRedeemerEnforcerTerms('0x11' as Hex),
).toThrow(
'Invalid redeemer enforcer terms: length must be a multiple of 20 bytes',
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { getChecksumAddress } from '@metamask/utils';
import type { Hex } from '@metamask/utils';

import { getByteLength } from './utils';

/**
* Decodes {@link https://github.com/MetaMask/delegation-framework/blob/main/src/enforcers/RedeemerEnforcer.sol RedeemerEnforcer}
* caveat terms: concatenated 20-byte Ethereum addresses (no ABI header).
*
* @param terms - Hex-encoded packed addresses from the caveat.
* @returns Checksummed redeemer addresses.
*/
export function decodeRedeemerEnforcerTerms(terms: Hex): Hex[] {
if (terms === '0x') {
throw new Error('Invalid redeemer enforcer terms: empty payload');
}

const byteLength = getByteLength(terms);

if (byteLength === 0) {
throw new Error(
'Invalid redeemer enforcer terms: length must be positive',
);
}

if (byteLength % 20 !== 0) {
throw new Error(
'Invalid redeemer enforcer terms: length must be a multiple of 20 bytes',
);
}

const addresses: Hex[] = [];
const body = terms.slice(2);

for (let i = 0; i < body.length; i += 40) {
const slice = body.slice(i, i + 40);
addresses.push(getChecksumAddress(`0x${slice}` as Hex));
}

return addresses;
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,12 @@ export function makeErc20TokenPeriodicRule(
erc20PeriodicEnforcer,
valueLteEnforcer,
nonceEnforcer,
redeemerEnforcer,
} = enforcers;
return makePermissionRule({
permissionType: 'erc20-token-periodic',
optionalEnforcers: [timestampEnforcer],
redeemerEnforcer,
timestampEnforcer,
requiredEnforcers: {
[erc20PeriodicEnforcer]: 1,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,12 @@ export function makeErc20TokenRevocationRule(
allowedCalldataEnforcer,
valueLteEnforcer,
nonceEnforcer,
redeemerEnforcer,
} = enforcers;
return makePermissionRule({
permissionType: 'erc20-token-revocation',
optionalEnforcers: [timestampEnforcer],
redeemerEnforcer,
timestampEnforcer,
requiredEnforcers: {
[allowedCalldataEnforcer]: 2,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,12 @@ export function makeErc20TokenStreamRule(
erc20StreamingEnforcer,
valueLteEnforcer,
nonceEnforcer,
redeemerEnforcer,
} = enforcers;
return makePermissionRule({
permissionType: 'erc20-token-stream',
optionalEnforcers: [timestampEnforcer],
redeemerEnforcer,
timestampEnforcer,
requiredEnforcers: {
[erc20StreamingEnforcer]: 1,
Expand Down
Loading
Loading