From afbe13166738346d9c7cdcc91c41749b7cf615aa Mon Sep 17 00:00:00 2001 From: Sam Walker Date: Tue, 3 Mar 2026 01:50:24 -0500 Subject: [PATCH 01/13] fix: batch transactions for delegated accounts to avoid in-flight tx limit Ethereum nodes only allow 1 pending transaction for EIP-7702 delegated accounts. When a swap requires token approval, the approve and swap transactions are submitted separately, causing the second to be rejected. This fix detects delegated accounts via isAtomicBatchSupported and routes them through the batched transaction path, combining approve + swap into a single transaction. Changes: - Expose isAtomicBatchSupported as a TransactionController messenger action - Add delegation check in BridgeStatusController before tx submission - Allow 7702 batching for delegated accounts without requiring gas sponsorship --- .../src/bridge-status-controller.ts | 28 ++++++++++++++++++- .../bridge-status-controller/src/types.ts | 2 ++ .../src/utils/transaction.ts | 9 ++++-- .../src/TransactionController.ts | 14 ++++++++++ packages/transaction-controller/src/index.ts | 1 + 5 files changed, 50 insertions(+), 4 deletions(-) diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index 0b0efe710d0..c5aad539f01 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -1481,7 +1481,32 @@ export class BridgeStatusController extends StaticIntervalPollingController => { + try { + const atomicBatchSupport = await this.messenger.call( + 'TransactionController:isAtomicBatchSupported', + { + address: quoteResponse.trade.from as Hex, + chainIds: [hexChainId], + }, + ); + return atomicBatchSupport.some( + (entry) => entry.isSupported && entry.delegationAddress, + ); + } catch { + return false; + } + })(); + + if ( + isStxEnabledOnClient || + quoteResponse.quote.gasIncluded7702 || + isDelegatedAccount + ) { const { tradeMeta, approvalMeta } = await this.#handleEvmTransactionBatch({ isBridgeTx, @@ -1493,6 +1518,7 @@ export class BridgeStatusController extends StaticIntervalPollingController | BridgeControllerAction | GetGasFeeState diff --git a/packages/bridge-status-controller/src/utils/transaction.ts b/packages/bridge-status-controller/src/utils/transaction.ts index 5341222a5f6..3f1ead788a5 100644 --- a/packages/bridge-status-controller/src/utils/transaction.ts +++ b/packages/bridge-status-controller/src/utils/transaction.ts @@ -343,6 +343,7 @@ export const getAddTransactionBatchParams = async ({ toTokenAmount, }, requireApproval = false, + isDelegatedAccount = false, estimateGasFeeFn, }: { messenger: BridgeStatusControllerMessenger; @@ -354,6 +355,7 @@ export const getAddTransactionBatchParams = async ({ approval?: TxData; resetApproval?: TxData; requireApproval?: boolean; + isDelegatedAccount?: boolean; }) => { const isGasless = gasIncluded || gasIncluded7702; const selectedAccount = messenger.call( @@ -371,9 +373,10 @@ export const getAddTransactionBatchParams = async ({ hexChainId, ); - // When an active quote has gasIncluded7702 set to true, - // enable 7702 gasless txs for smart accounts - const disable7702 = gasIncluded7702 !== true; + // Enable 7702 batching when the quote includes gasless 7702 support, + // or when the account is already delegated (to avoid the in-flight + // transaction limit for delegated accounts) + const disable7702 = gasIncluded7702 !== true && !isDelegatedAccount; const transactions: TransactionBatchSingleRequest[] = []; if (resetApproval) { const gasFees = await calculateGasFees( diff --git a/packages/transaction-controller/src/TransactionController.ts b/packages/transaction-controller/src/TransactionController.ts index cc6e47cb5e2..d60bc7ac1c9 100644 --- a/packages/transaction-controller/src/TransactionController.ts +++ b/packages/transaction-controller/src/TransactionController.ts @@ -407,6 +407,14 @@ export type TransactionControllerGetGasFeeTokensAction = { handler: (request: GetGasFeeTokensRequest) => Promise; }; +/** + * Represents the `TransactionController:isAtomicBatchSupported` action. + */ +export type TransactionControllerIsAtomicBatchSupportedAction = { + type: `${typeof controllerName}:isAtomicBatchSupported`; + handler: TransactionController['isAtomicBatchSupported']; +}; + /** * The internal actions available to the TransactionController. */ @@ -417,6 +425,7 @@ export type TransactionControllerActions = | TransactionControllerEstimateGasAction | TransactionControllerEstimateGasBatchAction | TransactionControllerGetGasFeeTokensAction + | TransactionControllerIsAtomicBatchSupportedAction | TransactionControllerGetNonceLockAction | TransactionControllerGetStateAction | TransactionControllerGetTransactionsAction @@ -4657,6 +4666,11 @@ export class TransactionController extends BaseController< `${controllerName}:updateTransaction`, this.updateTransaction.bind(this), ); + + this.messenger.registerActionHandler( + `${controllerName}:isAtomicBatchSupported`, + this.isAtomicBatchSupported.bind(this), + ); } #deleteTransaction(transactionId: string): void { diff --git a/packages/transaction-controller/src/index.ts b/packages/transaction-controller/src/index.ts index fe1dc7ad587..3c5a655d40f 100644 --- a/packages/transaction-controller/src/index.ts +++ b/packages/transaction-controller/src/index.ts @@ -13,6 +13,7 @@ export type { TransactionControllerGetGasFeeTokensAction, TransactionControllerGetNonceLockAction, TransactionControllerGetStateAction, + TransactionControllerIsAtomicBatchSupportedAction, TransactionControllerGetTransactionsAction, TransactionControllerIncomingTransactionsReceivedEvent, TransactionControllerPostTransactionBalanceUpdatedEvent, From d1a7a5de1a99026515f2754d140ddfa25bddfbe6 Mon Sep 17 00:00:00 2001 From: Sam Walker Date: Mon, 9 Mar 2026 13:37:26 -0400 Subject: [PATCH 02/13] fix: handle bridge transaction types in 7702 batch matching --- packages/bridge-status-controller/src/utils/transaction.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/bridge-status-controller/src/utils/transaction.ts b/packages/bridge-status-controller/src/utils/transaction.ts index 3f1ead788a5..5ee8c51921b 100644 --- a/packages/bridge-status-controller/src/utils/transaction.ts +++ b/packages/bridge-status-controller/src/utils/transaction.ts @@ -485,14 +485,16 @@ export const findAndUpdateTransactionsInBatch = ({ // For 7702 transactions, we need to match based on transaction type // since the data field might be different (batch execute call) if ( - txType === TransactionType.swap && + (txType === TransactionType.swap || + txType === TransactionType.bridge) && tx.type === TransactionType.batch ) { return true; } // Also check if it's an approval transaction for 7702 if ( - txType === TransactionType.swapApproval && + (txType === TransactionType.swapApproval || + txType === TransactionType.bridgeApproval) && tx.txParams.data === txData ) { return true; From 11554516d81d3709be035032f795b9918dcf4910 Mon Sep 17 00:00:00 2001 From: Sam Walker Date: Mon, 9 Mar 2026 16:29:13 -0400 Subject: [PATCH 03/13] fix: include delegated accounts in batch history tracking --- .../src/bridge-status-controller.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index c5aad539f01..9bb165edc01 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -1465,6 +1465,7 @@ export class BridgeStatusController extends StaticIntervalPollingController => { + isDelegatedAccount = await (async (): Promise => { try { const atomicBatchSupport = await this.messenger.call( 'TransactionController:isAtomicBatchSupported', @@ -1590,7 +1591,8 @@ export class BridgeStatusController extends StaticIntervalPollingController Date: Mon, 9 Mar 2026 16:34:08 -0400 Subject: [PATCH 04/13] test: update 7702 bridge matching test to expect correct behavior --- .../src/utils/transaction.test.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/bridge-status-controller/src/utils/transaction.test.ts b/packages/bridge-status-controller/src/utils/transaction.test.ts index edea3e636db..f9d8e3f312c 100644 --- a/packages/bridge-status-controller/src/utils/transaction.test.ts +++ b/packages/bridge-status-controller/src/utils/transaction.test.ts @@ -2249,16 +2249,22 @@ describe('Bridge Status Controller Transaction Utils', () => { [TransactionType.bridge]: '0xbridgeData', }; - // Test with bridge transaction (not swap) - findAndUpdateTransactionsInBatch({ + // Test with bridge transaction — should match batch type for 7702 + const result = findAndUpdateTransactionsInBatch({ messenger: mockMessagingSystem, batchId, txDataByType, updateTransactionFn: mockUpdateTransactionFn, }); - // Should not match since it's looking for bridge but finds batch type - expect(mockUpdateTransactionFn).not.toHaveBeenCalled(); + // Should match since 7702 bridge transactions use batch type + expect(mockUpdateTransactionFn).toHaveBeenCalledWith( + expect.objectContaining({ id: 'tx1', type: TransactionType.bridge }), + 'Update tx type to bridge', + ); + expect(result.tradeMeta).toStrictEqual( + expect.objectContaining({ id: 'tx1', type: TransactionType.bridge }), + ); }); }); From 489fd5530a3dcd45da11c7d55e6f82bb2b270e21 Mon Sep 17 00:00:00 2001 From: Sam Walker Date: Mon, 9 Mar 2026 16:46:19 -0400 Subject: [PATCH 05/13] fix: skip undefined tx types in 7702 batch matching to prevent swap/bridge mistyping --- packages/bridge-status-controller/src/utils/transaction.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/bridge-status-controller/src/utils/transaction.ts b/packages/bridge-status-controller/src/utils/transaction.ts index 5ee8c51921b..fb3de9aa477 100644 --- a/packages/bridge-status-controller/src/utils/transaction.ts +++ b/packages/bridge-status-controller/src/utils/transaction.ts @@ -468,6 +468,11 @@ export const findAndUpdateTransactionsInBatch = ({ // This is a workaround to update the tx type after the tx is signed // TODO: remove this once the tx type for batch txs is preserved in the tx controller Object.entries(txDataByType).forEach(([txType, txData]) => { + // Skip types not present in the batch (e.g. swap entry is undefined for bridge txs) + if (txData === undefined) { + return; + } + // Find transaction by batchId and either matching data or delegation characteristics const txMeta = txs.find((tx) => { if (tx.batchId !== batchId) { From 78691b49f578f816fcf105f9604ed7b2e50e53fb Mon Sep 17 00:00:00 2001 From: Sam Walker Date: Mon, 9 Mar 2026 17:23:44 -0400 Subject: [PATCH 06/13] fix: resolve TypeScript errors in delegation check --- .../src/bridge-status-controller.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index 803c453ea57..189ba21fdb6 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -33,6 +33,7 @@ import { TransactionType, } from '@metamask/transaction-controller'; import type { + IsAtomicBatchSupportedResultEntry, TransactionController, TransactionMeta, TransactionParams, @@ -1352,6 +1353,7 @@ export class BridgeStatusController extends StaticIntervalPollingController; let approvalTxId: string | undefined; + let isDelegatedAccount = false; const startTime = Date.now(); const isBridgeTx = isCrossChain( @@ -1455,7 +1457,6 @@ export class BridgeStatusController extends StaticIntervalPollingController entry.isSupported && entry.delegationAddress, + (entry: IsAtomicBatchSupportedResultEntry) => + entry.isSupported && entry.delegationAddress, ); } catch { return false; From 79d5b05ecd982f2d1e2ec5a704a048e83e157e45 Mon Sep 17 00:00:00 2001 From: Sam Walker Date: Mon, 9 Mar 2026 17:49:33 -0400 Subject: [PATCH 07/13] fix: separate gas field logic from 7702 batching for delegated accounts --- .../src/utils/gas.test.ts | 8 +-- .../bridge-status-controller/src/utils/gas.ts | 4 +- .../src/utils/transaction.test.ts | 69 ++++++++++++++++++- .../src/utils/transaction.ts | 20 +++--- 4 files changed, 84 insertions(+), 17 deletions(-) diff --git a/packages/bridge-status-controller/src/utils/gas.test.ts b/packages/bridge-status-controller/src/utils/gas.test.ts index dccfa7fd050..b955d96e768 100644 --- a/packages/bridge-status-controller/src/utils/gas.test.ts +++ b/packages/bridge-status-controller/src/utils/gas.test.ts @@ -120,9 +120,9 @@ describe('gas calculation utils', () => { value: '0x1', }; - it('should return empty object if 7702 is enabled (disable7702 is false)', async () => { + it('should return empty object if gas fields should be skipped (skipGasFields is true)', async () => { const result = await calculateGasFees( - false, + true, null as never, jest.fn(), mockTrade, @@ -134,7 +134,7 @@ describe('gas calculation utils', () => { it('should txFee when provided', async () => { const result = await calculateGasFees( - true, + false, null as never, jest.fn(), mockTrade, @@ -178,7 +178,7 @@ describe('gas calculation utils', () => { }, }); const result = await calculateGasFees( - true, + false, { call: mockCall } as never, mockEstimateGasFeeFn, { ...mockTrade, gasLimit }, diff --git a/packages/bridge-status-controller/src/utils/gas.ts b/packages/bridge-status-controller/src/utils/gas.ts index 3f0e2aa2ab5..d4286205536 100644 --- a/packages/bridge-status-controller/src/utils/gas.ts +++ b/packages/bridge-status-controller/src/utils/gas.ts @@ -63,7 +63,7 @@ export const getTxGasEstimates = ({ }; export const calculateGasFees = async ( - disable7702: boolean, + skipGasFields: boolean, messenger: BridgeStatusControllerMessenger, estimateGasFeeFn: typeof TransactionController.prototype.estimateGasFee, { chainId: _, gasLimit, ...trade }: TxData, @@ -71,7 +71,7 @@ export const calculateGasFees = async ( chainId: Hex, txFee?: { maxFeePerGas: string; maxPriorityFeePerGas: string }, ) => { - if (!disable7702) { + if (skipGasFields) { return {}; } if (txFee) { diff --git a/packages/bridge-status-controller/src/utils/transaction.test.ts b/packages/bridge-status-controller/src/utils/transaction.test.ts index f9d8e3f312c..ab4b2d0fa89 100644 --- a/packages/bridge-status-controller/src/utils/transaction.test.ts +++ b/packages/bridge-status-controller/src/utils/transaction.test.ts @@ -1721,7 +1721,7 @@ describe('Bridge Status Controller Transaction Utils', () => { }); describe('toBatchTxParams', () => { - it('should return params without gas if disable7702 is false', () => { + it('should return params without gas if skipGasFields is true', () => { const mockTrade = { chainId: 1, gasLimit: 1231, @@ -1730,7 +1730,7 @@ describe('Bridge Status Controller Transaction Utils', () => { from: '0x1', value: '0x1', }; - const result = toBatchTxParams(false, mockTrade, {}); + const result = toBatchTxParams(true, mockTrade, {}); expect(result).toStrictEqual({ data: '0x1', from: '0x1', @@ -1999,6 +1999,71 @@ describe('Bridge Status Controller Transaction Utils', () => { expect(result.isGasFeeIncluded).toBe(false); expect(result.disable7702).toBe(true); }); + + it('should enable 7702 but include gas fields when isDelegatedAccount is true and gasIncluded7702 is false', async () => { + const mockQuoteResponse = createMockQuoteResponse({ + gasIncluded7702: false, + }); + + const mockEstimateGasFeeFn = jest.fn().mockResolvedValue({ + estimates: { + medium: { + maxFeePerGas: '0xabc', + maxPriorityFeePerGas: '0xdef', + }, + }, + }); + + const result = await getAddTransactionBatchParams({ + quoteResponse: mockQuoteResponse, + messenger: mockMessagingSystem, + isBridgeTx: true, + trade: mockQuoteResponse.trade, + isDelegatedAccount: true, + estimateGasFeeFn: mockEstimateGasFeeFn, + }); + + // 7702 should be enabled for delegated accounts + expect(result.disable7702).toBe(false); + // Gas is NOT sponsored + expect(result.isGasFeeIncluded).toBe(false); + // Gas estimation should have been called (not skipped) + expect(mockEstimateGasFeeFn).toHaveBeenCalled(); + // Transaction params should include gas fields + expect(result.transactions).toHaveLength(1); + expect(result.transactions[0].params).toHaveProperty('gas'); + expect(result.transactions[0].params).toHaveProperty('maxFeePerGas'); + expect(result.transactions[0].params).toHaveProperty('maxPriorityFeePerGas'); + }); + + it('should enable 7702 and omit gas fields when isDelegatedAccount is true and gasIncluded7702 is true', async () => { + const mockQuoteResponse = createMockQuoteResponse({ + gasIncluded7702: true, + }); + + const mockEstimateGasFeeFn = jest.fn().mockResolvedValue({}); + + const result = await getAddTransactionBatchParams({ + quoteResponse: mockQuoteResponse, + messenger: mockMessagingSystem, + isBridgeTx: true, + trade: mockQuoteResponse.trade, + isDelegatedAccount: true, + estimateGasFeeFn: mockEstimateGasFeeFn, + }); + + // 7702 should be enabled + expect(result.disable7702).toBe(false); + // Gas IS sponsored + expect(result.isGasFeeIncluded).toBe(true); + // Gas estimation should NOT have been called (skipped because gas is sponsored) + expect(mockEstimateGasFeeFn).not.toHaveBeenCalled(); + // Transaction params should NOT include gas fields + expect(result.transactions).toHaveLength(1); + expect(result.transactions[0].params).not.toHaveProperty('gas'); + expect(result.transactions[0].params).not.toHaveProperty('maxFeePerGas'); + expect(result.transactions[0].params).not.toHaveProperty('maxPriorityFeePerGas'); + }); }); describe('findAndUpdateTransactionsInBatch', () => { diff --git a/packages/bridge-status-controller/src/utils/transaction.ts b/packages/bridge-status-controller/src/utils/transaction.ts index fb3de9aa477..930cf3940af 100644 --- a/packages/bridge-status-controller/src/utils/transaction.ts +++ b/packages/bridge-status-controller/src/utils/transaction.ts @@ -300,7 +300,7 @@ export const rekeyHistoryItemInState = ( }; export const toBatchTxParams = ( - disable7702: boolean, + skipGasFields: boolean, { chainId, gasLimit, ...trade }: TxData, { maxFeePerGas, @@ -314,7 +314,7 @@ export const toBatchTxParams = ( to: trade.to as `0x${string}`, value: trade.value as `0x${string}`, }; - if (!disable7702) { + if (skipGasFields) { return params; } @@ -373,14 +373,16 @@ export const getAddTransactionBatchParams = async ({ hexChainId, ); + // Gas fields should be omitted only when gas is sponsored via 7702 + const skipGasFields = gasIncluded7702 === true; // Enable 7702 batching when the quote includes gasless 7702 support, // or when the account is already delegated (to avoid the in-flight // transaction limit for delegated accounts) - const disable7702 = gasIncluded7702 !== true && !isDelegatedAccount; + const disable7702 = !skipGasFields && !isDelegatedAccount; const transactions: TransactionBatchSingleRequest[] = []; if (resetApproval) { const gasFees = await calculateGasFees( - disable7702, + skipGasFields, messenger, estimateGasFeeFn, resetApproval, @@ -392,12 +394,12 @@ export const getAddTransactionBatchParams = async ({ type: isBridgeTx ? TransactionType.bridgeApproval : TransactionType.swapApproval, - params: toBatchTxParams(disable7702, resetApproval, gasFees), + params: toBatchTxParams(skipGasFields, resetApproval, gasFees), }); } if (approval) { const gasFees = await calculateGasFees( - disable7702, + skipGasFields, messenger, estimateGasFeeFn, approval, @@ -409,11 +411,11 @@ export const getAddTransactionBatchParams = async ({ type: isBridgeTx ? TransactionType.bridgeApproval : TransactionType.swapApproval, - params: toBatchTxParams(disable7702, approval, gasFees), + params: toBatchTxParams(skipGasFields, approval, gasFees), }); } const gasFees = await calculateGasFees( - disable7702, + skipGasFields, messenger, estimateGasFeeFn, trade, @@ -423,7 +425,7 @@ export const getAddTransactionBatchParams = async ({ ); transactions.push({ type: isBridgeTx ? TransactionType.bridge : TransactionType.swap, - params: toBatchTxParams(disable7702, trade, gasFees), + params: toBatchTxParams(skipGasFields, trade, gasFees), assetsFiatValues: { sending: sentAmount?.valueInCurrency?.toString(), receiving: toTokenAmount?.valueInCurrency?.toString(), From 0ecd57498077fa4873e8768b1845adaca64ec5b3 Mon Sep 17 00:00:00 2001 From: Sam Walker Date: Mon, 9 Mar 2026 17:52:34 -0400 Subject: [PATCH 08/13] docs: add changelog entries for both packages --- packages/bridge-status-controller/CHANGELOG.md | 7 +++++++ packages/transaction-controller/CHANGELOG.md | 5 +++++ 2 files changed, 12 insertions(+) diff --git a/packages/bridge-status-controller/CHANGELOG.md b/packages/bridge-status-controller/CHANGELOG.md index 83992a951c2..716f67a9bad 100644 --- a/packages/bridge-status-controller/CHANGELOG.md +++ b/packages/bridge-status-controller/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Delegated accounts (EIP-7702) now use batched transactions to avoid in-flight transaction limit ([#8125](https://github.com/MetaMask/core/pull/8125)) +- Bridge transaction types now properly matched in 7702 batch path ([#8125](https://github.com/MetaMask/core/pull/8125)) +- Delegated account batch transactions now recorded in bridge status history ([#8125](https://github.com/MetaMask/core/pull/8125)) +- Gas fields now included for delegated account transactions that are not gas-sponsored ([#8125](https://github.com/MetaMask/core/pull/8125)) + ## [68.0.1] ### Changed diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index ede1871582c..f56e041cdd7 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Expose `isAtomicBatchSupported` as a messenger action ([#8125](https://github.com/MetaMask/core/pull/8125)) +- Export `TransactionControllerIsAtomicBatchSupportedAction` type ([#8125](https://github.com/MetaMask/core/pull/8125)) + ## [62.21.0] ### Added From 565248f4cdb6a48cbd0e7171a3de94221031d629 Mon Sep 17 00:00:00 2001 From: Sam Walker Date: Mon, 9 Mar 2026 17:58:58 -0400 Subject: [PATCH 09/13] style: fix prettier formatting in transaction tests --- .../src/utils/transaction.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/bridge-status-controller/src/utils/transaction.test.ts b/packages/bridge-status-controller/src/utils/transaction.test.ts index ab4b2d0fa89..29a04a49298 100644 --- a/packages/bridge-status-controller/src/utils/transaction.test.ts +++ b/packages/bridge-status-controller/src/utils/transaction.test.ts @@ -2033,7 +2033,9 @@ describe('Bridge Status Controller Transaction Utils', () => { expect(result.transactions).toHaveLength(1); expect(result.transactions[0].params).toHaveProperty('gas'); expect(result.transactions[0].params).toHaveProperty('maxFeePerGas'); - expect(result.transactions[0].params).toHaveProperty('maxPriorityFeePerGas'); + expect(result.transactions[0].params).toHaveProperty( + 'maxPriorityFeePerGas', + ); }); it('should enable 7702 and omit gas fields when isDelegatedAccount is true and gasIncluded7702 is true', async () => { @@ -2062,7 +2064,9 @@ describe('Bridge Status Controller Transaction Utils', () => { expect(result.transactions).toHaveLength(1); expect(result.transactions[0].params).not.toHaveProperty('gas'); expect(result.transactions[0].params).not.toHaveProperty('maxFeePerGas'); - expect(result.transactions[0].params).not.toHaveProperty('maxPriorityFeePerGas'); + expect(result.transactions[0].params).not.toHaveProperty( + 'maxPriorityFeePerGas', + ); }); }); From cc678726717a891046cb0a0668df59065ef10c84 Mon Sep 17 00:00:00 2001 From: Sam Walker Date: Mon, 9 Mar 2026 18:39:05 -0400 Subject: [PATCH 10/13] test: add isAtomicBatchSupported mock to submitTx tests The new TransactionController:isAtomicBatchSupported messenger call in submitTx shifts mock queues for all EVM bridge and swap tests. Add the mock to setupEventTrackingMocks helpers and manual setups, update call count assertions, refresh snapshots, and add a test covering the catch fallback path. --- .../bridge-status-controller.test.ts.snap | 126 ++++++++++++++++++ .../src/bridge-status-controller.test.ts | 42 +++++- 2 files changed, 162 insertions(+), 6 deletions(-) diff --git a/packages/bridge-status-controller/src/__snapshots__/bridge-status-controller.test.ts.snap b/packages/bridge-status-controller/src/__snapshots__/bridge-status-controller.test.ts.snap index 29bbc53bad7..d1050e81fe9 100644 --- a/packages/bridge-status-controller/src/__snapshots__/bridge-status-controller.test.ts.snap +++ b/packages/bridge-status-controller/src/__snapshots__/bridge-status-controller.test.ts.snap @@ -579,6 +579,15 @@ exports[`BridgeStatusController submitTx: EVM bridge should call handleMobileHar "usd_quoted_return": 0, }, ], + [ + "TransactionController:isAtomicBatchSupported", + { + "address": "0xaccount1", + "chainIds": [ + "0xa4b1", + ], + }, + ], [ "AccountsController:getAccountByAddress", "0xaccount1", @@ -818,6 +827,15 @@ exports[`BridgeStatusController submitTx: EVM bridge should delay after submitti "usd_quoted_return": 0, }, ], + [ + "TransactionController:isAtomicBatchSupported", + { + "address": "0xaccount1", + "chainIds": [ + "0x2105", + ], + }, + ], [ "AccountsController:getAccountByAddress", "0xaccount1", @@ -1057,6 +1075,15 @@ exports[`BridgeStatusController submitTx: EVM bridge should delay after submitti "usd_quoted_return": 0, }, ], + [ + "TransactionController:isAtomicBatchSupported", + { + "address": "0xaccount1", + "chainIds": [ + "0xe708", + ], + }, + ], [ "AccountsController:getAccountByAddress", "0xaccount1", @@ -1363,6 +1390,15 @@ exports[`BridgeStatusController submitTx: EVM bridge should handle smart transac "usd_quoted_return": 0, }, ], + [ + "TransactionController:isAtomicBatchSupported", + { + "address": "0xaccount1", + "chainIds": [ + "0xa4b1", + ], + }, + ], [ "AccountsController:getAccountByAddress", "0xaccount1", @@ -1563,6 +1599,15 @@ exports[`BridgeStatusController submitTx: EVM bridge should not call handleMobil "usd_quoted_return": 0, }, ], + [ + "TransactionController:isAtomicBatchSupported", + { + "address": "0xaccount1", + "chainIds": [ + "0xa4b1", + ], + }, + ], [ "AccountsController:getAccountByAddress", "0xaccount1", @@ -1802,6 +1847,15 @@ exports[`BridgeStatusController submitTx: EVM bridge should not call handleMobil "usd_quoted_return": 0, }, ], + [ + "TransactionController:isAtomicBatchSupported", + { + "address": "0xaccount1", + "chainIds": [ + "0xa4b1", + ], + }, + ], [ "AccountsController:getAccountByAddress", "0xaccount1", @@ -2156,6 +2210,15 @@ exports[`BridgeStatusController submitTx: EVM bridge should reset USDT allowance "usd_quoted_return": 0, }, ], + [ + "TransactionController:isAtomicBatchSupported", + { + "address": "0xaccount1", + "chainIds": [ + "0xa4b1", + ], + }, + ], [ "AccountsController:getAccountByAddress", "0xaccount1", @@ -2429,6 +2492,15 @@ exports[`BridgeStatusController submitTx: EVM bridge should successfully submit "usd_quoted_return": 0, }, ], + [ + "TransactionController:isAtomicBatchSupported", + { + "address": "0xaccount1", + "chainIds": [ + "0xa4b1", + ], + }, + ], [ "AccountsController:getAccountByAddress", "0xaccount1", @@ -2688,6 +2760,15 @@ exports[`BridgeStatusController submitTx: EVM bridge should successfully submit "usd_quoted_return": 0, }, ], + [ + "TransactionController:isAtomicBatchSupported", + { + "address": "0xaccount1", + "chainIds": [ + "0xa4b1", + ], + }, + ], [ "AccountsController:getAccountByAddress", "0xaccount1", @@ -2765,6 +2846,15 @@ exports[`BridgeStatusController submitTx: EVM bridge should throw an error if ap "usd_quoted_return": 0, }, ], + [ + "TransactionController:isAtomicBatchSupported", + { + "address": "0xaccount1", + "chainIds": [ + "0xa4b1", + ], + }, + ], [ "AccountsController:getAccountByAddress", "0xaccount1", @@ -2839,6 +2929,15 @@ exports[`BridgeStatusController submitTx: EVM bridge should throw an error if ap "usd_quoted_return": 0, }, ], + [ + "TransactionController:isAtomicBatchSupported", + { + "address": "0xaccount1", + "chainIds": [ + "0xa4b1", + ], + }, + ], [ "AccountsController:getAccountByAddress", "0xaccount1", @@ -3132,6 +3231,15 @@ exports[`BridgeStatusController submitTx: EVM swap should handle smart transacti "usd_quoted_return": 0, }, ], + [ + "TransactionController:isAtomicBatchSupported", + { + "address": "0xaccount1", + "chainIds": [ + "0xa4b1", + ], + }, + ], [ "AccountsController:getAccountByAddress", "0xaccount1", @@ -3201,6 +3309,15 @@ exports[`BridgeStatusController submitTx: EVM swap should successfully submit an "AccountsController:getAccountByAddress", "0xaccount1", ], + [ + "TransactionController:isAtomicBatchSupported", + { + "address": "0xaccount1", + "chainIds": [ + "0xa4b1", + ], + }, + ], [ "AccountsController:getAccountByAddress", "0xaccount1", @@ -3411,6 +3528,15 @@ exports[`BridgeStatusController submitTx: EVM swap should successfully submit an "usd_quoted_return": 0, }, ], + [ + "TransactionController:isAtomicBatchSupported", + { + "address": "0xaccount1", + "chainIds": [ + "0xa4b1", + ], + }, + ], [ "AccountsController:getAccountByAddress", "0xaccount1", diff --git a/packages/bridge-status-controller/src/bridge-status-controller.test.ts b/packages/bridge-status-controller/src/bridge-status-controller.test.ts index 5d4469f177f..2650bb2c502 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.test.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.test.ts @@ -2538,6 +2538,7 @@ describe('BridgeStatusController', () => { const setupEventTrackingMocks = (mockCall: jest.Mock) => { mockCall.mockReturnValueOnce(mockSelectedAccount); mockCall.mockImplementationOnce(jest.fn()); // track event + mockCall.mockReturnValueOnce([]); // isAtomicBatchSupported }; const setupApprovalMocks = (mockCall: jest.Mock) => { @@ -2854,7 +2855,7 @@ describe('BridgeStatusController', () => { expect(estimateGasFeeFn).toHaveBeenCalledTimes(3); expect(addTransactionFn).not.toHaveBeenCalled(); expect(addTransactionBatchFn).toHaveBeenCalledTimes(1); - expect(mockMessengerCall).toHaveBeenCalledTimes(9); + expect(mockMessengerCall).toHaveBeenCalledTimes(10); }); it('should throw an error if approval tx fails', async () => { @@ -3021,6 +3022,7 @@ describe('BridgeStatusController', () => { }, }); mockMessengerCall.mockImplementationOnce(jest.fn()); // track event + mockMessengerCall.mockReturnValueOnce([]); // isAtomicBatchSupported setupApprovalMocks(mockMessengerCall); setupBridgeMocks(mockMessengerCall); @@ -3103,6 +3105,7 @@ describe('BridgeStatusController', () => { }, }); mockMessengerCall.mockImplementationOnce(jest.fn()); // track event + mockMessengerCall.mockReturnValueOnce([]); // isAtomicBatchSupported setupApprovalMocks(mockMessengerCall); setupBridgeMocks(mockMessengerCall); @@ -3364,6 +3367,7 @@ describe('BridgeStatusController', () => { const setupEventTrackingMocks = (mockCall: jest.Mock) => { mockCall.mockReturnValueOnce(mockSelectedAccount); mockCall.mockImplementationOnce(jest.fn()); // track event + mockCall.mockReturnValueOnce([]); // isAtomicBatchSupported }; const setupApprovalMocks = () => { @@ -3422,11 +3426,12 @@ describe('BridgeStatusController', () => { const { approvalTxId } = controller.state.txHistory[result.id]; expect(approvalTxId).toBe('test-approval-tx-id'); expect(addTransactionFn).toHaveBeenCalledTimes(2); - expect(mockMessengerCall).toHaveBeenCalledTimes(11); + expect(mockMessengerCall).toHaveBeenCalledTimes(12); }); it('should successfully submit an EVM swap transaction with featureId', async () => { mockMessengerCall.mockReturnValueOnce(mockSelectedAccount); + mockMessengerCall.mockReturnValueOnce([]); // isAtomicBatchSupported setupApprovalMocks(); setupBridgeMocks(); @@ -3450,7 +3455,7 @@ describe('BridgeStatusController', () => { FeatureId.PERPS, ); expect(addTransactionFn).toHaveBeenCalledTimes(2); - expect(mockMessengerCall).toHaveBeenCalledTimes(10); + expect(mockMessengerCall).toHaveBeenCalledTimes(11); expect(mockMessengerCall.mock.calls).toMatchSnapshot(); }); @@ -3501,7 +3506,7 @@ describe('BridgeStatusController', () => { expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); expect(addTransactionFn).not.toHaveBeenCalled(); expect(addTransactionBatchFn).toHaveBeenCalledTimes(1); - expect(mockMessengerCall).toHaveBeenCalledTimes(6); + expect(mockMessengerCall).toHaveBeenCalledTimes(7); }); it('should successfully submit an EVM swap transaction with no approval', async () => { @@ -3729,7 +3734,7 @@ describe('BridgeStatusController', () => { expect(estimateGasFeeFn).not.toHaveBeenCalled(); expect(addTransactionFn).not.toHaveBeenCalled(); expect(addTransactionBatchFn).not.toHaveBeenCalled(); - expect(mockMessengerCall).toHaveBeenCalledTimes(4); + expect(mockMessengerCall).toHaveBeenCalledTimes(5); }); it('should throw error if batched tx is not found', async () => { @@ -3768,7 +3773,32 @@ describe('BridgeStatusController', () => { expect(estimateGasFeeFn).toHaveBeenCalledTimes(2); expect(addTransactionFn).not.toHaveBeenCalled(); expect(addTransactionBatchFn).toHaveBeenCalledTimes(1); - expect(mockMessengerCall).toHaveBeenCalledTimes(8); + expect(mockMessengerCall).toHaveBeenCalledTimes(9); + }); + + it('should gracefully handle isAtomicBatchSupported failure', async () => { + // Manually set up mocks without setupEventTrackingMocks + // to control the isAtomicBatchSupported mock + mockMessengerCall.mockReturnValueOnce(mockSelectedAccount); // getAccountByAddress + mockMessengerCall.mockImplementationOnce(jest.fn()); // track event + mockMessengerCall.mockRejectedValueOnce( + new Error('isAtomicBatchSupported failed'), + ); // isAtomicBatchSupported throws + setupApprovalMocks(); + setupBridgeMocks(); + + const { controller } = getController(mockMessengerCall); + const result = await controller.submitTx( + (mockEvmQuoteResponse.trade as TxData).from, + mockEvmQuoteResponse, + false, // STX disabled - uses non-batch path + ); + controller.stopAllPolling(); + + // Should fall back to non-batch path when isAtomicBatchSupported throws + expect(addTransactionFn).toHaveBeenCalledTimes(2); + expect(addTransactionBatchFn).not.toHaveBeenCalled(); + expect(result).toBeDefined(); }); }); From 9bef1f403b748241e152561e0d5d15e8213c6786 Mon Sep 17 00:00:00 2001 From: Sam Walker Date: Tue, 10 Mar 2026 11:20:52 -0400 Subject: [PATCH 11/13] chore: fix prettier formatting on .cursor/worktrees.json --- .cursor/worktrees.json | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .cursor/worktrees.json diff --git a/.cursor/worktrees.json b/.cursor/worktrees.json new file mode 100644 index 00000000000..c81f1214f24 --- /dev/null +++ b/.cursor/worktrees.json @@ -0,0 +1,3 @@ +{ + "setup-worktree": ["npm install"] +} From 6dd065388a0b7ed75bd3c54c43c41d0f0616ec00 Mon Sep 17 00:00:00 2001 From: Sam Walker Date: Tue, 10 Mar 2026 11:41:28 -0400 Subject: [PATCH 12/13] chore: remove mistakenly committed .cursor/worktrees.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This file was incorrectly added in 9bef1f403 — it's a Cursor IDE config that doesn't belong in the repo. --- .cursor/worktrees.json | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 .cursor/worktrees.json diff --git a/.cursor/worktrees.json b/.cursor/worktrees.json deleted file mode 100644 index c81f1214f24..00000000000 --- a/.cursor/worktrees.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "setup-worktree": ["npm install"] -} From 1197f32218d636b0fd2121b301c0e8273c256bca Mon Sep 17 00:00:00 2001 From: Sam Walker Date: Tue, 10 Mar 2026 11:55:04 -0400 Subject: [PATCH 13/13] chore: mark new isAtomicBatchSupported action as breaking change in changelog --- packages/bridge-status-controller/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/bridge-status-controller/CHANGELOG.md b/packages/bridge-status-controller/CHANGELOG.md index 716f67a9bad..cd9e2a8fa7d 100644 --- a/packages/bridge-status-controller/CHANGELOG.md +++ b/packages/bridge-status-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **BREAKING:** `BridgeStatusControllerMessenger` must now allow `TransactionController:isAtomicBatchSupported` action ([#8125](https://github.com/MetaMask/core/pull/8125)) + ### Fixed - Delegated accounts (EIP-7702) now use batched transactions to avoid in-flight transaction limit ([#8125](https://github.com/MetaMask/core/pull/8125))