From 3a9edfc854d3dff9217da32f0bea1ca3006df66d Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Tue, 7 Apr 2026 14:33:32 +0200 Subject: [PATCH 1/4] Fix hardware wallet MMPay on EIP-7702 chains by gating 7702 paths on account keyring capability --- .../src/KeyringController.test.ts | 46 ++++++++++++++++ .../src/KeyringController.ts | 25 +++++++-- .../src/strategy/across/across-submit.test.ts | 36 +++++++++++++ .../src/strategy/across/across-submit.ts | 9 +++- .../src/strategy/relay/relay-quotes.test.ts | 23 ++++++++ .../src/strategy/relay/relay-quotes.ts | 6 +++ .../src/strategy/relay/relay-submit.test.ts | 52 +++++++++++++++++++ .../src/strategy/relay/relay-submit.ts | 16 ++++-- .../src/tests/messenger-mock.ts | 11 ++++ .../transaction-pay-controller/src/types.ts | 6 ++- 10 files changed, 219 insertions(+), 11 deletions(-) diff --git a/packages/keyring-controller/src/KeyringController.test.ts b/packages/keyring-controller/src/KeyringController.test.ts index 5ead0613292..dd597f989e3 100644 --- a/packages/keyring-controller/src/KeyringController.test.ts +++ b/packages/keyring-controller/src/KeyringController.test.ts @@ -1036,6 +1036,52 @@ describe('KeyringController', () => { }); }); + describe('accountSupports7702', () => { + it('should return true for HD keyring accounts', async () => { + await withController(async ({ controller, initialState }) => { + const account = initialState.keyrings[0].accounts[0]; + const result = await controller.accountSupports7702(account); + expect(result).toBe(true); + }); + }); + + it('should return true for simple keyring accounts', async () => { + await withController(async ({ controller }) => { + const importedAccountAddress = + await controller.importAccountWithStrategy( + AccountImportStrategy.privateKey, + [privateKey], + ); + + const result = + await controller.accountSupports7702(importedAccountAddress); + expect(result).toBe(true); + }); + }); + + it('should return false for non-HD and non-simple keyring accounts', async () => { + const address = '0x5AC6D462f054690a373FABF8CC28e161003aEB19'; + stubKeyringClassWithAccount(MockKeyring, address); + await withController( + { keyringBuilders: [keyringBuilderFactory(MockKeyring)] }, + async ({ controller }) => { + await controller.addNewKeyring(MockKeyring.type); + + const result = await controller.accountSupports7702(address); + expect(result).toBe(false); + }, + ); + }); + + it('should throw error if no keyring is found for the given account', async () => { + await withController(async ({ controller }) => { + await expect(controller.accountSupports7702('0x')).rejects.toThrow( + KeyringControllerErrorMessage.KeyringNotFound, + ); + }); + }); + }); + describe('getEncryptionPublicKey', () => { describe('when the keyring for the given address supports getEncryptionPublicKey', () => { it('should return the correct encryption public key', async () => { diff --git a/packages/keyring-controller/src/KeyringController.ts b/packages/keyring-controller/src/KeyringController.ts index bb6fa5d85b2..7f0a6944894 100644 --- a/packages/keyring-controller/src/KeyringController.ts +++ b/packages/keyring-controller/src/KeyringController.ts @@ -215,6 +215,11 @@ export type KeyringControllerRemoveAccountAction = { handler: KeyringController['removeAccount']; }; +export type KeyringControllerAccountSupports7702Action = { + type: `${typeof name}:accountSupports7702`; + handler: KeyringController['accountSupports7702']; +}; + export type KeyringControllerStateChangeEvent = { type: `${typeof name}:stateChange`; payload: [KeyringControllerState, Patch[]]; @@ -256,7 +261,8 @@ export type KeyringControllerActions = | KeyringControllerAddNewKeyringAction | KeyringControllerCreateNewVaultAndKeychainAction | KeyringControllerCreateNewVaultAndRestoreAction - | KeyringControllerRemoveAccountAction; + | KeyringControllerRemoveAccountAction + | KeyringControllerAccountSupports7702Action; export type KeyringControllerEvents = | KeyringControllerStateChangeEvent @@ -1921,10 +1927,14 @@ export class KeyringController< return keyring.type; } - /** - * Constructor helper for registering this controller's messeger - * actions. - */ + async accountSupports7702(account: string): Promise { + const keyringType = await this.getAccountKeyringType(account); + return ( + keyringType === (KeyringTypes.hd as string) || + keyringType === (KeyringTypes.simple as string) + ); + } + #registerMessageHandlers(): void { this.messenger.registerActionHandler( `${name}:signMessage`, @@ -2025,6 +2035,11 @@ export class KeyringController< `${name}:removeAccount`, this.removeAccount.bind(this), ); + + this.messenger.registerActionHandler( + `${name}:accountSupports7702`, + this.accountSupports7702.bind(this), + ); } /** diff --git a/packages/transaction-pay-controller/src/strategy/across/across-submit.test.ts b/packages/transaction-pay-controller/src/strategy/across/across-submit.test.ts index dbf8ab7cdc8..861ef5ee72b 100644 --- a/packages/transaction-pay-controller/src/strategy/across/across-submit.test.ts +++ b/packages/transaction-pay-controller/src/strategy/across/across-submit.test.ts @@ -101,6 +101,7 @@ describe('Across Submit', () => { const successfulFetchMock = jest.mocked(successfulFetch); const { + accountSupports7702Mock, addTransactionBatchMock, addTransactionMock, estimateGasMock, @@ -126,6 +127,7 @@ describe('Across Submit', () => { }, }); + accountSupports7702Mock.mockResolvedValue(true); estimateGasMock.mockResolvedValue({ gas: '0x5208', simulationFails: undefined, @@ -259,6 +261,40 @@ describe('Across Submit', () => { ); }); + it('disables 7702 batch when account does not support 7702', async () => { + accountSupports7702Mock.mockResolvedValue(false); + + const batchGasQuote = { + ...QUOTE_MOCK, + original: { + ...QUOTE_MOCK.original, + metamask: { + gasLimits: [ + { estimate: 21000, max: 21000 }, + { estimate: 22000, max: 22000 }, + ], + is7702: true, + }, + }, + } as unknown as TransactionPayQuote; + + await submitAcrossQuotes({ + messenger, + quotes: [batchGasQuote], + transaction: TRANSACTION_META_MOCK, + isSmartTransaction: jest.fn(), + }); + + expect(addTransactionBatchMock).toHaveBeenCalledWith( + expect.objectContaining({ + disable7702: true, + disableHook: false, + disableSequential: false, + gasLimit7702: undefined, + }), + ); + }); + it('submits a single transaction when no approvals', async () => { const noApprovalQuote = { ...QUOTE_MOCK, diff --git a/packages/transaction-pay-controller/src/strategy/across/across-submit.ts b/packages/transaction-pay-controller/src/strategy/across/across-submit.ts index b4b73039a52..51eb5c80e25 100644 --- a/packages/transaction-pay-controller/src/strategy/across/across-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/across/across-submit.ts @@ -48,6 +48,7 @@ export async function submitAcrossQuotes( log('Executing quotes', request); const { quotes, messenger, transaction } = request; + let transactionHash: Hex | undefined; for (const quote of quotes) { @@ -117,8 +118,14 @@ async function submitTransactions( messenger: TransactionPayControllerMessenger, ): Promise { const { swapTx } = quote.original.quote; - const { gasLimits: quoteGasLimits, is7702 } = quote.original.metamask; + const { gasLimits: quoteGasLimits, is7702: apiIs7702 } = + quote.original.metamask; const { from } = quote.request; + const accountSupports7702 = await messenger.call( + 'KeyringController:accountSupports7702', + from, + ); + const is7702 = apiIs7702 && accountSupports7702; const chainId = toHex(swapTx.chainId); const orderedTransactions = getAcrossOrderedTransactions({ quote: quote.original.quote, diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts index 85ca5bfa33e..e72d04e9122 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts @@ -180,6 +180,7 @@ describe('Relay Quotes Utils', () => { const getSlippageMock = jest.mocked(getSlippage); const { + accountSupports7702Mock, messenger, estimateGasMock, estimateGasBatchMock, @@ -215,6 +216,7 @@ describe('Relay Quotes Utils', () => { ...getDefaultRemoteFeatureFlagControllerState(), }); + accountSupports7702Mock.mockResolvedValue(true); isEIP7702ChainMock.mockReturnValue(true); isRelayExecuteEnabledMock.mockReturnValue(false); getGasBufferMock.mockReturnValue(1.0); @@ -320,6 +322,27 @@ describe('Relay Quotes Utils', () => { expect(body.originGasOverhead).toBeUndefined(); }); + it('omits originGasOverhead when account does not support 7702 even on EIP-7702 chain with relay execute enabled', async () => { + isRelayExecuteEnabledMock.mockReturnValue(true); + accountSupports7702Mock.mockResolvedValue(false); + + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as never); + + await getRelayQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + const body = JSON.parse( + successfulFetchMock.mock.calls[0][1]?.body as string, + ); + + expect(body.originGasOverhead).toBeUndefined(); + }); + it('sends request with EXACT_INPUT trade type when isMaxAmount is true', async () => { successfulFetchMock.mockResolvedValue({ json: async () => QUOTE_MOCK, diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts index 8ad148cbf13..637725564f4 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -221,7 +221,13 @@ async function getSingleQuote( // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const useExactInput = isMaxAmount || request.isPostQuote; + const accountSupports7702 = await messenger.call( + 'KeyringController:accountSupports7702', + from, + ); + const useExecute = + accountSupports7702 && isRelayExecuteEnabled(messenger) && isEIP7702Chain(messenger, sourceChainId); diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts index 0e38d5bcea3..6de18d4f15c 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts @@ -141,6 +141,7 @@ describe('Relay Submit Utils', () => { const getRelayPollingTimeoutMock = jest.mocked(getRelayPollingTimeout); const { + accountSupports7702Mock, addTransactionMock, addTransactionBatchMock, getDelegationTransactionMock, @@ -157,6 +158,7 @@ describe('Relay Submit Utils', () => { beforeEach(() => { jest.resetAllMocks(); + accountSupports7702Mock.mockResolvedValue(true); getRelayPollingIntervalMock.mockReturnValue(1); getRelayPollingTimeoutMock.mockReturnValue(undefined); @@ -388,6 +390,33 @@ describe('Relay Submit Utils', () => { ); }); + it('does not add authorization list when account does not support 7702', async () => { + accountSupports7702Mock.mockResolvedValue(false); + request.quotes[0].original.details.currencyOut.currency.chainId = 1; + request.quotes[0].original.request = { + authorizationList: [ + { + address: '0xabc' as Hex, + chainId: 1, + nonce: 2, + r: '0xr' as Hex, + s: '0xs' as Hex, + yParity: 1, + }, + ], + } as never; + + await submitRelayQuotes(request); + + expect(addTransactionMock).toHaveBeenCalledTimes(1); + expect(addTransactionMock).toHaveBeenCalledWith( + expect.not.objectContaining({ + authorizationList: expect.anything(), + }), + expect.anything(), + ); + }); + it('adds transaction batch if multiple params', async () => { request.quotes[0].original.steps[0].items.push({ ...request.quotes[0].original.steps[0].items[0], @@ -994,6 +1023,29 @@ describe('Relay Submit Utils', () => { ); }); + it('disables 7702 batch when account does not support 7702', async () => { + accountSupports7702Mock.mockResolvedValue(false); + + request.quotes[0].original.steps[0].items.push({ + ...request.quotes[0].original.steps[0].items[0], + }); + + request.quotes[0].original.metamask.gasLimits = [42000]; + request.quotes[0].original.metamask.is7702 = true; + + await submitRelayQuotes(request); + + expect(addTransactionBatchMock).toHaveBeenCalledTimes(1); + expect(addTransactionBatchMock).toHaveBeenCalledWith( + expect.objectContaining({ + disable7702: true, + disableHook: false, + disableSequential: false, + gasLimit7702: undefined, + }), + ); + }); + it('adds transaction batch without gasLimit7702 when multiple gas limits', async () => { request.quotes[0].original.steps[0].items.push({ ...request.quotes[0].original.steps[0].items[0], diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts index 001973d3ef0..8d9f4661d9f 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts @@ -476,6 +476,11 @@ async function submitViaTransactionController( const { from, sourceChainId, sourceTokenAddress } = quote.request; const { isPostQuote } = quote.request; + const accountSupports7702 = await messenger.call( + 'KeyringController:accountSupports7702', + from, + ); + const networkClientId = messenger.call( 'NetworkController:findNetworkClientIdByChainId', sourceChainId, @@ -526,7 +531,9 @@ async function submitViaTransactionController( quote.original.details.currencyOut.currency.chainId; const authorizationList: AuthorizationList | undefined = - isSameChain && quote.original.request.authorizationList?.length + accountSupports7702 && + isSameChain && + quote.original.request.authorizationList?.length ? quote.original.request.authorizationList.map((a) => ({ address: a.address, chainId: toHex(a.chainId), @@ -555,9 +562,10 @@ async function submitViaTransactionController( }, ); } else { - const gasLimit7702 = metamask.is7702 - ? toHex(metamask.gasLimits[0]) - : undefined; + const gasLimit7702 = + accountSupports7702 && metamask.is7702 + ? toHex(metamask.gasLimits[0]) + : undefined; const transactions = allParams.map((singleParams, index) => { const gasLimit = gasLimits[index]; diff --git a/packages/transaction-pay-controller/src/tests/messenger-mock.ts b/packages/transaction-pay-controller/src/tests/messenger-mock.ts index c164eb45e30..75021a2d0ef 100644 --- a/packages/transaction-pay-controller/src/tests/messenger-mock.ts +++ b/packages/transaction-pay-controller/src/tests/messenger-mock.ts @@ -11,6 +11,7 @@ import type { import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; import type { NetworkControllerGetNetworkClientByIdAction } from '@metamask/network-controller'; import type { NetworkControllerFindNetworkClientIdByChainIdAction } from '@metamask/network-controller'; +import type { KeyringControllerAccountSupports7702Action } from '@metamask/keyring-controller'; import type { RemoteFeatureFlagControllerGetStateAction } from '@metamask/remote-feature-flag-controller'; import type { TransactionControllerAddTransactionAction, @@ -127,6 +128,10 @@ export function getMessengerMock({ TransactionControllerEstimateGasBatchAction['handler'] > = jest.fn(); + const accountSupports7702Mock: jest.MockedFn< + KeyringControllerAccountSupports7702Action['handler'] + > = jest.fn(); + const getAssetsControllerStateMock = jest.fn(); const messenger: RootMessenger = new Messenger({ @@ -248,11 +253,17 @@ export function getMessengerMock({ 'AssetsController:getStateForTransactionPay', getAssetsControllerStateMock, ); + + messenger.registerActionHandler( + 'KeyringController:accountSupports7702', + accountSupports7702Mock, + ); } const publish = messenger.publish.bind(messenger); return { + accountSupports7702Mock, addTransactionMock, getAssetsControllerStateMock, addTransactionBatchMock, diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index 320f07327dc..49e93eed8b4 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -12,7 +12,10 @@ import type { BridgeControllerActions } from '@metamask/bridge-controller'; import type { BridgeStatusControllerStateChangeEvent } from '@metamask/bridge-status-controller'; import type { BridgeStatusControllerActions } from '@metamask/bridge-status-controller'; import type { GasFeeControllerActions } from '@metamask/gas-fee-controller'; -import type { KeyringControllerSignTypedMessageAction } from '@metamask/keyring-controller'; +import type { + KeyringControllerAccountSupports7702Action, + KeyringControllerSignTypedMessageAction, +} from '@metamask/keyring-controller'; import type { Messenger } from '@metamask/messenger'; import type { NetworkControllerFindNetworkClientIdByChainIdAction } from '@metamask/network-controller'; import type { NetworkControllerGetNetworkClientByIdAction } from '@metamask/network-controller'; @@ -47,6 +50,7 @@ export type AllowedActions = | BridgeStatusControllerActions | CurrencyRateControllerActions | GasFeeControllerActions + | KeyringControllerAccountSupports7702Action | KeyringControllerSignTypedMessageAction | NetworkControllerFindNetworkClientIdByChainIdAction | NetworkControllerGetNetworkClientByIdAction From 8baad445b5391f5a3ad425aabcd9d49214e10655 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Tue, 7 Apr 2026 14:37:51 +0200 Subject: [PATCH 2/4] Add changelog --- packages/keyring-controller/CHANGELOG.md | 4 ++++ packages/transaction-pay-controller/CHANGELOG.md | 1 + 2 files changed, 5 insertions(+) diff --git a/packages/keyring-controller/CHANGELOG.md b/packages/keyring-controller/CHANGELOG.md index 52ca3f75e4c..8d033da6934 100644 --- a/packages/keyring-controller/CHANGELOG.md +++ b/packages/keyring-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `accountSupports7702` method and `KeyringController:accountSupports7702` messenger action to check whether an account's keyring supports EIP-7702 authorization signing ([#8388](https://github.com/MetaMask/core/pull/8388)) + ### Changed - Bump `@metamask/messenger` from `^1.0.0` to `^1.1.1` ([#8364](https://github.com/MetaMask/core/pull/8364), [#8373](https://github.com/MetaMask/core/pull/8373)) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index bab1d2599cd..6f4eee3f3c4 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Fix perps withdraw to Arbitrum USDC showing inflated transaction fee by bypassing same-token filter when `isHyperliquidSource` is set ([#8387](https://github.com/MetaMask/core/pull/8387)) +- Fix mUSD conversion for hardware wallets on EIP-7702 chains by gating relay and Across 7702 paths on `KeyringController:accountSupports7702` ([#8388](https://github.com/MetaMask/core/pull/8388)) ## [19.0.3] From bce8c098b2526083558b353421c948b580d671b3 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Tue, 7 Apr 2026 19:41:45 +0200 Subject: [PATCH 3/4] Update --- .../src/KeyringController.test.ts | 5 ++- .../src/strategy/across/across-quotes.test.ts | 40 +++++++++++++++++++ .../src/strategy/across/across-quotes.ts | 40 ++++++++++++++++++- .../src/strategy/across/across-submit.test.ts | 6 +-- .../src/tests/messenger-mock.ts | 2 +- 5 files changed, 85 insertions(+), 8 deletions(-) diff --git a/packages/keyring-controller/src/KeyringController.test.ts b/packages/keyring-controller/src/KeyringController.test.ts index dd597f989e3..8e9393c73da 100644 --- a/packages/keyring-controller/src/KeyringController.test.ts +++ b/packages/keyring-controller/src/KeyringController.test.ts @@ -1053,8 +1053,9 @@ describe('KeyringController', () => { [privateKey], ); - const result = - await controller.accountSupports7702(importedAccountAddress); + const result = await controller.accountSupports7702( + importedAccountAddress, + ); expect(result).toBe(true); }); }); diff --git a/packages/transaction-pay-controller/src/strategy/across/across-quotes.test.ts b/packages/transaction-pay-controller/src/strategy/across/across-quotes.test.ts index 766001d26f0..b1e91732f39 100644 --- a/packages/transaction-pay-controller/src/strategy/across/across-quotes.test.ts +++ b/packages/transaction-pay-controller/src/strategy/across/across-quotes.test.ts @@ -164,6 +164,7 @@ describe('Across Quotes', () => { const calculateGasCostMock = jest.mocked(calculateGasCost); const { + accountSupports7702Mock, messenger, estimateGasMock, estimateGasBatchMock, @@ -203,6 +204,7 @@ describe('Across Quotes', () => { getGasBufferMock.mockReturnValue(1.0); getSlippageMock.mockReturnValue(0.005); + accountSupports7702Mock.mockResolvedValue(true); findNetworkClientIdByChainIdMock.mockReturnValue('mainnet'); estimateGasMock.mockResolvedValue({ gas: '0x5208', @@ -1253,6 +1255,44 @@ describe('Across Quotes', () => { ); }); + it('re-estimates individually when batch returns 7702 but account does not support it', async () => { + accountSupports7702Mock.mockResolvedValue(false); + + estimateGasBatchMock.mockResolvedValue({ + totalGasLimit: 51000, + gasLimits: [51000], + }); + + estimateGasMock.mockResolvedValue({ + gas: '0x5208', + simulationFails: undefined, + }); + + successfulFetchMock.mockResolvedValue({ + json: async () => ({ + ...QUOTE_MOCK, + approvalTxns: [ + { + chainId: 1, + data: '0xaaaa' as Hex, + to: '0xapprove1' as Hex, + value: '0x1' as Hex, + }, + ], + }), + } as Response); + + const result = await getAcrossQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + expect(result[0].original.metamask.is7702).toBe(false); + expect(result[0].original.metamask.gasLimits).toHaveLength(2); + expect(estimateGasMock).toHaveBeenCalledTimes(2); + }); + it('throws when the shared gas estimator marks a quote as 7702 without a combined gas limit', async () => { const estimateQuoteGasLimitsSpy = jest.spyOn( quoteGasUtils, diff --git a/packages/transaction-pay-controller/src/strategy/across/across-quotes.ts b/packages/transaction-pay-controller/src/strategy/across/across-quotes.ts index 52fd12ab668..bc40d9cbc55 100644 --- a/packages/transaction-pay-controller/src/strategy/across/across-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/across/across-quotes.ts @@ -401,7 +401,7 @@ async function calculateSourceNetworkCost( const orderedTransactions = getAcrossOrderedTransactions({ quote }); const { swapTx } = quote; const swapChainId = toHex(swapTx.chainId); - const gasEstimates = await estimateQuoteGasLimits({ + let gasEstimates = await estimateQuoteGasLimits({ fallbackGas: acrossFallbackGas, messenger, transactions: orderedTransactions.map((transaction) => ({ @@ -413,7 +413,43 @@ async function calculateSourceNetworkCost( value: transaction.value ?? '0x0', })), }); - const { batchGasLimit, is7702 } = gasEstimates; + const { batchGasLimit } = gasEstimates; + + const accountSupports7702 = await messenger.call( + 'KeyringController:accountSupports7702', + from, + ); + + // If the chain returned a combined 7702 gas limit but the account cannot sign + // EIP-7702 authorizations (e.g. hardware wallet), re-estimate each transaction + // individually so the submit path receives per-transaction gas limits. + if (gasEstimates.is7702 && !accountSupports7702) { + const individualResults = await Promise.all( + orderedTransactions.map((transaction) => + estimateQuoteGasLimits({ + fallbackGas: acrossFallbackGas, + messenger, + transactions: [ + { + chainId: toHex(transaction.chainId), + data: transaction.data, + from, + gas: transaction.gas, + to: transaction.to, + value: transaction.value ?? '0x0', + }, + ], + }), + ), + ); + gasEstimates = { + ...gasEstimates, + is7702: false, + gasLimits: individualResults.map((result) => result.gasLimits[0]), + }; + } + + const { is7702 } = gasEstimates; if (is7702) { if (!batchGasLimit) { diff --git a/packages/transaction-pay-controller/src/strategy/across/across-submit.test.ts b/packages/transaction-pay-controller/src/strategy/across/across-submit.test.ts index 861ef5ee72b..00b1dd02d40 100644 --- a/packages/transaction-pay-controller/src/strategy/across/across-submit.test.ts +++ b/packages/transaction-pay-controller/src/strategy/across/across-submit.test.ts @@ -264,7 +264,7 @@ describe('Across Submit', () => { it('disables 7702 batch when account does not support 7702', async () => { accountSupports7702Mock.mockResolvedValue(false); - const batchGasQuote = { + const nonIs7702Quote = { ...QUOTE_MOCK, original: { ...QUOTE_MOCK.original, @@ -273,14 +273,14 @@ describe('Across Submit', () => { { estimate: 21000, max: 21000 }, { estimate: 22000, max: 22000 }, ], - is7702: true, + is7702: false, }, }, } as unknown as TransactionPayQuote; await submitAcrossQuotes({ messenger, - quotes: [batchGasQuote], + quotes: [nonIs7702Quote], transaction: TRANSACTION_META_MOCK, isSmartTransaction: jest.fn(), }); diff --git a/packages/transaction-pay-controller/src/tests/messenger-mock.ts b/packages/transaction-pay-controller/src/tests/messenger-mock.ts index 75021a2d0ef..da7069e6630 100644 --- a/packages/transaction-pay-controller/src/tests/messenger-mock.ts +++ b/packages/transaction-pay-controller/src/tests/messenger-mock.ts @@ -3,6 +3,7 @@ import type { TokenBalancesControllerGetStateAction } from '@metamask/assets-con import type { TokenRatesControllerGetStateAction } from '@metamask/assets-controllers'; import type { AccountTrackerControllerGetStateAction } from '@metamask/assets-controllers'; import type { BridgeStatusControllerGetStateAction } from '@metamask/bridge-status-controller'; +import type { KeyringControllerAccountSupports7702Action } from '@metamask/keyring-controller'; import type { MessengerActions, MessengerEvents, @@ -11,7 +12,6 @@ import type { import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; import type { NetworkControllerGetNetworkClientByIdAction } from '@metamask/network-controller'; import type { NetworkControllerFindNetworkClientIdByChainIdAction } from '@metamask/network-controller'; -import type { KeyringControllerAccountSupports7702Action } from '@metamask/keyring-controller'; import type { RemoteFeatureFlagControllerGetStateAction } from '@metamask/remote-feature-flag-controller'; import type { TransactionControllerAddTransactionAction, From 8fff4f2dcc9e772ff7daa55d0f11bdf4f93d6658 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Wed, 8 Apr 2026 10:25:35 +0200 Subject: [PATCH 4/4] Update --- .../src/strategy/across/across-submit.test.ts | 12 ++--- .../src/strategy/across/across-submit.ts | 24 +++++---- .../src/strategy/relay/relay-submit.test.ts | 15 ++---- .../src/strategy/relay/relay-submit.ts | 50 +++++++++++-------- 4 files changed, 48 insertions(+), 53 deletions(-) diff --git a/packages/transaction-pay-controller/src/strategy/across/across-submit.test.ts b/packages/transaction-pay-controller/src/strategy/across/across-submit.test.ts index 00b1dd02d40..e5dc2c7cb27 100644 --- a/packages/transaction-pay-controller/src/strategy/across/across-submit.test.ts +++ b/packages/transaction-pay-controller/src/strategy/across/across-submit.test.ts @@ -261,7 +261,7 @@ describe('Across Submit', () => { ); }); - it('disables 7702 batch when account does not support 7702', async () => { + it('submits individually when account does not support 7702', async () => { accountSupports7702Mock.mockResolvedValue(false); const nonIs7702Quote = { @@ -285,14 +285,8 @@ describe('Across Submit', () => { isSmartTransaction: jest.fn(), }); - expect(addTransactionBatchMock).toHaveBeenCalledWith( - expect.objectContaining({ - disable7702: true, - disableHook: false, - disableSequential: false, - gasLimit7702: undefined, - }), - ); + expect(addTransactionBatchMock).not.toHaveBeenCalled(); + expect(addTransactionMock).toHaveBeenCalledTimes(2); }); it('submits a single transaction when no approvals', async () => { diff --git a/packages/transaction-pay-controller/src/strategy/across/across-submit.ts b/packages/transaction-pay-controller/src/strategy/across/across-submit.ts index 51eb5c80e25..1d4e09e549d 100644 --- a/packages/transaction-pay-controller/src/strategy/across/across-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/across/across-submit.ts @@ -203,17 +203,19 @@ async function submitTransactions( let result: { result: Promise } | undefined; try { - if (transactions.length === 1) { - result = await messenger.call( - 'TransactionController:addTransaction', - transactions[0].params, - { - networkClientId, - origin: ORIGIN_METAMASK, - requireApproval: false, - type: transactions[0].type, - }, - ); + if (transactions.length === 1 || !accountSupports7702) { + for (const { params, type } of transactions) { + result = await messenger.call( + 'TransactionController:addTransaction', + params, + { + networkClientId, + origin: ORIGIN_METAMASK, + requireApproval: false, + type, + }, + ); + } } else { const batchTransactions = transactions.map(({ params, type }) => ({ params: toBatchTransactionParams(params), diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts index 6de18d4f15c..9f015abc7a4 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts @@ -1023,27 +1023,20 @@ describe('Relay Submit Utils', () => { ); }); - it('disables 7702 batch when account does not support 7702', async () => { + it('submits individually when account does not support 7702', async () => { accountSupports7702Mock.mockResolvedValue(false); request.quotes[0].original.steps[0].items.push({ ...request.quotes[0].original.steps[0].items[0], }); - request.quotes[0].original.metamask.gasLimits = [42000]; + request.quotes[0].original.metamask.gasLimits = [21000, 22000]; request.quotes[0].original.metamask.is7702 = true; await submitRelayQuotes(request); - expect(addTransactionBatchMock).toHaveBeenCalledTimes(1); - expect(addTransactionBatchMock).toHaveBeenCalledWith( - expect.objectContaining({ - disable7702: true, - disableHook: false, - disableSequential: false, - gasLimit7702: undefined, - }), - ); + expect(addTransactionBatchMock).not.toHaveBeenCalled(); + expect(addTransactionMock).toHaveBeenCalledTimes(2); }); it('adds transaction batch without gasLimit7702 when multiple gas limits', async () => { diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts index 8d9f4661d9f..336ecd81567 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts @@ -543,29 +543,35 @@ async function submitViaTransactionController( const { metamask } = quote.original; const { gasLimits } = metamask; - if (allParams.length === 1) { - const transactionParams = { - ...allParams[0], - authorizationList, - gas: toHex(gasLimits[0]), - }; - - result = await messenger.call( - 'TransactionController:addTransaction', - transactionParams, - { - gasFeeToken, - networkClientId, - origin: ORIGIN_METAMASK, - requireApproval: false, - type: getRelayDepositType(transaction.type), - }, - ); + if (allParams.length === 1 || !accountSupports7702) { + for (let i = 0; i < allParams.length; i++) { + const transactionParams = { + ...allParams[i], + ...(i === 0 ? { authorizationList } : {}), + gas: toHex(gasLimits[i] ?? gasLimits[0]), + }; + + result = await messenger.call( + 'TransactionController:addTransaction', + transactionParams, + { + ...(i === 0 ? { gasFeeToken } : {}), + networkClientId, + origin: ORIGIN_METAMASK, + requireApproval: false, + type: getTransactionType( + isPostQuote, + i, + transaction.type, + normalizedParams.length, + ), + }, + ); + } } else { - const gasLimit7702 = - accountSupports7702 && metamask.is7702 - ? toHex(metamask.gasLimits[0]) - : undefined; + const gasLimit7702 = metamask.is7702 + ? toHex(metamask.gasLimits[0]) + : undefined; const transactions = allParams.map((singleParams, index) => { const gasLimit = gasLimits[index];