Skip to content

Commit 90f7bc0

Browse files
authored
fix: wallet_requestExecutionPermissions requests should reject any requests that include chains that don't support EIP-7702 (#40152)
## **Description** Presently when a `wallet_requestExecutionPermissions` RPC is served for a chain that doesn't support EIP-7702, we allow the user to sign the permission, and it is returned to the dapp. This results in an expectation that the dapp will serve the requested feature - but the permission is not valid, as the account may not be upgraded on the chain. This PR adds additional validation to the RPC before forwarding it to the Permissions Kernel snap. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/40152?quickstart=1) ## **Changelog** <!-- If this PR is not End-User-Facing and should not show up in the CHANGELOG, you can choose to either: 1. Write `CHANGELOG entry: null` 2. Label with `no-changelog` If this PR is End-User-Facing, please write a short User-Facing description in the past tense like: `CHANGELOG entry: Added a new tab for users to see their NFTs` `CHANGELOG entry: Fixed a bug that was causing some NFTs to flicker` (This helps the Release Engineer do their job more quickly and accurately) --> CHANGELOG entry: Reject `wallet_requestExecutionPermissions` requests that include chains that do not support EIP-7702 ## **Related issues** Fixes: ## **Manual testing steps** 1. Load Gator Permissions Snap test dapp https://github.com/MetaMask/snap-7715-permissions/tree/main/packages/site with `VITE_SUPPORTED_CHAINS=1,59144` 2. Select "Ethereum" under chain and request the permission Expect: Permission request is shown in the wallet 1. Select "Linea mainnet" (not supported) under chain and request the permission Expect: The request is rejected ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> <img width="708" height="169" alt="image" src="https://github.com/user-attachments/assets/9020bc6f-8f90-428c-86f1-d9aa6270f515" /> ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-extension/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes JSON-RPC behavior for `wallet_requestExecutionPermissions` by hard-rejecting requests containing unsupported `chainId`s based on remote feature flags, which could affect dapps relying on previous permissive behavior. Logic is straightforward but touches permission-gating and feature-flag-driven chain support. > > **Overview** > Pre-validates `wallet_requestExecutionPermissions` requests to **reject any params that include a `chainId` not listed in the `confirmations_eip_7702.supportedChains` remote feature flag**, returning `methodNotSupported` before forwarding to the Permissions Kernel snap. > > Adds `getEip7702SupportedChains` in `eip7702-support-utils` to read supported chains from remote feature flags, and extends unit + e2e coverage (including mocking the client-config flags API) to ensure unsupported chains are blocked and matching is case-insensitive. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit ffc7e8e. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent afcb306 commit 90f7bc0

File tree

4 files changed

+247
-5
lines changed

4 files changed

+247
-5
lines changed

app/scripts/metamask-controller.js

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ import { isSnapId } from '@metamask/snaps-utils';
129129
import {
130130
findAtomicBatchSupportForChain,
131131
checkEip7702Support,
132+
getEip7702SupportedChains,
132133
} from '../../shared/lib/eip7702-support-utils';
133134
import { createEIP7702UpgradeTransaction } from '../../shared/lib/eip7702-utils';
134135
import { captureException } from '../../shared/lib/sentry';
@@ -1149,8 +1150,25 @@ export default class MetamaskController extends EventEmitter {
11491150
throw rpcErrors.methodNotSupported('No permission type provided');
11501151
}
11511152

1152-
for (const param of params) {
1153-
const permissionType = param?.permission?.type;
1153+
const supportedChains = getEip7702SupportedChains(
1154+
this.remoteFeatureFlagController.state,
1155+
).map((chainId) => chainId.toLowerCase());
1156+
1157+
const unsupportedChains = params
1158+
.filter(
1159+
({ chainId }) => !supportedChains.includes(chainId?.toLowerCase()),
1160+
)
1161+
.map(({ chainId }) => chainId);
1162+
1163+
if (unsupportedChains.length > 0) {
1164+
throw rpcErrors.methodNotSupported(
1165+
`wallet_requestExecutionPermissions is not supported on chains '${unsupportedChains.join(', ')}'`,
1166+
);
1167+
}
1168+
1169+
for (const { permission } of params) {
1170+
const permissionType = permission?.type;
1171+
11541172
if (!enabledTypes.includes(permissionType)) {
11551173
throw rpcErrors.methodNotSupported(
11561174
`Permission type '${permissionType ?? 'unknown'}' is not enabled`,

app/scripts/metamask-controller.test.js

Lines changed: 151 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,12 @@ import {
4040
getEthAccounts,
4141
} from '@metamask/chain-agnostic-permission';
4242
import { PermissionDoesNotExistError } from '@metamask/permission-controller';
43-
4443
import log from 'loglevel';
4544
import browser from 'webextension-polyfill';
45+
import { JsonRpcEngine } from '@metamask/json-rpc-engine';
46+
import { errorCodes } from '@metamask/rpc-errors';
4647
import { parseCaipAccountId } from '@metamask/utils';
48+
4749
import { createTestProviderTools } from '../../test/stub/provider';
4850
import {
4951
HardwareDeviceNames,
@@ -69,12 +71,14 @@ import {
6971
DEFI_REFERRAL_PARTNERS,
7072
DefiReferralPartner,
7173
} from '../../shared/constants/defi-referrals';
74+
import { getEnabledAdvancedPermissions } from '../../shared/modules/environment';
7275
import { ReferralStatus } from './controllers/preferences-controller';
7376
import { METAMASK_COOKIE_HANDLER } from './constants/stream';
7477
import {
7578
getOriginsWithSessionProperty,
7679
getPermittedAccountsForScopesByOrigin,
7780
} from './controllers/permissions';
81+
import { forwardRequestToSnap } from './lib/forwardRequestToSnap';
7882
import MetaMaskController from './metamask-controller';
7983

8084
jest.mock('webextension-polyfill', () => ({
@@ -310,6 +314,15 @@ jest.mock('@metamask/core-backend', () => ({
310314
createApiPlatformClient: jest.fn().mockReturnValue({ mockApiClient: true }),
311315
}));
312316

317+
jest.mock('../../shared/modules/environment', () => ({
318+
...jest.requireActual('../../shared/modules/environment'),
319+
getEnabledAdvancedPermissions: jest.fn(() => []),
320+
}));
321+
322+
jest.mock('./lib/forwardRequestToSnap', () => ({
323+
forwardRequestToSnap: jest.fn().mockResolvedValue({}),
324+
}));
325+
313326
const TEST_SEED =
314327
'debris dizzy just program just float decrease vacant alarm reduce speak stadium';
315328
const TEST_ADDRESS = '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc';
@@ -1308,6 +1321,143 @@ describe('MetaMaskController', () => {
13081321
});
13091322
});
13101323

1324+
describe('wallet_requestExecutionPermissions (processRequestExecutionPermissions)', () => {
1325+
beforeEach(() => {
1326+
jest
1327+
.mocked(getEnabledAdvancedPermissions)
1328+
.mockReturnValue(['erc20-token-revocation']);
1329+
jest.mocked(forwardRequestToSnap).mockResolvedValue({});
1330+
});
1331+
1332+
/**
1333+
* Run wallet_requestExecutionPermissions through the controller's
1334+
* metamask middleware and return the JSON-RPC response.
1335+
* @param params - The parameters for the wallet_requestExecutionPermissions request.
1336+
* @returns The JSON-RPC response.
1337+
*/
1338+
async function requestExecutionPermissions(params) {
1339+
const engine = new JsonRpcEngine();
1340+
engine.push(metamaskController.metamaskMiddleware);
1341+
const request = {
1342+
jsonrpc: '2.0',
1343+
id: 1,
1344+
method: 'wallet_requestExecutionPermissions',
1345+
params,
1346+
};
1347+
return await engine.handle(request);
1348+
}
1349+
1350+
const createWalletRequestExecutionPermissionsParamsForChains = (
1351+
chainIds,
1352+
) => {
1353+
return chainIds.map((chainId) => ({
1354+
chainId,
1355+
to: '0x0000000000000000000000000000000000000000',
1356+
permission: {
1357+
type: 'erc20-token-revocation',
1358+
data: {
1359+
justification: 'A test permission request',
1360+
},
1361+
isAdjustmentAllowed: true,
1362+
},
1363+
}));
1364+
};
1365+
1366+
it('rejects when a requested chain is not in EIP-7702 supportedChains', async () => {
1367+
jest
1368+
.spyOn(metamaskController.remoteFeatureFlagController, 'state', 'get')
1369+
.mockReturnValue({
1370+
remoteFeatureFlags: {
1371+
confirmations_eip_7702: {
1372+
supportedChains: ['0x1', '0x5'],
1373+
},
1374+
},
1375+
cacheTimestamp: 0,
1376+
});
1377+
1378+
const params = createWalletRequestExecutionPermissionsParamsForChains([
1379+
'0x99',
1380+
]);
1381+
const response = await requestExecutionPermissions(params);
1382+
1383+
expect(response.error).toBeDefined();
1384+
expect(response.error.code).toStrictEqual(
1385+
errorCodes.rpc.methodNotSupported,
1386+
);
1387+
expect(response.error.message).toMatch(
1388+
/wallet_requestExecutionPermissions is not supported on chains '0x99'/u,
1389+
);
1390+
});
1391+
1392+
it('rejects when any of multiple requested chains are unsupported', async () => {
1393+
jest
1394+
.spyOn(metamaskController.remoteFeatureFlagController, 'state', 'get')
1395+
.mockReturnValue({
1396+
remoteFeatureFlags: {
1397+
confirmations_eip_7702: {
1398+
supportedChains: ['0x1'],
1399+
},
1400+
},
1401+
cacheTimestamp: 0,
1402+
});
1403+
1404+
const params = createWalletRequestExecutionPermissionsParamsForChains([
1405+
'0x1',
1406+
'0x5',
1407+
]);
1408+
const response = await requestExecutionPermissions(params);
1409+
1410+
expect(response.error).toBeDefined();
1411+
expect(response.error.code).toStrictEqual(
1412+
errorCodes.rpc.methodNotSupported,
1413+
);
1414+
expect(response.error.message).toMatch(
1415+
/wallet_requestExecutionPermissions is not supported on chains .*0x5/u,
1416+
);
1417+
});
1418+
1419+
it('does not reject when all requested chains are supported', async () => {
1420+
jest
1421+
.spyOn(metamaskController.remoteFeatureFlagController, 'state', 'get')
1422+
.mockReturnValue({
1423+
remoteFeatureFlags: {
1424+
confirmations_eip_7702: {
1425+
supportedChains: ['0x1', '0x5', '0x539'],
1426+
},
1427+
},
1428+
cacheTimestamp: 0,
1429+
});
1430+
1431+
const params = createWalletRequestExecutionPermissionsParamsForChains([
1432+
'0x1',
1433+
'0x539',
1434+
]);
1435+
const response = await requestExecutionPermissions(params);
1436+
1437+
expect(response.error).toBeUndefined();
1438+
});
1439+
1440+
it('does not reject when chainId matches supported chain (case-insensitive)', async () => {
1441+
jest
1442+
.spyOn(metamaskController.remoteFeatureFlagController, 'state', 'get')
1443+
.mockReturnValue({
1444+
remoteFeatureFlags: {
1445+
confirmations_eip_7702: {
1446+
supportedChains: ['0xaa'],
1447+
},
1448+
},
1449+
cacheTimestamp: 0,
1450+
});
1451+
1452+
const params = createWalletRequestExecutionPermissionsParamsForChains([
1453+
'0xAA',
1454+
]);
1455+
const response = await requestExecutionPermissions(params);
1456+
1457+
expect(response.error).toBeUndefined();
1458+
});
1459+
});
1460+
13111461
describe('requestApprovalPermittedChainsPermission', () => {
13121462
it('requests approval', async () => {
13131463
jest

shared/lib/eip7702-support-utils.ts

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { Hex } from '@metamask/utils';
2-
import { IsAtomicBatchSupportedResultEntry } from '@metamask/transaction-controller';
1+
import type { Hex } from '@metamask/utils';
2+
import type { IsAtomicBatchSupportedResultEntry } from '@metamask/transaction-controller';
3+
import type { RemoteFeatureFlagControllerState } from '@metamask/remote-feature-flag-controller';
34

45
/**
56
* Checks if EIP-7702 is supported for a specific chain based on atomic batch support results.
@@ -51,3 +52,40 @@ export function checkEip7702Support(
5152
: null,
5253
};
5354
}
55+
56+
// Feature flag definitions from @metamask/transaction-controller
57+
const EIP7702_FEATURE_FLAG = 'confirmations_eip_7702';
58+
59+
type TransactionControllerPartialFeatureFlags = {
60+
/** Feature flags to support EIP-7702 / type-4 transactions. */
61+
[EIP7702_FEATURE_FLAG]?: {
62+
/**
63+
* All contracts that support EIP-7702 batch transactions.
64+
* Keyed by chain ID.
65+
* First entry in each array is the contract that standard EOAs will be upgraded to.
66+
*/
67+
contracts?: Record<
68+
Hex,
69+
{
70+
/** Address of the smart contract. */
71+
address: Hex;
72+
73+
/** Signature to verify the contract is authentic. */
74+
signature: Hex;
75+
}[]
76+
>;
77+
78+
/** Chains enabled for EIP-7702 batch transactions. */
79+
supportedChains?: Hex[];
80+
};
81+
};
82+
83+
export function getEip7702SupportedChains({
84+
remoteFeatureFlags,
85+
}: RemoteFeatureFlagControllerState): Hex[] {
86+
const eip7702FeatureFlags = remoteFeatureFlags as
87+
| TransactionControllerPartialFeatureFlags
88+
| undefined;
89+
90+
return eip7702FeatureFlags?.[EIP7702_FEATURE_FLAG]?.supportedChains ?? [];
91+
}

test/e2e/json-rpc/wallet_requestExecutionPermissions.spec.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,54 @@
11
import { strict as assert } from 'assert';
2+
import type { Mockttp } from 'mockttp';
23
import { withFixtures } from '../helpers';
34
import FixtureBuilderV2 from '../fixtures/fixture-builder-v2';
45
import { Driver } from '../webdriver/driver';
56
import { loginWithBalanceValidation } from '../page-objects/flows/login.flow';
67
import { WINDOW_TITLES } from '../constants';
78
import TestDapp from '../page-objects/pages/test-dapp';
89
import AdvancedPermissionsIntroduction from '../page-objects/pages/confirmations/advanced-permissions-introduction';
10+
import { getProductionRemoteFlagApiResponse } from '../feature-flags/feature-flag-registry';
11+
12+
/**
13+
* Mocks the client-config flags API so that confirmations_eip_7702 has
14+
* supportedChains: ['0x539']. This is more reliable than fixture state because
15+
* the extension fetches flags when the UI opens and overwrites fixture-loaded state.
16+
*
17+
* @param server - The mockttp server to mock the flags API on.
18+
* @returns An array of mockttp requests to mock the flags API.
19+
*/
20+
async function mockEip7702SupportedChains(server: Mockttp) {
21+
const flags = getProductionRemoteFlagApiResponse();
22+
23+
const flagsWithEip7702 = [
24+
...flags,
25+
{
26+
// eslint-disable-next-line @typescript-eslint/naming-convention
27+
confirmations_eip_7702: {
28+
supportedChains: ['0x539'],
29+
contracts: {},
30+
},
31+
},
32+
];
33+
return [
34+
await server
35+
.forGet('https://client-config.api.cx.metamask.io/v1/flags')
36+
.withQuery({ client: 'extension', distribution: 'main' })
37+
.thenCallback(() => ({
38+
ok: true,
39+
statusCode: 200,
40+
json: flagsWithEip7702,
41+
})),
42+
];
43+
}
944

1045
describe('wallet_requestExecutionPermissions', function () {
1146
it('blocks other requests, until the dialog is closed', async function () {
1247
await withFixtures(
1348
{
1449
dappOptions: { numberOfTestDapps: 1 },
1550
fixtures: new FixtureBuilderV2().build(),
51+
testSpecificMock: mockEip7702SupportedChains,
1652
title: this.test?.fullTitle(),
1753
},
1854
async ({ driver }: { driver: Driver }) => {

0 commit comments

Comments
 (0)