Repository: MetaMask/metamask-mobile
Pull request #14993: feat: Add validations for incoming batch request
Author: login: jpuri
Labels: name: team-confirmations
description: Push issues to confirmations team
name: release-7.47.0
description: Issue or pull request that will be included in release 7.47.0
Created: 2025-04-30T05:05:04Z | Merged: 2025-05-05T15:47:58Z

## **Description**

Add validations for incoming batch request

## **Related issues**

Fixes: https://github.com/MetaMask/MetaMask-planning/issues/4785

## **Manual testing steps**

1. Try different error situations like submit batch request on mainnet etc
2. Ensure that you get validation error back

## **Screenshots/Recordings**

<img width="400" alt="Screenshot 2025-04-30 at 12 15 38 PM" src="https://github.com/user-attachments/assets/52d5bee8-1c0a-489f-bf2f-c3a0cfb7897e" />

## **Pre-merge author checklist**

- [X] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [X] I've completed the PR template to the best of my ability
- [X] I’ve included tests if applicable
- [X] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable
- [X] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/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.



Diff:
diff --git a/.js.env.example b/.js.env.example
index df4ab3203d1..2b72f3b9a30 100644
--- a/.js.env.example
+++ b/.js.env.example
@@ -121,3 +121,5 @@ export MM_POOLED_STAKING_SERVICE_INTERRUPTION_BANNER_ENABLED="true"
 # Remote feature flag values won't be updated,
 # and selectors should return their fallback values
 export OVERRIDE_REMOTE_FEATURE_FLAGS="false"
+# Public key used to validate EIP-7702 contract signatures from LaunchDarkly
+export EIP_7702_PUBLIC_KEY=""
diff --git a/app/core/RPCMethods/createAsyncWalletMiddleware.test.ts b/app/core/RPCMethods/createAsyncWalletMiddleware.test.ts
index e59a512c873..849488dada9 100644
--- a/app/core/RPCMethods/createAsyncWalletMiddleware.test.ts
+++ b/app/core/RPCMethods/createAsyncWalletMiddleware.test.ts
@@ -34,8 +34,12 @@ jest.mock('../Engine', () => ({
     },
     TransactionController: {
       addTransactionBatch: jest.fn().mockResolvedValue({ batchId: 123 }),
+      isAtomicBatchSupported: jest.fn().mockResolvedValue([true]),
     },
   },
+  controllerMessenger: {
+    call: jest.fn().mockReturnValue({ configuration: { chainId: '0xaa36a7' } }),
+  },
 }));
 
 const MockEngine = jest.mocked(Engine);
@@ -86,11 +90,84 @@ describe('processSendCalls', () => {
     networkClientId: 'sepolia',
   } as JsonRpcRequest;
 
-  it('return selected account address', async () => {
+  it('creates transaction instance for batch request', async () => {
     const result = await processSendCalls(MOCK_PARAMS, MOCK_REQUEST);
     expect(
       Engine.context.TransactionController.addTransactionBatch,
     ).toHaveBeenCalledTimes(1);
     expect(result.id).toStrictEqual(123);
   });
+
+  it('throw error if wrong version of request is used', async () => {
+    expect(async () => {
+      await processSendCalls(
+        { ...MOCK_PARAMS, version: '3.0.0' },
+        MOCK_REQUEST,
+      );
+    }).rejects.toThrow('Version not supported: Got 3.0.0, expected 2.0.0');
+  });
+
+  it('throw error if TransactionController.isAtomicBatchSupported returns false', async () => {
+    Engine.context.TransactionController.isAtomicBatchSupported = jest
+      .fn()
+      .mockResolvedValue([]);
+    expect(async () => {
+      await processSendCalls(MOCK_PARAMS, MOCK_REQUEST);
+    }).rejects.toThrow('EIP-7702 not supported on chain: 0xaa36a7');
+    Engine.context.TransactionController.isAtomicBatchSupported = jest
+      .fn()
+      .mockResolvedValue([{}]);
+  });
+
+  it('throws if top-level capability is required', async () => {
+    await expect(
+      processSendCalls(
+        {
+          ...MOCK_PARAMS,
+          capabilities: {
+            test: {},
+            test2: { optional: true },
+            test3: { optional: false },
+          },
+        },
+        MOCK_REQUEST,
+      ),
+    ).rejects.toThrow('Unsupported non-optional capabilities: test, test3');
+  });
+
+  it('throws if call capability is required', async () => {
+    await expect(
+      processSendCalls(
+        {
+          ...MOCK_PARAMS,
+          calls: [
+            ...MOCK_PARAMS.calls,
+            {
+              ...MOCK_PARAMS.calls[0],
+              capabilities: {
+                test: {},
+                test2: { optional: true },
+                test3: { optional: false },
+              },
+            },
+          ],
+        },
+        MOCK_REQUEST,
+      ),
+    ).rejects.toThrow('Unsupported non-optional capabilities: test, test3');
+  });
+
+  it('throw error if dappChainId is different from request chainId', async () => {
+    Engine.controllerMessenger.call = jest
+      .fn()
+      .mockReturnValue({ configuration: { chainId: '0x1' } });
+    expect(async () => {
+      await processSendCalls(MOCK_PARAMS, {
+        ...MOCK_REQUEST,
+        networkClientId: 'linea',
+      } as JsonRpcRequest);
+    }).rejects.toThrow(
+      'Chain ID must match the dApp selected network: Got 0xaa36a7, expected 0x1',
+    );
+  });
 });
diff --git a/app/core/RPCMethods/createAsyncWalletMiddleware.ts b/app/core/RPCMethods/createAsyncWalletMiddleware.ts
index 55545bc81d5..e5e05d28607 100644
--- a/app/core/RPCMethods/createAsyncWalletMiddleware.ts
+++ b/app/core/RPCMethods/createAsyncWalletMiddleware.ts
@@ -6,16 +6,113 @@ import {
 import { JsonRpcMiddleware } from '@metamask/json-rpc-engine';
 import { JsonRpcParams } from '@metamask/eth-query';
 import { Hex, Json, JsonRpcRequest } from '@metamask/utils';
+import { JsonRpcError, rpcErrors } from '@metamask/rpc-errors';
 import { v4 as uuidv4 } from 'uuid';
 
+import ppomUtil from '../../lib/ppom/ppom-util';
 import Engine from '../Engine';
 
+const VERSION = '2.0.0';
+
+enum EIP5792ErrorCode {
+  UnsupportedNonOptionalCapability = 5700,
+  UnsupportedChainId = 5710,
+  UnknownBundleId = 5730,
+  RejectedUpgrade = 5750,
+}
+
+type JSONRPCRequest = JsonRpcRequest & {
+  networkClientId: string;
+  origin?: string;
+};
+
 export const getAccounts = async () => {
   const { AccountsController } = Engine.context;
   const selectedAddress = AccountsController.getSelectedAccount()?.address;
   return Promise.resolve(selectedAddress ? [selectedAddress] : []);
 };
 
+function validateSendCallsVersion(sendCalls: SendCalls) {
+  const { version } = sendCalls;
+
+  if (version !== VERSION) {
+    throw rpcErrors.invalidInput(
+      `Version not supported: Got ${version}, expected ${VERSION}`,
+    );
+  }
+}
+
+async function validateSendCallsChainId(
+  sendCalls: SendCalls,
+  req: JSONRPCRequest,
+) {
+  const { TransactionController, AccountsController } = Engine.context;
+  const { chainId } = sendCalls;
+  const { networkClientId } = req;
+
+  const dappChainId = Engine.controllerMessenger.call(
+    'NetworkController:getNetworkClientById',
+    networkClientId,
+  ).configuration.chainId;
+
+  if (chainId && chainId.toLowerCase() !== dappChainId.toLowerCase()) {
+    throw rpcErrors.invalidParams(
+      `Chain ID must match the dApp selected network: Got ${chainId}, expected ${dappChainId}`,
+    );
+  }
+
+  const from =
+    sendCalls.from ?? (AccountsController.getSelectedAccount()?.address as Hex);
+
+  const batchSupport = await TransactionController.isAtomicBatchSupported({
+    address: from,
+    chainIds: [dappChainId],
+  });
+
+  const chainBatchSupport = batchSupport?.[0];
+
+  if (!chainBatchSupport) {
+    throw new JsonRpcError(
+      EIP5792ErrorCode.UnsupportedChainId,
+      `EIP-7702 not supported on chain: ${dappChainId}`,
+    );
+  }
+}
+
+function validateCapabilities(sendCalls: SendCalls) {
+  const { calls, capabilities } = sendCalls;
+
+  const requiredTopLevelCapabilities = Object.keys(capabilities ?? {}).filter(
+    (name) => capabilities?.[name].optional !== true,
+  );
+
+  const requiredCallCapabilities = calls.flatMap((call) =>
+    Object.keys(call.capabilities ?? {}).filter(
+      (name) => call.capabilities?.[name].optional !== true,
+    ),
+  );
+
+  const requiredCapabilities = [
+    ...requiredTopLevelCapabilities,
+    ...requiredCallCapabilities,
+  ];
+
+  if (requiredCapabilities.length) {
+    throw new JsonRpcError(
+      EIP5792ErrorCode.UnsupportedNonOptionalCapability,
+      `Unsupported non-optional capabilities: ${requiredCapabilities.join(
+        ', ',
+      )}`,
+    );
+  }
+}
+
+async function validateSendCalls(sendCalls: SendCalls, req: JSONRPCRequest) {
+  validateSendCallsVersion(sendCalls);
+  await validateSendCallsChainId(sendCalls, req);
+  validateCapabilities(sendCalls);
+}
+
 export async function processSendCalls(
   params: SendCalls,
   req: JsonRpcRequest,
@@ -28,16 +125,20 @@ export async function processSendCalls(
   };
   const transactions = calls.map((call) => ({ params: call }));
 
+  await validateSendCalls(params, req as JSONRPCRequest);
+
   const from =
     paramFrom ?? (AccountsController.getSelectedAccount()?.address as Hex);
+  const securityAlertId = uuidv4();
 
   const { batchId: id } = await TransactionController.addTransactionBatch({
     from,
     networkClientId,
     origin,
-    securityAlertId: uuidv4(),
+    securityAlertId,
     transactions,
-    validateSecurity: () => Promise.resolve(),
+    validateSecurity:
+      ppomUtil.createValidatorForSecurityAlertId(securityAlertId),
   });
 
   return { id };
diff --git a/app/lib/ppom/ppom-util.ts b/app/lib/ppom/ppom-util.ts
index 32a8aacf423..5f143f51062 100644
--- a/app/lib/ppom/ppom-util.ts
+++ b/app/lib/ppom/ppom-util.ts
@@ -56,7 +56,11 @@ const SECURITY_ALERT_RESPONSE_IN_PROGRESS = {
   description: 'Validating the confirmation in progress.',
 };
 
-async function validateRequest(req: PPOMRequest, transactionId?: string) {
+async function validateRequest(
+  req: PPOMRequest,
+  transactionId?: string,
+  securityAlertId?: string,
+) {
   const {
     AccountsController,
     NetworkController,
@@ -70,25 +74,21 @@ async function validateRequest(req: PPOMRequest, transactionId?: string) {
   );
   const isConfirmationMethod = CONFIRMATION_METHODS.includes(req.method);
   const isBlockaidFeatEnabled = await isBlockaidFeatureEnabled();
-  if (
-    !ppomController ||
-    !isBlockaidFeatEnabled ||
-    !isConfirmationMethod
-  ) {
+  if (!ppomController || !isBlockaidFeatEnabled || !isConfirmationMethod) {
     return;
   }
 
   if (req.method === 'eth_sendTransaction') {
     const internalAccounts = AccountsController.listAccounts();
-    const toAddress: string | undefined = (
-      req?.params?.[0] as Record<string, string>
-    ).to;
+    const { from: fromAddress, to: toAddress } = req
+      ?.params?.[0] as Partial<TransactionParams>;
 
     if (
       internalAccounts.some(
         ({ address }: { address: string }) =>
           address?.toLowerCase() === toAddress?.toLowerCase(),
-      )
+      ) &&
+      toAddress !== fromAddress
     ) {
       return;
     }
@@ -107,6 +107,7 @@ async function validateRequest(req: PPOMRequest, transactionId?: string) {
       req,
       SECURITY_ALERT_RESPONSE_IN_PROGRESS,
       transactionId,
+      { securityAlertId },
     );
 
     const normalizedRequest = normalizeRequest(req);
@@ -129,6 +130,7 @@ async function validateRequest(req: PPOMRequest, transactionId?: string) {
 
     setSecurityAlertResponse(req, securityAlertResponse, transactionId, {
       updateControllerState: true,
+      securityAlertId,
     });
   }
 }
@@ -177,7 +179,10 @@ function setSecurityAlertResponse(
   request: PPOMRequest,
   response: SecurityAlertResponse,
   transactionId?: string,
-  { updateControllerState }: { updateControllerState?: boolean } = {},
+  {
+    updateControllerState,
+    securityAlertId,
+  }: { updateControllerState?: boolean; securityAlertId?: string } = {},
 ) {
   if (isTransactionRequest(request)) {
     store.dispatch(
@@ -185,8 +190,11 @@ function setSecurityAlertResponse(
     );
 
     if (updateControllerState) {
-      // eslint-disable-next-line @typescript-eslint/no-explicit-any
-      updateSecurityAlertResponse(transactionId as string, response as any);
+      updateSecurityAlertResponse(
+        transactionId as string,
+        // eslint-disable-next-line @typescript-eslint/no-explicit-any
+        { ...response, securityAlertId } as any,
+      );
     }
   } else {
     store.dispatch(setSignatureRequestSecurityAlertResponse(response));
@@ -235,7 +243,13 @@ function clearSignatureSecurityAlertResponse() {
   store.dispatch(setSignatureRequestSecurityAlertResponse());
 }
 
+function createValidatorForSecurityAlertId(securityAlertId: string) {
+  return (req: PPOMRequest, transactionId?: string) =>
+    validateRequest(req, transactionId, securityAlertId);
+}
+
 export default {
   validateRequest,
+  createValidatorForSecurityAlertId,
   clearSignatureSecurityAlertResponse,
 };


Reviews:
user: login: matthewwalsh0
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-05-01T09:20:36Z
user: login: jpuri
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-01T09:26:41Z
user: login: jpuri
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-01T09:27:24Z
user: login: jpuri
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-01T09:28:07Z
user: login: matthewwalsh0
state: DISMISSED
author_association: MEMBER
submitted_at: 2025-05-01T09:34:59Z
user: login: jiexi
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-01T15:01:41Z
user: login: jiexi
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-01T15:04:17Z
user: login: jiexi
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-01T15:08:35Z
user: login: jiexi
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-01T15:09:40Z
user: login: jiexi
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-01T15:10:42Z
user: login: jpuri
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-01T15:49:56Z
user: login: jpuri
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-01T15:50:53Z
user: login: jpuri
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-01T15:57:35Z
user: login: jpuri
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-01T15:57:51Z
user: login: jpuri
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-01T15:57:55Z
user: login: jiexi
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-01T16:30:07Z
user: login: jiexi
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-01T16:33:37Z
user: login: jpuri
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-02T00:10:38Z
user: login: jpuri
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-02T00:20:22Z
user: login: jiexi
state: DISMISSED
author_association: MEMBER
submitted_at: 2025-05-02T15:31:33Z
user: login: matthewwalsh0
state: DISMISSED
author_association: MEMBER
submitted_at: 2025-05-02T19:21:46Z
user: login: jpuri
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-05T02:57:02Z
user: login: adonesky1
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-05T14:47:51Z
user: login: jiexi
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-05T15:06:28Z
user: login: OGPoyraz
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-05T15:18:35Z
user: login: vinistevam
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-05T15:23:16Z

Review comments:
diff_hunk: @@ -28,16 +125,20 @@ export async function processSendCalls(
   };
   const transactions = calls.map((call) => ({ params: call }));
 
+  await validateSendCalls(params, req as JSONRPCRequest);
+
   const from =
     paramFrom ?? (AccountsController.getSelectedAccount()?.address as Hex);
+  const securityAlertId = uuidv4();
 
   const { batchId: id } = await TransactionController.addTransactionBatch({
     from,
     networkClientId,
     origin,
-    securityAlertId: uuidv4(),
+    securityAlertId,
     transactions,
-    validateSecurity: () => Promise.resolve(),
+    validateSecurity:
+      ppomUtil.createValidatorForSecurityAlertId(securityAlertId),
   });
 
   return { id };
path: app/core/RPCMethods/createAsyncWalletMiddleware.ts
user: login: matthewwalsh0
body: As mentioned in the previous PR, can we rename this file to `eip5792` to align with extension and encapsulate the EIP, and move the `createAsyncWalletMiddleware` function inside `RPCMethodMiddleware`?
created_at: 2025-05-01T09:16:21Z
author_association: MEMBER
diff_hunk: @@ -6,16 +6,113 @@ import {
 import { JsonRpcMiddleware } from '@metamask/json-rpc-engine';
 import { JsonRpcParams } from '@metamask/eth-query';
 import { Hex, Json, JsonRpcRequest } from '@metamask/utils';
+import { JsonRpcError, rpcErrors } from '@metamask/rpc-errors';
 import { v4 as uuidv4 } from 'uuid';
 
+import ppomUtil from '../../lib/ppom/ppom-util';
 import Engine from '../Engine';
 
+const VERSION = '2.0.0';
+
+enum EIP5792ErrorCode {
+  UnsupportedNonOptionalCapability = 5700,
+  UnsupportedChainId = 5710,
+  UnknownBundleId = 5730,
+  RejectedUpgrade = 5750,
+}
+
+type JSONRPCRequest = JsonRpcRequest & {
+  networkClientId: string;
+  origin?: string;
+};
+
 export const getAccounts = async () => {
   const { AccountsController } = Engine.context;
   const selectedAddress = AccountsController.getSelectedAccount()?.address;
   return Promise.resolve(selectedAddress ? [selectedAddress] : []);
 };
 
+function validateSendCallsVersion(sendCalls: SendCalls) {
+  const { version } = sendCalls;
+
+  if (version !== VERSION) {
+    throw rpcErrors.invalidInput(
+      `Version not supported: Got ${version}, expected ${VERSION}`,
+    );
+  }
+}
+
+async function validateSendCallsChainId(
+  sendCalls: SendCalls,
+  req: JSONRPCRequest,
+) {
+  const { TransactionController, AccountsController } = Engine.context;
+  const { chainId } = sendCalls;
+  const { networkClientId } = req;
+
+  const dappChainId = Engine.controllerMessenger.call(
+    'NetworkController:getNetworkClientById',
+    networkClientId,
+  ).configuration.chainId;
+
+  if (chainId && chainId.toLowerCase() !== dappChainId.toLowerCase()) {
+    throw rpcErrors.invalidParams(
+      `Chain ID must match the dApp selected network: Got ${chainId}, expected ${dappChainId}`,
+    );
+  }
+
+  const from =
+    sendCalls.from ?? (AccountsController.getSelectedAccount()?.address as Hex);
+
+  const batchSupport = await TransactionController.isAtomicBatchSupported({
+    address: from,
+    chainIds: [dappChainId],
+  });
+
+  const chainBatchSupport = batchSupport?.[0];
+
+  if (!chainBatchSupport) {
+    throw new JsonRpcError(
+      EIP5792ErrorCode.UnsupportedChainId,
+      `EIP-7702 not supported on chain: ${dappChainId}`,
+    );
+  }
+}
+
+function validateCapabilities(sendCalls: SendCalls) {
+  const { calls, capabilities } = sendCalls;
+
+  const requiredTopLevelCapabilities = Object.keys(capabilities ?? {}).filter(
+    (name) => capabilities?.[name].optional !== true,
+  );
+
+  const requiredCallCapabilities = calls.flatMap((call) =>
+    Object.keys(call.capabilities ?? {}).filter(
+      (name) => call.capabilities?.[name].optional !== true,
+    ),
+  );
+
+  const requiredCapabilities = [
+    ...requiredTopLevelCapabilities,
+    ...requiredCallCapabilities,
+  ];
+
+  if (requiredCapabilities?.length) {
+    throw new JsonRpcError(
+      EIP5792ErrorCode.UnsupportedNonOptionalCapability,
+      `Unsupported non-optional capabilities: ${requiredCapabilities.join(
+        ', ',
+      )}`,
+    );
+  }
+}
+
+async function validateSendCalls(sendCalls: SendCalls, req: JSONRPCRequest) {
path: app/core/RPCMethods/createAsyncWalletMiddleware.ts
user: login: matthewwalsh0
body: Minor, should we keep all local functions at bottom to highlight exported ones?
created_at: 2025-05-01T09:16:54Z
author_association: MEMBER
diff_hunk: @@ -6,16 +6,113 @@ import {
 import { JsonRpcMiddleware } from '@metamask/json-rpc-engine';
 import { JsonRpcParams } from '@metamask/eth-query';
 import { Hex, Json, JsonRpcRequest } from '@metamask/utils';
+import { JsonRpcError, rpcErrors } from '@metamask/rpc-errors';
 import { v4 as uuidv4 } from 'uuid';
 
+import ppomUtil from '../../lib/ppom/ppom-util';
 import Engine from '../Engine';
 
+const VERSION = '2.0.0';
+
+enum EIP5792ErrorCode {
path: app/core/RPCMethods/createAsyncWalletMiddleware.ts
user: login: matthewwalsh0
body: Not needed as part of this PR, but worth us creating a ticket to migrate these to `@metamask/rpc-errors`. I added them locally in extension for the sake of time.
created_at: 2025-05-01T09:18:34Z
author_association: MEMBER
diff_hunk: @@ -6,16 +6,113 @@ import {
 import { JsonRpcMiddleware } from '@metamask/json-rpc-engine';
 import { JsonRpcParams } from '@metamask/eth-query';
 import { Hex, Json, JsonRpcRequest } from '@metamask/utils';
+import { JsonRpcError, rpcErrors } from '@metamask/rpc-errors';
 import { v4 as uuidv4 } from 'uuid';
 
+import ppomUtil from '../../lib/ppom/ppom-util';
 import Engine from '../Engine';
 
+const VERSION = '2.0.0';
+
+enum EIP5792ErrorCode {
+  UnsupportedNonOptionalCapability = 5700,
+  UnsupportedChainId = 5710,
+  UnknownBundleId = 5730,
+  RejectedUpgrade = 5750,
+}
+
+type JSONRPCRequest = JsonRpcRequest & {
+  networkClientId: string;
+  origin?: string;
+};
+
 export const getAccounts = async () => {
   const { AccountsController } = Engine.context;
   const selectedAddress = AccountsController.getSelectedAccount()?.address;
   return Promise.resolve(selectedAddress ? [selectedAddress] : []);
 };
 
+function validateSendCallsVersion(sendCalls: SendCalls) {
+  const { version } = sendCalls;
+
+  if (version !== VERSION) {
+    throw rpcErrors.invalidInput(
+      `Version not supported: Got ${version}, expected ${VERSION}`,
+    );
+  }
+}
+
+async function validateSendCallsChainId(
+  sendCalls: SendCalls,
+  req: JSONRPCRequest,
+) {
+  const { TransactionController, AccountsController } = Engine.context;
+  const { chainId } = sendCalls;
+  const { networkClientId } = req;
+
+  const dappChainId = Engine.controllerMessenger.call(
+    'NetworkController:getNetworkClientById',
+    networkClientId,
+  ).configuration.chainId;
+
+  if (chainId && chainId.toLowerCase() !== dappChainId.toLowerCase()) {
+    throw rpcErrors.invalidParams(
+      `Chain ID must match the dApp selected network: Got ${chainId}, expected ${dappChainId}`,
+    );
+  }
+
+  const from =
+    sendCalls.from ?? (AccountsController.getSelectedAccount()?.address as Hex);
+
+  const batchSupport = await TransactionController.isAtomicBatchSupported({
+    address: from,
+    chainIds: [dappChainId],
+  });
+
+  const chainBatchSupport = batchSupport?.[0];
+
+  if (!chainBatchSupport) {
+    throw new JsonRpcError(
+      EIP5792ErrorCode.UnsupportedChainId,
+      `EIP-7702 not supported on chain: ${dappChainId}`,
+    );
+  }
+}
+
+function validateCapabilities(sendCalls: SendCalls) {
+  const { calls, capabilities } = sendCalls;
+
+  const requiredTopLevelCapabilities = Object.keys(capabilities ?? {}).filter(
+    (name) => capabilities?.[name].optional !== true,
+  );
+
+  const requiredCallCapabilities = calls.flatMap((call) =>
+    Object.keys(call.capabilities ?? {}).filter(
+      (name) => call.capabilities?.[name].optional !== true,
+    ),
+  );
+
+  const requiredCapabilities = [
+    ...requiredTopLevelCapabilities,
+    ...requiredCallCapabilities,
+  ];
+
+  if (requiredCapabilities?.length) {
+    throw new JsonRpcError(
+      EIP5792ErrorCode.UnsupportedNonOptionalCapability,
+      `Unsupported non-optional capabilities: ${requiredCapabilities.join(
+        ', ',
+      )}`,
+    );
+  }
+}
+
+async function validateSendCalls(sendCalls: SendCalls, req: JSONRPCRequest) {
+  validateSendCallsVersion(sendCalls);
+  await validateSendCallsChainId(sendCalls, req);
+  validateCapabilities(sendCalls);
path: app/core/RPCMethods/createAsyncWalletMiddleware.ts
user: login: matthewwalsh0
body: Just confirming we're adding the disablement / preference validations in a separate ticket? Assuming we want the same logic in mobile.
created_at: 2025-05-01T09:19:17Z
author_association: MEMBER
diff_hunk: @@ -121,3 +121,4 @@ export MM_POOLED_STAKING_SERVICE_INTERRUPTION_BANNER_ENABLED="true"
 # Remote feature flag values won't be updated,
 # and selectors should return their fallback values
 export OVERRIDE_REMOTE_FEATURE_FLAGS="false"
+export EIP_7702_PUBLIC_KEY=""
path: .js.env.example
user: login: matthewwalsh0
body: Is it worth a comment to clarify it's usage?

Something like `Public key used to validate EIP-7702 contract signatures from LaunchDarkly` ?
created_at: 2025-05-01T09:20:32Z
author_association: MEMBER
diff_hunk: @@ -28,16 +125,20 @@ export async function processSendCalls(
   };
   const transactions = calls.map((call) => ({ params: call }));
 
+  await validateSendCalls(params, req as JSONRPCRequest);
+
   const from =
     paramFrom ?? (AccountsController.getSelectedAccount()?.address as Hex);
+  const securityAlertId = uuidv4();
 
   const { batchId: id } = await TransactionController.addTransactionBatch({
     from,
     networkClientId,
     origin,
-    securityAlertId: uuidv4(),
+    securityAlertId,
     transactions,
-    validateSecurity: () => Promise.resolve(),
+    validateSecurity:
+      ppomUtil.createValidatorForSecurityAlertId(securityAlertId),
   });
 
   return { id };
path: app/core/RPCMethods/createAsyncWalletMiddleware.ts
user: login: jpuri
body: yep I did it already in following PR: https://github.com/MetaMask/metamask-mobile/pull/15015
created_at: 2025-05-01T09:26:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -6,16 +6,113 @@ import {
 import { JsonRpcMiddleware } from '@metamask/json-rpc-engine';
 import { JsonRpcParams } from '@metamask/eth-query';
 import { Hex, Json, JsonRpcRequest } from '@metamask/utils';
+import { JsonRpcError, rpcErrors } from '@metamask/rpc-errors';
 import { v4 as uuidv4 } from 'uuid';
 
+import ppomUtil from '../../lib/ppom/ppom-util';
 import Engine from '../Engine';
 
+const VERSION = '2.0.0';
+
+enum EIP5792ErrorCode {
+  UnsupportedNonOptionalCapability = 5700,
+  UnsupportedChainId = 5710,
+  UnknownBundleId = 5730,
+  RejectedUpgrade = 5750,
+}
+
+type JSONRPCRequest = JsonRpcRequest & {
+  networkClientId: string;
+  origin?: string;
+};
+
 export const getAccounts = async () => {
   const { AccountsController } = Engine.context;
   const selectedAddress = AccountsController.getSelectedAccount()?.address;
   return Promise.resolve(selectedAddress ? [selectedAddress] : []);
 };
 
+function validateSendCallsVersion(sendCalls: SendCalls) {
+  const { version } = sendCalls;
+
+  if (version !== VERSION) {
+    throw rpcErrors.invalidInput(
+      `Version not supported: Got ${version}, expected ${VERSION}`,
+    );
+  }
+}
+
+async function validateSendCallsChainId(
+  sendCalls: SendCalls,
+  req: JSONRPCRequest,
+) {
+  const { TransactionController, AccountsController } = Engine.context;
+  const { chainId } = sendCalls;
+  const { networkClientId } = req;
+
+  const dappChainId = Engine.controllerMessenger.call(
+    'NetworkController:getNetworkClientById',
+    networkClientId,
+  ).configuration.chainId;
+
+  if (chainId && chainId.toLowerCase() !== dappChainId.toLowerCase()) {
+    throw rpcErrors.invalidParams(
+      `Chain ID must match the dApp selected network: Got ${chainId}, expected ${dappChainId}`,
+    );
+  }
+
+  const from =
+    sendCalls.from ?? (AccountsController.getSelectedAccount()?.address as Hex);
+
+  const batchSupport = await TransactionController.isAtomicBatchSupported({
+    address: from,
+    chainIds: [dappChainId],
+  });
+
+  const chainBatchSupport = batchSupport?.[0];
+
+  if (!chainBatchSupport) {
+    throw new JsonRpcError(
+      EIP5792ErrorCode.UnsupportedChainId,
+      `EIP-7702 not supported on chain: ${dappChainId}`,
+    );
+  }
+}
+
+function validateCapabilities(sendCalls: SendCalls) {
+  const { calls, capabilities } = sendCalls;
+
+  const requiredTopLevelCapabilities = Object.keys(capabilities ?? {}).filter(
+    (name) => capabilities?.[name].optional !== true,
+  );
+
+  const requiredCallCapabilities = calls.flatMap((call) =>
+    Object.keys(call.capabilities ?? {}).filter(
+      (name) => call.capabilities?.[name].optional !== true,
+    ),
+  );
+
+  const requiredCapabilities = [
+    ...requiredTopLevelCapabilities,
+    ...requiredCallCapabilities,
+  ];
+
+  if (requiredCapabilities?.length) {
+    throw new JsonRpcError(
+      EIP5792ErrorCode.UnsupportedNonOptionalCapability,
+      `Unsupported non-optional capabilities: ${requiredCapabilities.join(
+        ', ',
+      )}`,
+    );
+  }
+}
+
+async function validateSendCalls(sendCalls: SendCalls, req: JSONRPCRequest) {
+  validateSendCallsVersion(sendCalls);
+  await validateSendCallsChainId(sendCalls, req);
+  validateCapabilities(sendCalls);
path: app/core/RPCMethods/createAsyncWalletMiddleware.ts
user: login: jpuri
body: yep this will be coupled with UI work
created_at: 2025-05-01T09:27:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -6,16 +6,113 @@ import {
 import { JsonRpcMiddleware } from '@metamask/json-rpc-engine';
 import { JsonRpcParams } from '@metamask/eth-query';
 import { Hex, Json, JsonRpcRequest } from '@metamask/utils';
+import { JsonRpcError, rpcErrors } from '@metamask/rpc-errors';
 import { v4 as uuidv4 } from 'uuid';
 
+import ppomUtil from '../../lib/ppom/ppom-util';
 import Engine from '../Engine';
 
+const VERSION = '2.0.0';
+
+enum EIP5792ErrorCode {
+  UnsupportedNonOptionalCapability = 5700,
+  UnsupportedChainId = 5710,
+  UnknownBundleId = 5730,
+  RejectedUpgrade = 5750,
+}
+
+type JSONRPCRequest = JsonRpcRequest & {
+  networkClientId: string;
+  origin?: string;
+};
+
 export const getAccounts = async () => {
   const { AccountsController } = Engine.context;
   const selectedAddress = AccountsController.getSelectedAccount()?.address;
   return Promise.resolve(selectedAddress ? [selectedAddress] : []);
 };
 
+function validateSendCallsVersion(sendCalls: SendCalls) {
+  const { version } = sendCalls;
+
+  if (version !== VERSION) {
+    throw rpcErrors.invalidInput(
+      `Version not supported: Got ${version}, expected ${VERSION}`,
+    );
+  }
+}
+
+async function validateSendCallsChainId(
+  sendCalls: SendCalls,
+  req: JSONRPCRequest,
+) {
+  const { TransactionController, AccountsController } = Engine.context;
+  const { chainId } = sendCalls;
+  const { networkClientId } = req;
+
+  const dappChainId = Engine.controllerMessenger.call(
+    'NetworkController:getNetworkClientById',
+    networkClientId,
+  ).configuration.chainId;
+
+  if (chainId && chainId.toLowerCase() !== dappChainId.toLowerCase()) {
+    throw rpcErrors.invalidParams(
+      `Chain ID must match the dApp selected network: Got ${chainId}, expected ${dappChainId}`,
+    );
+  }
+
+  const from =
+    sendCalls.from ?? (AccountsController.getSelectedAccount()?.address as Hex);
+
+  const batchSupport = await TransactionController.isAtomicBatchSupported({
+    address: from,
+    chainIds: [dappChainId],
+  });
+
+  const chainBatchSupport = batchSupport?.[0];
+
+  if (!chainBatchSupport) {
+    throw new JsonRpcError(
+      EIP5792ErrorCode.UnsupportedChainId,
+      `EIP-7702 not supported on chain: ${dappChainId}`,
+    );
+  }
+}
+
+function validateCapabilities(sendCalls: SendCalls) {
+  const { calls, capabilities } = sendCalls;
+
+  const requiredTopLevelCapabilities = Object.keys(capabilities ?? {}).filter(
+    (name) => capabilities?.[name].optional !== true,
+  );
+
+  const requiredCallCapabilities = calls.flatMap((call) =>
+    Object.keys(call.capabilities ?? {}).filter(
+      (name) => call.capabilities?.[name].optional !== true,
+    ),
+  );
+
+  const requiredCapabilities = [
+    ...requiredTopLevelCapabilities,
+    ...requiredCallCapabilities,
+  ];
+
+  if (requiredCapabilities?.length) {
+    throw new JsonRpcError(
+      EIP5792ErrorCode.UnsupportedNonOptionalCapability,
+      `Unsupported non-optional capabilities: ${requiredCapabilities.join(
+        ', ',
+      )}`,
+    );
+  }
+}
+
+async function validateSendCalls(sendCalls: SendCalls, req: JSONRPCRequest) {
path: app/core/RPCMethods/createAsyncWalletMiddleware.ts
user: login: jpuri
body: let me do this in PR that create `eip5792.ts`
created_at: 2025-05-01T09:28:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -86,11 +90,84 @@ describe('processSendCalls', () => {
     networkClientId: 'sepolia',
   } as JsonRpcRequest;
 
-  it('return selected account address', async () => {
+  it('creates transaction instance for batch request', async () => {
     const result = await processSendCalls(MOCK_PARAMS, MOCK_REQUEST);
     expect(
       Engine.context.TransactionController.addTransactionBatch,
     ).toHaveBeenCalledTimes(1);
     expect(result.id).toStrictEqual(123);
   });
+
+  it('throw error if wrong version of request is used', async () => {
+    expect(async () => {
+      await processSendCalls(
+        { ...MOCK_PARAMS, version: '3.0.0' },
+        MOCK_REQUEST,
+      );
+    }).rejects.toThrow('Version not supported: Got 3.0.0, expected 2.0.0');
+  });
+
+  it('throw error if TransactionController.isAtomicBatchSupported returns false', async () => {
+    Engine.context.TransactionController.isAtomicBatchSupported = jest
+      .fn()
+      .mockResolvedValue([false]);
path: app/core/RPCMethods/createAsyncWalletMiddleware.test.ts
user: login: jiexi
body: is this type correct?

https://github.com/MetaMask/core/blob/cfb040dc2cea24b5bb93234246a364551fc2a249/packages/transaction-controller/src/types.ts#L1753-L1766
created_at: 2025-05-01T15:01:41Z
author_association: MEMBER
diff_hunk: @@ -86,11 +90,84 @@ describe('processSendCalls', () => {
     networkClientId: 'sepolia',
   } as JsonRpcRequest;
 
-  it('return selected account address', async () => {
+  it('creates transaction instance for batch request', async () => {
     const result = await processSendCalls(MOCK_PARAMS, MOCK_REQUEST);
     expect(
       Engine.context.TransactionController.addTransactionBatch,
     ).toHaveBeenCalledTimes(1);
     expect(result.id).toStrictEqual(123);
   });
+
+  it('throw error if wrong version of request is used', async () => {
+    expect(async () => {
+      await processSendCalls(
+        { ...MOCK_PARAMS, version: '3.0.0' },
+        MOCK_REQUEST,
+      );
+    }).rejects.toThrow('Version not supported: Got 3.0.0, expected 2.0.0');
+  });
+
+  it('throw error if TransactionController.isAtomicBatchSupported returns false', async () => {
+    Engine.context.TransactionController.isAtomicBatchSupported = jest
+      .fn()
+      .mockResolvedValue([false]);
+    expect(async () => {
+      await processSendCalls(MOCK_PARAMS, MOCK_REQUEST);
+    }).rejects.toThrow('EIP-7702 not supported on chain: 0xaa36a7');
+    Engine.context.TransactionController.isAtomicBatchSupported = jest
+      .fn()
+      .mockResolvedValue([true]);
path: app/core/RPCMethods/createAsyncWalletMiddleware.test.ts
user: login: jiexi
body: thoughts on moving this into a beforeEach if it's required for the other tests? 
created_at: 2025-05-01T15:04:17Z
author_association: MEMBER
diff_hunk: @@ -6,16 +6,113 @@ import {
 import { JsonRpcMiddleware } from '@metamask/json-rpc-engine';
 import { JsonRpcParams } from '@metamask/eth-query';
 import { Hex, Json, JsonRpcRequest } from '@metamask/utils';
+import { JsonRpcError, rpcErrors } from '@metamask/rpc-errors';
 import { v4 as uuidv4 } from 'uuid';
 
+import ppomUtil from '../../lib/ppom/ppom-util';
 import Engine from '../Engine';
 
+const VERSION = '2.0.0';
+
+enum EIP5792ErrorCode {
+  UnsupportedNonOptionalCapability = 5700,
+  UnsupportedChainId = 5710,
+  UnknownBundleId = 5730,
+  RejectedUpgrade = 5750,
+}
+
+type JSONRPCRequest = JsonRpcRequest & {
+  networkClientId: string;
+  origin?: string;
+};
+
 export const getAccounts = async () => {
   const { AccountsController } = Engine.context;
   const selectedAddress = AccountsController.getSelectedAccount()?.address;
   return Promise.resolve(selectedAddress ? [selectedAddress] : []);
 };
 
+function validateSendCallsVersion(sendCalls: SendCalls) {
+  const { version } = sendCalls;
+
+  if (version !== VERSION) {
+    throw rpcErrors.invalidInput(
+      `Version not supported: Got ${version}, expected ${VERSION}`,
+    );
+  }
+}
+
+async function validateSendCallsChainId(
+  sendCalls: SendCalls,
+  req: JSONRPCRequest,
+) {
+  const { TransactionController, AccountsController } = Engine.context;
+  const { chainId } = sendCalls;
+  const { networkClientId } = req;
+
+  const dappChainId = Engine.controllerMessenger.call(
+    'NetworkController:getNetworkClientById',
+    networkClientId,
+  ).configuration.chainId;
+
+  if (chainId && chainId.toLowerCase() !== dappChainId.toLowerCase()) {
+    throw rpcErrors.invalidParams(
+      `Chain ID must match the dApp selected network: Got ${chainId}, expected ${dappChainId}`,
+    );
+  }
+
+  const from =
+    sendCalls.from ?? (AccountsController.getSelectedAccount()?.address as Hex);
path: app/core/RPCMethods/createAsyncWalletMiddleware.ts
user: login: jiexi
body: do we need a check if `from` still isn't define after all this?
created_at: 2025-05-01T15:08:35Z
author_association: MEMBER
diff_hunk: @@ -6,16 +6,113 @@ import {
 import { JsonRpcMiddleware } from '@metamask/json-rpc-engine';
 import { JsonRpcParams } from '@metamask/eth-query';
 import { Hex, Json, JsonRpcRequest } from '@metamask/utils';
+import { JsonRpcError, rpcErrors } from '@metamask/rpc-errors';
 import { v4 as uuidv4 } from 'uuid';
 
+import ppomUtil from '../../lib/ppom/ppom-util';
 import Engine from '../Engine';
 
+const VERSION = '2.0.0';
+
+enum EIP5792ErrorCode {
+  UnsupportedNonOptionalCapability = 5700,
+  UnsupportedChainId = 5710,
+  UnknownBundleId = 5730,
+  RejectedUpgrade = 5750,
+}
+
+type JSONRPCRequest = JsonRpcRequest & {
+  networkClientId: string;
+  origin?: string;
+};
+
 export const getAccounts = async () => {
   const { AccountsController } = Engine.context;
   const selectedAddress = AccountsController.getSelectedAccount()?.address;
   return Promise.resolve(selectedAddress ? [selectedAddress] : []);
 };
 
+function validateSendCallsVersion(sendCalls: SendCalls) {
+  const { version } = sendCalls;
+
+  if (version !== VERSION) {
+    throw rpcErrors.invalidInput(
+      `Version not supported: Got ${version}, expected ${VERSION}`,
+    );
+  }
+}
+
+async function validateSendCallsChainId(
+  sendCalls: SendCalls,
+  req: JSONRPCRequest,
+) {
+  const { TransactionController, AccountsController } = Engine.context;
+  const { chainId } = sendCalls;
+  const { networkClientId } = req;
+
+  const dappChainId = Engine.controllerMessenger.call(
+    'NetworkController:getNetworkClientById',
+    networkClientId,
+  ).configuration.chainId;
+
+  if (chainId && chainId.toLowerCase() !== dappChainId.toLowerCase()) {
+    throw rpcErrors.invalidParams(
+      `Chain ID must match the dApp selected network: Got ${chainId}, expected ${dappChainId}`,
+    );
+  }
+
+  const from =
+    sendCalls.from ?? (AccountsController.getSelectedAccount()?.address as Hex);
+
+  const batchSupport = await TransactionController.isAtomicBatchSupported({
+    address: from,
+    chainIds: [dappChainId],
+  });
+
+  const chainBatchSupport = batchSupport?.[0];
+
+  if (!chainBatchSupport) {
+    throw new JsonRpcError(
+      EIP5792ErrorCode.UnsupportedChainId,
+      `EIP-7702 not supported on chain: ${dappChainId}`,
+    );
+  }
+}
+
+function validateCapabilities(sendCalls: SendCalls) {
+  const { calls, capabilities } = sendCalls;
+
+  const requiredTopLevelCapabilities = Object.keys(capabilities ?? {}).filter(
+    (name) => capabilities?.[name].optional !== true,
+  );
+
+  const requiredCallCapabilities = calls.flatMap((call) =>
+    Object.keys(call.capabilities ?? {}).filter(
+      (name) => call.capabilities?.[name].optional !== true,
path: app/core/RPCMethods/createAsyncWalletMiddleware.ts
user: login: jiexi
body: why specifically `!== true`?
created_at: 2025-05-01T15:09:40Z
author_association: MEMBER
diff_hunk: @@ -6,16 +6,113 @@ import {
 import { JsonRpcMiddleware } from '@metamask/json-rpc-engine';
 import { JsonRpcParams } from '@metamask/eth-query';
 import { Hex, Json, JsonRpcRequest } from '@metamask/utils';
+import { JsonRpcError, rpcErrors } from '@metamask/rpc-errors';
 import { v4 as uuidv4 } from 'uuid';
 
+import ppomUtil from '../../lib/ppom/ppom-util';
 import Engine from '../Engine';
 
+const VERSION = '2.0.0';
+
+enum EIP5792ErrorCode {
+  UnsupportedNonOptionalCapability = 5700,
+  UnsupportedChainId = 5710,
+  UnknownBundleId = 5730,
+  RejectedUpgrade = 5750,
+}
+
+type JSONRPCRequest = JsonRpcRequest & {
+  networkClientId: string;
+  origin?: string;
+};
+
 export const getAccounts = async () => {
   const { AccountsController } = Engine.context;
   const selectedAddress = AccountsController.getSelectedAccount()?.address;
   return Promise.resolve(selectedAddress ? [selectedAddress] : []);
 };
 
+function validateSendCallsVersion(sendCalls: SendCalls) {
+  const { version } = sendCalls;
+
+  if (version !== VERSION) {
+    throw rpcErrors.invalidInput(
+      `Version not supported: Got ${version}, expected ${VERSION}`,
+    );
+  }
+}
+
+async function validateSendCallsChainId(
+  sendCalls: SendCalls,
+  req: JSONRPCRequest,
+) {
+  const { TransactionController, AccountsController } = Engine.context;
+  const { chainId } = sendCalls;
+  const { networkClientId } = req;
+
+  const dappChainId = Engine.controllerMessenger.call(
+    'NetworkController:getNetworkClientById',
+    networkClientId,
+  ).configuration.chainId;
+
+  if (chainId && chainId.toLowerCase() !== dappChainId.toLowerCase()) {
+    throw rpcErrors.invalidParams(
+      `Chain ID must match the dApp selected network: Got ${chainId}, expected ${dappChainId}`,
+    );
+  }
+
+  const from =
+    sendCalls.from ?? (AccountsController.getSelectedAccount()?.address as Hex);
+
+  const batchSupport = await TransactionController.isAtomicBatchSupported({
+    address: from,
+    chainIds: [dappChainId],
+  });
+
+  const chainBatchSupport = batchSupport?.[0];
+
+  if (!chainBatchSupport) {
+    throw new JsonRpcError(
+      EIP5792ErrorCode.UnsupportedChainId,
+      `EIP-7702 not supported on chain: ${dappChainId}`,
+    );
+  }
+}
+
+function validateCapabilities(sendCalls: SendCalls) {
+  const { calls, capabilities } = sendCalls;
+
+  const requiredTopLevelCapabilities = Object.keys(capabilities ?? {}).filter(
+    (name) => capabilities?.[name].optional !== true,
+  );
+
+  const requiredCallCapabilities = calls.flatMap((call) =>
+    Object.keys(call.capabilities ?? {}).filter(
+      (name) => call.capabilities?.[name].optional !== true,
+    ),
+  );
+
+  const requiredCapabilities = [
+    ...requiredTopLevelCapabilities,
+    ...requiredCallCapabilities,
+  ];
+
+  if (requiredCapabilities?.length) {
path: app/core/RPCMethods/createAsyncWalletMiddleware.ts
user: login: jiexi
body: is the optional chain `?` needed?
created_at: 2025-05-01T15:10:42Z
author_association: MEMBER
diff_hunk: @@ -6,16 +6,113 @@ import {
 import { JsonRpcMiddleware } from '@metamask/json-rpc-engine';
 import { JsonRpcParams } from '@metamask/eth-query';
 import { Hex, Json, JsonRpcRequest } from '@metamask/utils';
+import { JsonRpcError, rpcErrors } from '@metamask/rpc-errors';
 import { v4 as uuidv4 } from 'uuid';
 
+import ppomUtil from '../../lib/ppom/ppom-util';
 import Engine from '../Engine';
 
+const VERSION = '2.0.0';
+
+enum EIP5792ErrorCode {
+  UnsupportedNonOptionalCapability = 5700,
+  UnsupportedChainId = 5710,
+  UnknownBundleId = 5730,
+  RejectedUpgrade = 5750,
+}
+
+type JSONRPCRequest = JsonRpcRequest & {
+  networkClientId: string;
+  origin?: string;
+};
+
 export const getAccounts = async () => {
   const { AccountsController } = Engine.context;
   const selectedAddress = AccountsController.getSelectedAccount()?.address;
   return Promise.resolve(selectedAddress ? [selectedAddress] : []);
 };
 
+function validateSendCallsVersion(sendCalls: SendCalls) {
+  const { version } = sendCalls;
+
+  if (version !== VERSION) {
+    throw rpcErrors.invalidInput(
+      `Version not supported: Got ${version}, expected ${VERSION}`,
+    );
+  }
+}
+
+async function validateSendCallsChainId(
+  sendCalls: SendCalls,
+  req: JSONRPCRequest,
+) {
+  const { TransactionController, AccountsController } = Engine.context;
+  const { chainId } = sendCalls;
+  const { networkClientId } = req;
+
+  const dappChainId = Engine.controllerMessenger.call(
+    'NetworkController:getNetworkClientById',
+    networkClientId,
+  ).configuration.chainId;
+
+  if (chainId && chainId.toLowerCase() !== dappChainId.toLowerCase()) {
+    throw rpcErrors.invalidParams(
+      `Chain ID must match the dApp selected network: Got ${chainId}, expected ${dappChainId}`,
+    );
+  }
+
+  const from =
+    sendCalls.from ?? (AccountsController.getSelectedAccount()?.address as Hex);
path: app/core/RPCMethods/createAsyncWalletMiddleware.ts
user: login: jpuri
body: `from` is an optional field in `SendCalls`
created_at: 2025-05-01T15:49:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -86,11 +90,84 @@ describe('processSendCalls', () => {
     networkClientId: 'sepolia',
   } as JsonRpcRequest;
 
-  it('return selected account address', async () => {
+  it('creates transaction instance for batch request', async () => {
     const result = await processSendCalls(MOCK_PARAMS, MOCK_REQUEST);
     expect(
       Engine.context.TransactionController.addTransactionBatch,
     ).toHaveBeenCalledTimes(1);
     expect(result.id).toStrictEqual(123);
   });
+
+  it('throw error if wrong version of request is used', async () => {
+    expect(async () => {
+      await processSendCalls(
+        { ...MOCK_PARAMS, version: '3.0.0' },
+        MOCK_REQUEST,
+      );
+    }).rejects.toThrow('Version not supported: Got 3.0.0, expected 2.0.0');
+  });
+
+  it('throw error if TransactionController.isAtomicBatchSupported returns false', async () => {
+    Engine.context.TransactionController.isAtomicBatchSupported = jest
+      .fn()
+      .mockResolvedValue([false]);
+    expect(async () => {
+      await processSendCalls(MOCK_PARAMS, MOCK_REQUEST);
+    }).rejects.toThrow('EIP-7702 not supported on chain: 0xaa36a7');
+    Engine.context.TransactionController.isAtomicBatchSupported = jest
+      .fn()
+      .mockResolvedValue([true]);
path: app/core/RPCMethods/createAsyncWalletMiddleware.test.ts
user: login: jpuri
body: It is used in only 1 test.
created_at: 2025-05-01T15:50:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -86,11 +90,84 @@ describe('processSendCalls', () => {
     networkClientId: 'sepolia',
   } as JsonRpcRequest;
 
-  it('return selected account address', async () => {
+  it('creates transaction instance for batch request', async () => {
     const result = await processSendCalls(MOCK_PARAMS, MOCK_REQUEST);
     expect(
       Engine.context.TransactionController.addTransactionBatch,
     ).toHaveBeenCalledTimes(1);
     expect(result.id).toStrictEqual(123);
   });
+
+  it('throw error if wrong version of request is used', async () => {
+    expect(async () => {
+      await processSendCalls(
+        { ...MOCK_PARAMS, version: '3.0.0' },
+        MOCK_REQUEST,
+      );
+    }).rejects.toThrow('Version not supported: Got 3.0.0, expected 2.0.0');
+  });
+
+  it('throw error if TransactionController.isAtomicBatchSupported returns false', async () => {
+    Engine.context.TransactionController.isAtomicBatchSupported = jest
+      .fn()
+      .mockResolvedValue([false]);
path: app/core/RPCMethods/createAsyncWalletMiddleware.test.ts
user: login: jpuri
body: I fixed it 👍 
created_at: 2025-05-01T15:57:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -6,16 +6,113 @@ import {
 import { JsonRpcMiddleware } from '@metamask/json-rpc-engine';
 import { JsonRpcParams } from '@metamask/eth-query';
 import { Hex, Json, JsonRpcRequest } from '@metamask/utils';
+import { JsonRpcError, rpcErrors } from '@metamask/rpc-errors';
 import { v4 as uuidv4 } from 'uuid';
 
+import ppomUtil from '../../lib/ppom/ppom-util';
 import Engine from '../Engine';
 
+const VERSION = '2.0.0';
+
+enum EIP5792ErrorCode {
+  UnsupportedNonOptionalCapability = 5700,
+  UnsupportedChainId = 5710,
+  UnknownBundleId = 5730,
+  RejectedUpgrade = 5750,
+}
+
+type JSONRPCRequest = JsonRpcRequest & {
+  networkClientId: string;
+  origin?: string;
+};
+
 export const getAccounts = async () => {
   const { AccountsController } = Engine.context;
   const selectedAddress = AccountsController.getSelectedAccount()?.address;
   return Promise.resolve(selectedAddress ? [selectedAddress] : []);
 };
 
+function validateSendCallsVersion(sendCalls: SendCalls) {
+  const { version } = sendCalls;
+
+  if (version !== VERSION) {
+    throw rpcErrors.invalidInput(
+      `Version not supported: Got ${version}, expected ${VERSION}`,
+    );
+  }
+}
+
+async function validateSendCallsChainId(
+  sendCalls: SendCalls,
+  req: JSONRPCRequest,
+) {
+  const { TransactionController, AccountsController } = Engine.context;
+  const { chainId } = sendCalls;
+  const { networkClientId } = req;
+
+  const dappChainId = Engine.controllerMessenger.call(
+    'NetworkController:getNetworkClientById',
+    networkClientId,
+  ).configuration.chainId;
+
+  if (chainId && chainId.toLowerCase() !== dappChainId.toLowerCase()) {
+    throw rpcErrors.invalidParams(
+      `Chain ID must match the dApp selected network: Got ${chainId}, expected ${dappChainId}`,
+    );
+  }
+
+  const from =
+    sendCalls.from ?? (AccountsController.getSelectedAccount()?.address as Hex);
+
+  const batchSupport = await TransactionController.isAtomicBatchSupported({
+    address: from,
+    chainIds: [dappChainId],
+  });
+
+  const chainBatchSupport = batchSupport?.[0];
+
+  if (!chainBatchSupport) {
+    throw new JsonRpcError(
+      EIP5792ErrorCode.UnsupportedChainId,
+      `EIP-7702 not supported on chain: ${dappChainId}`,
+    );
+  }
+}
+
+function validateCapabilities(sendCalls: SendCalls) {
+  const { calls, capabilities } = sendCalls;
+
+  const requiredTopLevelCapabilities = Object.keys(capabilities ?? {}).filter(
+    (name) => capabilities?.[name].optional !== true,
+  );
+
+  const requiredCallCapabilities = calls.flatMap((call) =>
+    Object.keys(call.capabilities ?? {}).filter(
+      (name) => call.capabilities?.[name].optional !== true,
path: app/core/RPCMethods/createAsyncWalletMiddleware.ts
user: login: jpuri
body: I fixed it 👍
created_at: 2025-05-01T15:57:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -6,16 +6,113 @@ import {
 import { JsonRpcMiddleware } from '@metamask/json-rpc-engine';
 import { JsonRpcParams } from '@metamask/eth-query';
 import { Hex, Json, JsonRpcRequest } from '@metamask/utils';
+import { JsonRpcError, rpcErrors } from '@metamask/rpc-errors';
 import { v4 as uuidv4 } from 'uuid';
 
+import ppomUtil from '../../lib/ppom/ppom-util';
 import Engine from '../Engine';
 
+const VERSION = '2.0.0';
+
+enum EIP5792ErrorCode {
+  UnsupportedNonOptionalCapability = 5700,
+  UnsupportedChainId = 5710,
+  UnknownBundleId = 5730,
+  RejectedUpgrade = 5750,
+}
+
+type JSONRPCRequest = JsonRpcRequest & {
+  networkClientId: string;
+  origin?: string;
+};
+
 export const getAccounts = async () => {
   const { AccountsController } = Engine.context;
   const selectedAddress = AccountsController.getSelectedAccount()?.address;
   return Promise.resolve(selectedAddress ? [selectedAddress] : []);
 };
 
+function validateSendCallsVersion(sendCalls: SendCalls) {
+  const { version } = sendCalls;
+
+  if (version !== VERSION) {
+    throw rpcErrors.invalidInput(
+      `Version not supported: Got ${version}, expected ${VERSION}`,
+    );
+  }
+}
+
+async function validateSendCallsChainId(
+  sendCalls: SendCalls,
+  req: JSONRPCRequest,
+) {
+  const { TransactionController, AccountsController } = Engine.context;
+  const { chainId } = sendCalls;
+  const { networkClientId } = req;
+
+  const dappChainId = Engine.controllerMessenger.call(
+    'NetworkController:getNetworkClientById',
+    networkClientId,
+  ).configuration.chainId;
+
+  if (chainId && chainId.toLowerCase() !== dappChainId.toLowerCase()) {
+    throw rpcErrors.invalidParams(
+      `Chain ID must match the dApp selected network: Got ${chainId}, expected ${dappChainId}`,
+    );
+  }
+
+  const from =
+    sendCalls.from ?? (AccountsController.getSelectedAccount()?.address as Hex);
+
+  const batchSupport = await TransactionController.isAtomicBatchSupported({
+    address: from,
+    chainIds: [dappChainId],
+  });
+
+  const chainBatchSupport = batchSupport?.[0];
+
+  if (!chainBatchSupport) {
+    throw new JsonRpcError(
+      EIP5792ErrorCode.UnsupportedChainId,
+      `EIP-7702 not supported on chain: ${dappChainId}`,
+    );
+  }
+}
+
+function validateCapabilities(sendCalls: SendCalls) {
+  const { calls, capabilities } = sendCalls;
+
+  const requiredTopLevelCapabilities = Object.keys(capabilities ?? {}).filter(
+    (name) => capabilities?.[name].optional !== true,
+  );
+
+  const requiredCallCapabilities = calls.flatMap((call) =>
+    Object.keys(call.capabilities ?? {}).filter(
+      (name) => call.capabilities?.[name].optional !== true,
+    ),
+  );
+
+  const requiredCapabilities = [
+    ...requiredTopLevelCapabilities,
+    ...requiredCallCapabilities,
+  ];
+
+  if (requiredCapabilities?.length) {
path: app/core/RPCMethods/createAsyncWalletMiddleware.ts
user: login: jpuri
body: I fixed it 👍
created_at: 2025-05-01T15:57:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -121,3 +121,5 @@ export MM_POOLED_STAKING_SERVICE_INTERRUPTION_BANNER_ENABLED="true"
 # Remote feature flag values won't be updated,
 # and selectors should return their fallback values
 export OVERRIDE_REMOTE_FEATURE_FLAGS="false"
+# Public key used to validate EIP-7702 contract signatures from LaunchDarkly
+export EIP_7702_PUBLIC_KEY=""
path: .js.env.example
user: login: jiexi
body: where is this used in code btw?
created_at: 2025-05-01T16:30:07Z
author_association: MEMBER
diff_hunk: @@ -83,12 +83,16 @@ async function validateRequest(req: PPOMRequest, transactionId?: string) {
     const toAddress: string | undefined = (
       req?.params?.[0] as Record<string, string>
     ).to;
+    const fromAddress: string | undefined = (
+      req?.params?.[0] as Record<string, string>
+    ).from;
path: app/lib/ppom/ppom-util.ts
user: login: jiexi
body: thoughts on  destructuring `to` and `from`?

```
const {to: toAddress, from: fromAddress} = req?.params?.[0] as Record<string, string>
```
created_at: 2025-05-01T16:33:36Z
author_association: MEMBER
diff_hunk: @@ -121,3 +121,5 @@ export MM_POOLED_STAKING_SERVICE_INTERRUPTION_BANNER_ENABLED="true"
 # Remote feature flag values won't be updated,
 # and selectors should return their fallback values
 export OVERRIDE_REMOTE_FEATURE_FLAGS="false"
+# Public key used to validate EIP-7702 contract signatures from LaunchDarkly
+export EIP_7702_PUBLIC_KEY=""
path: .js.env.example
user: login: jpuri
body: This was added in previous PR: https://github.com/MetaMask/metamask-mobile/pull/14792/files#diff-e39dfa37a0e6135a51e95538fa9a0e8e878d200b6722ce9008565f18fe14ee04R112
created_at: 2025-05-02T00:10:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -83,12 +83,16 @@ async function validateRequest(req: PPOMRequest, transactionId?: string) {
     const toAddress: string | undefined = (
       req?.params?.[0] as Record<string, string>
     ).to;
+    const fromAddress: string | undefined = (
+      req?.params?.[0] as Record<string, string>
+    ).from;
path: app/lib/ppom/ppom-util.ts
user: login: jpuri
body: PR is updated
created_at: 2025-05-02T00:20:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -88,7 +88,7 @@ function validateCapabilities(sendCalls: SendCalls) {
 
   const requiredCallCapabilities = calls.flatMap((call) =>
     Object.keys(call.capabilities ?? {}).filter(
-      (name) => call.capabilities?.[name].optional !== true,
+      (name) => !call.capabilities?.[name].optional,
path: app/core/RPCMethods/createAsyncWalletMiddleware.ts
user: login: matthewwalsh0
body: This isn't equivalent as it would mean if `optional` was set to `123` or `'false'`, we'd treat it as optional, but the default is required unless explicitly optional.

The middleware package should validate the type for us, but harmless and safer to validate here also?

Plus we want to align with extension which uses an explicit check.
created_at: 2025-05-02T19:20:24Z
author_association: MEMBER
diff_hunk: @@ -88,7 +88,7 @@ function validateCapabilities(sendCalls: SendCalls) {
 
   const requiredCallCapabilities = calls.flatMap((call) =>
     Object.keys(call.capabilities ?? {}).filter(
-      (name) => call.capabilities?.[name].optional !== true,
+      (name) => !call.capabilities?.[name].optional,
path: app/core/RPCMethods/createAsyncWalletMiddleware.ts
user: login: jpuri
body: I updated the PR @matthewwalsh0 
created_at: 2025-05-05T02:57:01Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-04-30T05:05:17Z
body: **CLA Signature Action:** All authors have signed the CLA. You may need to manually re-run the blocking PR check if it doesn't pass in a few minutes.
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-04-30T16:26:56Z
body: ## [<img alt="https://bitrise.io/" src="https://assets-global.website-files.com/5db35de024bb983af1b4e151/5e6f9ccc3e129dfd8a205e4e_Bitrise%20Logo%20-%20Eggplant%20Bg.png" height="20">](https://app.bitrise.io/app/be69d4368ee7e86d/pipelines/3aa937c6-86cd-4119-b26c-0fa52d995dfd) **Bitrise**

✅✅✅ `pr_smoke_e2e_pipeline` passed on Bitrise! ✅✅✅

Commit hash: 78c596d307645f59719c4f41fddc2abb35b973b8
Build link: https://app.bitrise.io/app/be69d4368ee7e86d/pipelines/3aa937c6-86cd-4119-b26c-0fa52d995dfd

>[!NOTE]
>- You can kick off another `pr_smoke_e2e_pipeline` on Bitrise by removing and re-applying the `Run Smoke E2E` label on the pull request

<!-- BITRISE_TAG -->
<!-- BITRISE_SUCCESS_TAG -->

<!-- 78c596d307645f59719c4f41fddc2abb35b973b8 -->
author_association: CONTRIBUTOR
user: login: codecov-commenter
created_at: 2025-05-05T03:09:55Z
body: ## [Codecov](https://app.codecov.io/gh/MetaMask/metamask-mobile/pull/14993?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=MetaMask) Report
Attention: Patch coverage is `91.42857%` with `3 lines` in your changes missing coverage. Please review.
> Project coverage is 67.81%. Comparing base [(`f518fc9`)](https://app.codecov.io/gh/MetaMask/metamask-mobile/commit/f518fc9cd65557f2ac6bcbc90cc048706f6ef961?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=MetaMask) to head [(`c6ec12a`)](https://app.codecov.io/gh/MetaMask/metamask-mobile/commit/c6ec12a25505b1cf1f1aa0b76d44b049a26b668b?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=MetaMask).
> Report is 51 commits behind head on main.

| [Files with missing lines](https://app.codecov.io/gh/MetaMask/metamask-mobile/pull/14993?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=MetaMask) | Patch % | Lines |
|---|---|---|
| [app/lib/ppom/ppom-util.ts](https://app.codecov.io/gh/MetaMask/metamask-mobile/pull/14993?src=pr&el=tree&filepath=app%2Flib%2Fppom%2Fppom-util.ts&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=MetaMask#diff-YXBwL2xpYi9wcG9tL3Bwb20tdXRpbC50cw==) | 66.66% | [2 Missing :warning: ](https://app.codecov.io/gh/MetaMask/metamask-mobile/pull/14993?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=MetaMask) |
| [app/core/RPCMethods/createAsyncWalletMiddleware.ts](https://app.codecov.io/gh/MetaMask/metamask-mobile/pull/14993?src=pr&el=tree&filepath=app%2Fcore%2FRPCMethods%2FcreateAsyncWalletMiddleware.ts&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=MetaMask#diff-YXBwL2NvcmUvUlBDTWV0aG9kcy9jcmVhdGVBc3luY1dhbGxldE1pZGRsZXdhcmUudHM=) | 96.55% | [0 Missing and 1 partial :warning: ](https://app.codecov.io/gh/MetaMask/metamask-mobile/pull/14993?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=MetaMask) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main   #14993      +/-   ##
==========================================
- Coverage   67.84%   67.81%   -0.04%     
==========================================
  Files        2343     2353      +10     
  Lines       50476    50780     +304     
  Branches     7452     7503      +51     
==========================================
+ Hits        34246    34435     +189     
- Misses      14062    14153      +91     
- Partials     2168     2192      +24     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/MetaMask/metamask-mobile/pull/14993?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=MetaMask).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=MetaMask).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
author_association: NONE
user: login: sonarqubecloud[bot]
created_at: 2025-05-05T15:26:49Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=metamask-mobile&pullRequest=14993) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=metamask-mobile&pullRequest=14993&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=metamask-mobile&pullRequest=14993&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=metamask-mobile&pullRequest=14993&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [93.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=metamask-mobile&pullRequest=14993&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=metamask-mobile&pullRequest=14993&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=metamask-mobile&pullRequest=14993)


author_association: NONE