From 01b4824f49ffed7d3719e05657553a92e633d7fc Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Tue, 9 Dec 2025 12:32:10 +0000 Subject: [PATCH 1/4] Estimate gas batch action --- packages/transaction-controller/CHANGELOG.md | 7 + .../src/TransactionController.test.ts | 69 +++- .../src/TransactionController.ts | 51 ++- packages/transaction-controller/src/index.ts | 1 + packages/transaction-controller/src/types.ts | 8 + .../src/utils/batch.test.ts | 41 ++- .../transaction-controller/src/utils/batch.ts | 4 +- .../src/utils/gas.test.ts | 324 ++++++++++++++++-- .../transaction-controller/src/utils/gas.ts | 145 +++++++- .../transaction-pay-controller/CHANGELOG.md | 5 + .../src/strategy/relay/relay-quotes.test.ts | 207 ++++++++++- .../src/strategy/relay/relay-quotes.ts | 292 ++++++++++++---- .../src/strategy/relay/relay-submit.test.ts | 77 ++++- .../src/strategy/relay/relay-submit.ts | 44 ++- .../src/strategy/relay/types.ts | 5 +- .../src/tests/messenger-mock.ts | 22 ++ .../transaction-pay-controller/src/types.ts | 4 + .../src/utils/feature-flags.ts | 49 ++- 18 files changed, 1215 insertions(+), 140 deletions(-) diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index 4f9ecf1690..6e0e0511cd 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add `estimateGasBatch` function and messenger action to estimate gas for batch transactions ([#7405](https://github.com/MetaMask/core/pull/7405)) + - Add optional `gasLimit7702` property to `TransactionBatchRequest`. - Automatically fail pending transactions if no receipt and hash not recognised by network after multiple attempts ([#7329](https://github.com/MetaMask/core/pull/7329)) - Add optional `isTimeoutEnabled` callback to disable for specific transactions. - Ignores transactions with future nonce. @@ -18,6 +20,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Prevent `TransactionController:transactionApproved` event firing if keyring throws during signing ([#7410](https://github.com/MetaMask/core/pull/7410)) +### Added + +- Add `estimateGasBatch` function and messenger action to estimate gas for batch transactions ([#7405](https://github.com/MetaMask/core/pull/7405)) + - Add optional `gasLimit7702` property to `TransactionBatchRequest`. + ## [62.5.0] ### Changed diff --git a/packages/transaction-controller/src/TransactionController.test.ts b/packages/transaction-controller/src/TransactionController.test.ts index d45aec1876..c1766d4b4c 100644 --- a/packages/transaction-controller/src/TransactionController.test.ts +++ b/packages/transaction-controller/src/TransactionController.test.ts @@ -97,7 +97,12 @@ import { getBalanceChanges } from './utils/balance-changes'; import { addTransactionBatch } from './utils/batch'; import { getDelegationAddress } from './utils/eip7702'; import { updateFirstTimeInteraction } from './utils/first-time-interaction'; -import { addGasBuffer, estimateGas, updateGas } from './utils/gas'; +import { + addGasBuffer, + estimateGas, + estimateGasBatch, + updateGas, +} from './utils/gas'; import { getGasFeeTokens } from './utils/gas-fee-tokens'; import { updateGasFees } from './utils/gas-fees'; import { getGasFeeFlow } from './utils/gas-flow'; @@ -538,6 +543,7 @@ describe('TransactionController', () => { const updateGasMock = jest.mocked(updateGas); const updateGasFeesMock = jest.mocked(updateGasFees); const estimateGasMock = jest.mocked(estimateGas); + const estimateGasBatchMock = jest.mocked(estimateGasBatch); const addGasBufferMock = jest.mocked(addGasBuffer); const updateSwapsTransactionMock = jest.mocked(updateSwapsTransaction); const updatePostTransactionBalanceMock = jest.mocked( @@ -8663,5 +8669,66 @@ describe('TransactionController', () => { expect(approvedEventListener).not.toHaveBeenCalled(); }); }); + + describe('TransactionController:estimateGasBatch', () => { + it('calls estimateGasBatch method via messenger and returns gas estimates', async () => { + const { messenger } = setupController(); + + const totalGasLimitMock = 100000; + const gasLimitsMock = [50000, 50000]; + + estimateGasBatchMock.mockResolvedValueOnce({ + totalGasLimit: totalGasLimitMock, + gasLimits: gasLimitsMock, + }); + + const result = await messenger.call( + 'TransactionController:estimateGasBatch', + { + chainId: CHAIN_ID_MOCK, + from: ACCOUNT_MOCK, + transactions: [ + { + to: ACCOUNT_2_MOCK, + value: VALUE_MOCK, + data: DATA_MOCK, + }, + { + to: ACCOUNT_2_MOCK, + value: VALUE_MOCK, + data: DATA_MOCK, + }, + ], + }, + ); + + expect(result).toStrictEqual({ + totalGasLimit: totalGasLimitMock, + gasLimits: gasLimitsMock, + }); + + expect(estimateGasBatchMock).toHaveBeenCalledTimes(1); + expect(estimateGasBatchMock).toHaveBeenCalledWith({ + chainId: CHAIN_ID_MOCK, + ethQuery: expect.anything(), + from: ACCOUNT_MOCK, + getSimulationConfig: expect.any(Function), + isAtomicBatchSupported: expect.any(Function), + messenger: expect.anything(), + transactions: [ + { + to: ACCOUNT_2_MOCK, + value: VALUE_MOCK, + data: DATA_MOCK, + }, + { + to: ACCOUNT_2_MOCK, + value: VALUE_MOCK, + data: DATA_MOCK, + }, + ], + }); + }); + }); }); }); diff --git a/packages/transaction-controller/src/TransactionController.ts b/packages/transaction-controller/src/TransactionController.ts index e6d25af384..d8b8505a13 100644 --- a/packages/transaction-controller/src/TransactionController.ts +++ b/packages/transaction-controller/src/TransactionController.ts @@ -139,7 +139,12 @@ import { } from './utils/eip7702'; import { validateConfirmedExternalTransaction } from './utils/external-transactions'; import { updateFirstTimeInteraction } from './utils/first-time-interaction'; -import { addGasBuffer, estimateGas, updateGas } from './utils/gas'; +import { + addGasBuffer, + estimateGas, + estimateGasBatch, + updateGas, +} from './utils/gas'; import { checkGasFeeTokenBeforePublish, getGasFeeTokens, @@ -310,6 +315,11 @@ export type TransactionControllerEstimateGasAction = { handler: TransactionController['estimateGas']; }; +export type TransactionControllerEstimateGasBatchAction = { + type: `${typeof controllerName}:estimateGasBatch`; + handler: TransactionController['estimateGasBatch']; +}; + /** * Adds external provided transaction to state as confirmed transaction. * @@ -400,6 +410,7 @@ export type TransactionControllerActions = | TransactionControllerAddTransactionBatchAction | TransactionControllerConfirmExternalTransactionAction | TransactionControllerEstimateGasAction + | TransactionControllerEstimateGasBatchAction | TransactionControllerGetGasFeeTokensAction | TransactionControllerGetNonceLockAction | TransactionControllerGetStateAction @@ -1783,6 +1794,39 @@ export class TransactionController extends BaseController< return { gas: estimatedGas, simulationFails }; } + /** + * Estimates required gas for a batch of transactions. + * + * @param request - Request object. + * @param request.chainId - Chain ID of the transactions. + * @param request.from - Address of the sender. + * @param request.transactions - Array of transactions within a batch request. + * @returns Object containing the gas limit. + */ + async estimateGasBatch({ + chainId, + from, + transactions, + }: { + chainId: Hex; + from: Hex; + transactions: BatchTransactionParams[]; + }): Promise<{ totalGasLimit: number; gasLimits: number[] }> { + const ethQuery = this.#getEthQuery({ + chainId, + }); + + return estimateGasBatch({ + chainId, + ethQuery, + from, + getSimulationConfig: this.#getSimulationConfig, + isAtomicBatchSupported: this.isAtomicBatchSupported.bind(this), + messenger: this.messenger, + transactions, + }); + } + /** * Estimates required gas for a given transaction and add additional gas buffer with the given multiplier. * @@ -4612,6 +4656,11 @@ export class TransactionController extends BaseController< this.estimateGas.bind(this), ); + this.messenger.registerActionHandler( + `${controllerName}:estimateGasBatch`, + this.estimateGasBatch.bind(this), + ); + this.messenger.registerActionHandler( `${controllerName}:getGasFeeTokens`, this.#getGasFeeTokensAction.bind(this), diff --git a/packages/transaction-controller/src/index.ts b/packages/transaction-controller/src/index.ts index 288c6f3ab6..a9dee8d621 100644 --- a/packages/transaction-controller/src/index.ts +++ b/packages/transaction-controller/src/index.ts @@ -9,6 +9,7 @@ export type { TransactionControllerEmulateTransactionUpdate, TransactionControllerEvents, TransactionControllerEstimateGasAction, + TransactionControllerEstimateGasBatchAction, TransactionControllerGetGasFeeTokensAction, TransactionControllerGetNonceLockAction, TransactionControllerGetStateAction, diff --git a/packages/transaction-controller/src/types.ts b/packages/transaction-controller/src/types.ts index a339a13cdb..a6e3cce50a 100644 --- a/packages/transaction-controller/src/types.ts +++ b/packages/transaction-controller/src/types.ts @@ -798,6 +798,11 @@ export enum TransactionType { */ predictWithdraw = 'predictWithdraw', + /** + * Deposit funds for Relay quote. + */ + relayDeposit = 'relayDeposit', + /** * When a transaction is failed it can be retried by * resubmitting the same transaction with a higher gas fee. This type is also used @@ -1739,6 +1744,9 @@ export type TransactionBatchRequest = { /** Address of an ERC-20 token to pay for the gas fee, if the user has insufficient native balance. */ gasFeeToken?: Hex; + /** Gas limit for the transaction batch if submitted via EIP-7702. */ + gasLimit7702?: Hex; + /** Whether MetaMask will be compensated for the gas fee by the transaction. */ isGasFeeIncluded?: boolean; diff --git a/packages/transaction-controller/src/utils/batch.test.ts b/packages/transaction-controller/src/utils/batch.test.ts index 8db0edf393..34d8348f3e 100644 --- a/packages/transaction-controller/src/utils/batch.test.ts +++ b/packages/transaction-controller/src/utils/batch.test.ts @@ -92,6 +92,7 @@ const UPGRADE_CONTRACT_ADDRESS_MOCK = const NONCE_PREVIOUS_MOCK = '0x110'; const NONCE_MOCK = '0x111'; const NONCE_MOCK_2 = '0x112'; +const GAS_LIMIT_7702_MOCK = '0x1234'; const TRANSACTION_META_MOCK = { id: BATCH_ID_CUSTOM_MOCK, @@ -345,7 +346,8 @@ describe('Batch Utils', () => { getChainIdMock.mockReturnValue(CHAIN_ID_MOCK); simulateGasBatchMock.mockResolvedValue({ - gasLimit: GAS_TOTAL_MOCK, + totalGasLimit: GAS_TOTAL_MOCK, + gasLimits: [GAS_TOTAL_MOCK], }); doesChainSupportEIP7702Mock.mockReturnValue(true); @@ -795,6 +797,34 @@ describe('Batch Utils', () => { ); }); + it('includes gasLimit7702 in EIP-7702 transaction params if provided', async () => { + isAccountUpgradedToEIP7702Mock.mockResolvedValueOnce({ + delegationAddress: undefined, + isSupported: true, + }); + + addTransactionMock.mockResolvedValueOnce({ + transactionMeta: TRANSACTION_META_MOCK, + result: Promise.resolve(''), + }); + + generateEIP7702BatchTransactionMock.mockReturnValueOnce( + TRANSACTION_BATCH_PARAMS_MOCK, + ); + + request.request.gasLimit7702 = GAS_LIMIT_7702_MOCK; + + await addTransactionBatch(request); + + expect(addTransactionMock).toHaveBeenCalledTimes(1); + expect(addTransactionMock).toHaveBeenCalledWith( + expect.objectContaining({ + gas: GAS_LIMIT_7702_MOCK, + }), + expect.anything(), + ); + }); + it('throws if chain not supported', async () => { doesChainSupportEIP7702Mock.mockReturnValue(false); @@ -1309,7 +1339,8 @@ describe('Batch Utils', () => { } as TransactionBatchSingleRequest['existingTransaction']; simulateGasBatchMock.mockResolvedValueOnce({ - gasLimit: GAS_TOTAL_MOCK, + totalGasLimit: GAS_TOTAL_MOCK, + gasLimits: [GAS_TOTAL_MOCK], }); addTransactionMock.mockResolvedValueOnce({ @@ -1402,7 +1433,8 @@ describe('Batch Utils', () => { const existingTransactionMock = {}; simulateGasBatchMock.mockResolvedValueOnce({ - gasLimit: GAS_TOTAL_MOCK, + totalGasLimit: GAS_TOTAL_MOCK, + gasLimits: [GAS_TOTAL_MOCK], }); addTransactionMock @@ -1496,7 +1528,8 @@ describe('Batch Utils', () => { } as TransactionBatchSingleRequest['existingTransaction']; simulateGasBatchMock.mockResolvedValueOnce({ - gasLimit: GAS_TOTAL_MOCK, + totalGasLimit: GAS_TOTAL_MOCK, + gasLimits: [GAS_TOTAL_MOCK], }); addTransactionMock.mockResolvedValue({ diff --git a/packages/transaction-controller/src/utils/batch.ts b/packages/transaction-controller/src/utils/batch.ts index fdf028c73b..952c989a08 100644 --- a/packages/transaction-controller/src/utils/batch.ts +++ b/packages/transaction-controller/src/utils/batch.ts @@ -297,6 +297,7 @@ async function addTransactionBatchWith7702( disableUpgrade, from, gasFeeToken, + gasLimit7702, networkClientId, origin, overwriteUpgrade, @@ -358,6 +359,7 @@ async function addTransactionBatchWith7702( const txParams: TransactionParams = { ...batchParams, from, + gas: gasLimit7702, maxFeePerGas: nestedTransactions[0]?.maxFeePerGas, maxPriorityFeePerGas: nestedTransactions[0]?.maxPriorityFeePerGas, }; @@ -866,7 +868,7 @@ async function prepareApprovalData({ log('Preparing approval data for batch'); const chainId = getChainId(networkClientId); - const { gasLimit } = await simulateGasBatch({ + const { totalGasLimit: gasLimit } = await simulateGasBatch({ chainId, from, getSimulationConfig, diff --git a/packages/transaction-controller/src/utils/gas.test.ts b/packages/transaction-controller/src/utils/gas.test.ts index 2a37eb04f5..e933833a8c 100644 --- a/packages/transaction-controller/src/utils/gas.test.ts +++ b/packages/transaction-controller/src/utils/gas.test.ts @@ -4,12 +4,13 @@ import { remove0x } from '@metamask/utils'; import type { Hex } from '@metamask/utils'; import { cloneDeep } from 'lodash'; -import { DELEGATION_PREFIX } from './eip7702'; +import { DELEGATION_PREFIX, generateEIP7702BatchTransaction } from './eip7702'; import { getGasEstimateBuffer, getGasEstimateFallback } from './feature-flags'; import type { UpdateGasRequest } from './gas'; import { addGasBuffer, estimateGas, + estimateGasBatch, updateGas, FIXED_GAS, DEFAULT_GAS_MULTIPLIER, @@ -28,6 +29,7 @@ import { TransactionEnvelopeType } from '../types'; import type { TransactionMeta } from '../types'; import type { AuthorizationList, + BatchTransactionParams, TransactionBatchSingleRequest, } from '../types'; @@ -39,8 +41,33 @@ jest.mock('@metamask/controller-utils', () => ({ jest.mock('./feature-flags'); jest.mock('../api/simulation-api'); +jest.mock('./eip7702', () => ({ + ...jest.requireActual('./eip7702'), + generateEIP7702BatchTransaction: jest.fn(), +})); + const DEFAULT_GAS_ESTIMATE_FALLBACK_MOCK = 35; const FIXED_ESTIMATE_GAS_MOCK = 100000; +const GAS_MOCK = 100; +const BLOCK_GAS_LIMIT_MOCK = 123456789; +const BLOCK_NUMBER_MOCK = '0x5678'; +const ETH_QUERY_MOCK = {} as unknown as EthQuery; +const FALLBACK_MULTIPLIER_35_PERCENT = 0.35; +const GET_SIMULATION_CONFIG_MOCK = jest.fn(); +const MAX_GAS_MULTIPLIER = MAX_GAS_BLOCK_PERCENT / 100; +const CHAIN_ID_MOCK = '0x123'; +const GAS_2_MOCK = 12345; +const SIMULATE_GAS_MOCK = 54321; +const FROM_MOCK = '0xabc'; +const TO_MOCK = '0xdef'; +const VALUE_MOCK = '0x1'; +const VALUE_MOCK_2 = '0x2'; +const DATA_MOCK = '0xabcdef'; +const DATA_MOCK_2 = '0x123456'; +const GAS_MOCK_1 = '0x5208'; +const GAS_MOCK_2 = '0x7a120'; +const UPGRADE_CONTRACT_ADDRESS_MOCK = '0xupgrade123'; + const MESSENGER_MOCK = { call: jest.fn().mockReturnValue({ remoteFeatureFlags: {}, @@ -57,16 +84,54 @@ const GAS_ESTIMATE_FALLBACK_MULTIPLIER_MOCK = { fixed: undefined, }; -const GAS_MOCK = 100; -const BLOCK_GAS_LIMIT_MOCK = 123456789; -const BLOCK_NUMBER_MOCK = '0x5678'; -const ETH_QUERY_MOCK = {} as unknown as EthQuery; -const FALLBACK_MULTIPLIER_35_PERCENT = 0.35; -const GET_SIMULATION_CONFIG_MOCK = jest.fn(); -const MAX_GAS_MULTIPLIER = MAX_GAS_BLOCK_PERCENT / 100; -const CHAIN_ID_MOCK = '0x123'; -const GAS_2_MOCK = 12345; -const SIMULATE_GAS_MOCK = 54321; +const BATCH_TX_PARAMS_MOCK: BatchTransactionParams[] = [ + { + data: DATA_MOCK, + to: TO_MOCK, + value: VALUE_MOCK, + }, + { + data: DATA_MOCK_2, + to: TO_MOCK, + value: VALUE_MOCK_2, + }, +]; + +const BATCH_TX_PARAMS_WITH_GAS_MOCK: BatchTransactionParams[] = [ + { + data: DATA_MOCK, + to: TO_MOCK, + value: VALUE_MOCK, + gas: GAS_MOCK_1, + }, + { + data: DATA_MOCK_2, + to: TO_MOCK, + value: VALUE_MOCK_2, + gas: GAS_MOCK_2, + }, +]; + +const TRANSACTION_BATCH_REQUEST_MOCK: TransactionBatchSingleRequest[] = [ + { + params: { + data: DATA_MOCK, + to: TO_MOCK, + value: VALUE_MOCK, + }, + }, + { + params: { + data: DATA_MOCK_2, + to: TO_MOCK, + value: VALUE_MOCK_2, + }, + }, +]; + +const SIMULATED_TRANSACTIONS_RESPONSE_MOCK: SimulationResponse = { + transactions: [{ gasLimit: GAS_MOCK_1 }, { gasLimit: GAS_MOCK_2 }], +} as unknown as SimulationResponse; const AUTHORIZATION_LIST_MOCK: AuthorizationList = [ { @@ -108,6 +173,9 @@ describe('gas', () => { const simulateTransactionsMock = jest.mocked(simulateTransactions); const getGasEstimateFallbackMock = jest.mocked(getGasEstimateFallback); const getGasEstimateBufferMock = jest.mocked(getGasEstimateBuffer); + const generateEIP7702BatchTransactionMock = jest.mocked( + generateEIP7702BatchTransaction, + ); let updateGasRequest: UpdateGasRequest; @@ -989,40 +1057,224 @@ describe('gas', () => { }); }); - describe('simulateGasBatch', () => { - const FROM_MOCK = '0xabc'; - const TO_MOCK = '0xdef'; - const VALUE_MOCK = '0x1'; - const VALUE_MOCK_2 = '0x2'; - const DATA_MOCK = '0xabcdef'; - const DATA_MOCK_2 = '0x123456'; - const GAS_MOCK_1 = '0x5208'; // 21000 gas - const GAS_MOCK_2 = '0x7a120'; // 500000 gas - const TRANSACTION_BATCH_REQUEST_MOCK = [ - { - params: { + describe('estimateGasBatch', () => { + it('uses EIP-7702 when supported and upgrade required', async () => { + const isAtomicBatchSupportedMock = jest.fn().mockResolvedValue([ + { + chainId: CHAIN_ID_MOCK, + isSupported: false, + upgradeContractAddress: UPGRADE_CONTRACT_ADDRESS_MOCK, + }, + ]); + + generateEIP7702BatchTransactionMock.mockReturnValue({ + to: TO_MOCK, + data: DATA_MOCK, + } as BatchTransactionParams); + + mockQuery({ + getBlockByNumberResponse: { gasLimit: toHex(BLOCK_GAS_LIMIT_MOCK) }, + estimateGasResponse: toHex(GAS_MOCK), + }); + + const result = await estimateGasBatch({ + chainId: CHAIN_ID_MOCK, + ethQuery: ETH_QUERY_MOCK, + from: FROM_MOCK, + getSimulationConfig: GET_SIMULATION_CONFIG_MOCK, + isAtomicBatchSupported: isAtomicBatchSupportedMock, + messenger: MESSENGER_MOCK, + transactions: BATCH_TX_PARAMS_MOCK, + }); + + expect(result).toStrictEqual({ + totalGasLimit: GAS_MOCK, + gasLimits: [GAS_MOCK], + }); + + expect(generateEIP7702BatchTransactionMock).toHaveBeenCalledWith( + FROM_MOCK, + BATCH_TX_PARAMS_MOCK, + ); + + expect(queryMock).toHaveBeenCalledWith(ETH_QUERY_MOCK, 'estimateGas', [ + expect.objectContaining({ + to: TO_MOCK, + data: DATA_MOCK, + from: FROM_MOCK, + authorizationList: [ + expect.objectContaining({ address: UPGRADE_CONTRACT_ADDRESS_MOCK }), + ], + type: TransactionEnvelopeType.setCode, + }), + ]); + }); + + it('uses EIP-7702 when supported but upgrade not required', async () => { + const isAtomicBatchSupportedMock = jest.fn().mockResolvedValue([ + { + chainId: CHAIN_ID_MOCK, + isSupported: true, + upgradeContractAddress: UPGRADE_CONTRACT_ADDRESS_MOCK, + }, + ]); + + generateEIP7702BatchTransactionMock.mockReturnValue({ + to: TO_MOCK, + data: DATA_MOCK, + } as BatchTransactionParams); + + mockQuery({ + getBlockByNumberResponse: { gasLimit: toHex(BLOCK_GAS_LIMIT_MOCK) }, + estimateGasResponse: toHex(GAS_MOCK), + }); + + const result = await estimateGasBatch({ + chainId: CHAIN_ID_MOCK, + ethQuery: ETH_QUERY_MOCK, + from: FROM_MOCK, + getSimulationConfig: GET_SIMULATION_CONFIG_MOCK, + isAtomicBatchSupported: isAtomicBatchSupportedMock, + messenger: MESSENGER_MOCK, + transactions: BATCH_TX_PARAMS_MOCK, + }); + + expect(result).toStrictEqual({ + totalGasLimit: GAS_MOCK, + gasLimits: [GAS_MOCK], + }); + + expect(queryMock).toHaveBeenCalledWith(ETH_QUERY_MOCK, 'estimateGas', [ + expect.objectContaining({ + to: TO_MOCK, + data: DATA_MOCK, + from: FROM_MOCK, + authorizationList: undefined, + type: undefined, + }), + ]); + }); + + it('throws error when upgrade contract address not found', async () => { + const isAtomicBatchSupportedMock = jest.fn().mockResolvedValue([ + { + chainId: CHAIN_ID_MOCK, + isSupported: false, + upgradeContractAddress: undefined, + }, + ]); + + await expect( + estimateGasBatch({ + chainId: CHAIN_ID_MOCK, + ethQuery: ETH_QUERY_MOCK, + from: FROM_MOCK, + getSimulationConfig: GET_SIMULATION_CONFIG_MOCK, + isAtomicBatchSupported: isAtomicBatchSupportedMock, + messenger: MESSENGER_MOCK, + transactions: BATCH_TX_PARAMS_MOCK, + }), + ).rejects.toThrow('Upgrade contract address not found'); + }); + + it('uses provided gas limits when all transactions have gas', async () => { + const isAtomicBatchSupportedMock = jest.fn().mockResolvedValue([]); + + const result = await estimateGasBatch({ + chainId: CHAIN_ID_MOCK, + ethQuery: ETH_QUERY_MOCK, + from: FROM_MOCK, + getSimulationConfig: GET_SIMULATION_CONFIG_MOCK, + isAtomicBatchSupported: isAtomicBatchSupportedMock, + messenger: MESSENGER_MOCK, + transactions: BATCH_TX_PARAMS_WITH_GAS_MOCK, + }); + + expect(result).toStrictEqual({ + totalGasLimit: 521000, + gasLimits: [21000, 500000], + }); + + expect(simulateTransactionsMock).not.toHaveBeenCalled(); + }); + + it('simulates gas when not all transactions have gas', async () => { + const isAtomicBatchSupportedMock = jest.fn().mockResolvedValue([]); + + simulateTransactionsMock.mockResolvedValue( + SIMULATED_TRANSACTIONS_RESPONSE_MOCK, + ); + + const result = await estimateGasBatch({ + chainId: CHAIN_ID_MOCK, + ethQuery: ETH_QUERY_MOCK, + from: FROM_MOCK, + getSimulationConfig: GET_SIMULATION_CONFIG_MOCK, + isAtomicBatchSupported: isAtomicBatchSupportedMock, + messenger: MESSENGER_MOCK, + transactions: BATCH_TX_PARAMS_MOCK, + }); + + expect(result).toStrictEqual({ + totalGasLimit: 521000, + gasLimits: [21000, 500000], + }); + + expect(simulateTransactionsMock).toHaveBeenCalledWith(CHAIN_ID_MOCK, { + getSimulationConfig: GET_SIMULATION_CONFIG_MOCK, + transactions: [ + { + ...BATCH_TX_PARAMS_MOCK[0], + from: FROM_MOCK, + }, + { + ...BATCH_TX_PARAMS_MOCK[1], + from: FROM_MOCK, + }, + ], + }); + }); + + it('prefers provided gas over simulated gas when both available', async () => { + const isAtomicBatchSupportedMock = jest.fn().mockResolvedValue([]); + + const customGasValue = toHex(100000); + + const mixedBatchParams: BatchTransactionParams[] = [ + { data: DATA_MOCK, to: TO_MOCK, value: VALUE_MOCK, + gas: customGasValue, }, - }, - { - params: { + { data: DATA_MOCK_2, to: TO_MOCK, value: VALUE_MOCK_2, }, - }, - ] as TransactionBatchSingleRequest[]; + ]; - const SIMULATED_TRANSACTIONS_RESPONSE_MOCK = { - transactions: [{ gasLimit: GAS_MOCK_1 }, { gasLimit: GAS_MOCK_2 }], - } as unknown as SimulationResponse; + simulateTransactionsMock.mockResolvedValue( + SIMULATED_TRANSACTIONS_RESPONSE_MOCK, + ); + + const result = await estimateGasBatch({ + chainId: CHAIN_ID_MOCK, + ethQuery: ETH_QUERY_MOCK, + from: FROM_MOCK, + getSimulationConfig: GET_SIMULATION_CONFIG_MOCK, + isAtomicBatchSupported: isAtomicBatchSupportedMock, + messenger: MESSENGER_MOCK, + transactions: mixedBatchParams, + }); - beforeEach(() => { - jest.resetAllMocks(); + expect(result.gasLimits[0]).toBe(100000); + expect(result.gasLimits[1]).toBe(500000); + expect(result.totalGasLimit).toBe(600000); }); + }); + describe('simulateGasBatch', () => { it('returns the total gas limit as a hex string', async () => { simulateTransactionsMock.mockResolvedValueOnce( SIMULATED_TRANSACTIONS_RESPONSE_MOCK, @@ -1036,7 +1288,8 @@ describe('gas', () => { }); expect(result).toStrictEqual({ - gasLimit: '0x7f328', // Total gas limit (21000 + 500000 = 521000) + totalGasLimit: '0x7f328', // Total gas limit (21000 + 500000 = 521000) + gasLimits: ['0x5208', '0x7a120'], }); expect(simulateTransactionsMock).toHaveBeenCalledTimes(1); @@ -1123,7 +1376,8 @@ describe('gas', () => { }); expect(result).toStrictEqual({ - gasLimit: '0x0', // Total gas limit is 0 + totalGasLimit: '0x0', // Total gas limit is 0 + gasLimits: [], }); expect(simulateTransactionsMock).toHaveBeenCalledTimes(1); diff --git a/packages/transaction-controller/src/utils/gas.ts b/packages/transaction-controller/src/utils/gas.ts index 2f4ce614d9..94f12112b7 100644 --- a/packages/transaction-controller/src/utils/gas.ts +++ b/packages/transaction-controller/src/utils/gas.ts @@ -8,9 +8,9 @@ import { import type EthQuery from '@metamask/eth-query'; import type { Hex, Json } from '@metamask/utils'; import { add0x, createModuleLogger, remove0x } from '@metamask/utils'; -import { BN } from 'bn.js'; +import { BigNumber } from 'bignumber.js'; -import { DELEGATION_PREFIX } from './eip7702'; +import { DELEGATION_PREFIX, generateEIP7702BatchTransaction } from './eip7702'; import { getGasEstimateBuffer, getGasEstimateFallback } from './feature-flags'; import { simulateTransactions } from '../api/simulation-api'; import { projectLogger } from '../logger'; @@ -18,7 +18,10 @@ import type { TransactionControllerMessenger } from '../TransactionController'; import { TransactionEnvelopeType } from '../types'; import type { AuthorizationList, + BatchTransactionParams, GetSimulationConfig, + IsAtomicBatchSupportedRequest, + IsAtomicBatchSupportedResult, TransactionBatchSingleRequest, TransactionMeta, TransactionParams, @@ -185,6 +188,107 @@ export async function estimateGas({ }; } +export async function estimateGasBatch({ + chainId, + ethQuery, + from, + getSimulationConfig, + isAtomicBatchSupported, + messenger, + transactions, +}: { + chainId: Hex; + ethQuery: EthQuery; + from: Hex; + getSimulationConfig: GetSimulationConfig; + isAtomicBatchSupported: ( + request: IsAtomicBatchSupportedRequest, + ) => Promise; + messenger: TransactionControllerMessenger; + transactions: BatchTransactionParams[]; +}): Promise<{ totalGasLimit: number; gasLimits: number[] }> { + const is7702Result = await isAtomicBatchSupported({ + address: from, + chainIds: [chainId], + }); + + const chainResult = is7702Result.find((result) => result.chainId === chainId); + const isUpgradeRequired = Boolean(chainResult && !chainResult.isSupported); + + if (isUpgradeRequired && !chainResult?.upgradeContractAddress) { + throw new Error('Upgrade contract address not found'); + } + + if (chainResult) { + const authorizationList = isUpgradeRequired + ? [{ address: chainResult.upgradeContractAddress as Hex }] + : undefined; + + const type = isUpgradeRequired + ? TransactionEnvelopeType.setCode + : undefined; + + const params: TransactionParams = { + ...generateEIP7702BatchTransaction(from, transactions), + authorizationList, + from, + type, + }; + + const { estimatedGas: gasLimitHex } = await estimateGas({ + chainId, + ethQuery, + isSimulationEnabled: true, + getSimulationConfig, + messenger, + txParams: params, + }); + + const totalGasLimit = new BigNumber(gasLimitHex).toNumber(); + + log('Estimated EIP-7702 gas limit', totalGasLimit); + + return { totalGasLimit, gasLimits: [totalGasLimit] }; + } + + const allTransactionsHaveGas = transactions.every( + (transaction) => transaction.gas !== undefined, + ); + + if (allTransactionsHaveGas) { + const gasLimits = transactions.map((transaction) => + new BigNumber(transaction.gas as Hex).toNumber(), + ); + + const total = gasLimits.reduce((acc, gasLimit) => acc + gasLimit, 0); + + log('Using batch parameter gas limits', { gasLimits, total }); + + return { totalGasLimit: total, gasLimits }; + } + + const { gasLimits: gasLimitsHex } = await simulateGasBatch({ + chainId, + from, + getSimulationConfig, + transactions: transactions.map((transaction) => ({ + params: transaction, + })), + }); + + const gasLimits = transactions.map((transaction, index) => + transaction.gas + ? new BigNumber(transaction.gas).toNumber() + : new BigNumber(gasLimitsHex[index]).toNumber(), + ); + + const totalGasLimit = gasLimits.reduce((acc, gasLimit) => acc + gasLimit, 0); + + log('Simulated batch gas limits', { totalGasLimit, gasLimits }); + + return { totalGasLimit, gasLimits }; +} + /** * Add a buffer to the provided estimated gas. * The buffer is calculated based on the block gas limit and a multiplier. @@ -246,7 +350,7 @@ export async function simulateGasBatch({ from: Hex; getSimulationConfig: GetSimulationConfig; transactions: TransactionBatchSingleRequest[]; -}): Promise<{ gasLimit: Hex }> { +}): Promise<{ totalGasLimit: Hex; gasLimits: Hex[] }> { try { const response = await simulateTransactions(chainId, { getSimulationConfig, @@ -263,21 +367,32 @@ export async function simulateGasBatch({ throw new Error('Simulation response does not match transaction count'); } - const totalGasLimit = response.transactions.reduce((acc, transaction) => { - const gasLimit = transaction?.gasLimit; + return response.transactions.reduce<{ + totalGasLimit: Hex; + gasLimits: Hex[]; + }>( + (acc, transaction) => { + const gasLimit = transaction?.gasLimit; - if (!gasLimit) { - throw new Error( - 'No simulated gas returned for one of the transactions', - ); - } + if (!gasLimit) { + throw new Error( + 'No simulated gas returned for one of the transactions', + ); + } - return acc.add(hexToBN(gasLimit)); - }, new BN(0)); + acc.gasLimits.push(gasLimit); - return { - gasLimit: BNToHex(totalGasLimit), // Return the total gas limit as a hex string - }; + acc.totalGasLimit = BNToHex( + hexToBN(acc.totalGasLimit).add(hexToBN(gasLimit)), + ); + + return acc; + }, + { + totalGasLimit: '0x0', + gasLimits: [], + }, + ); } catch (error: unknown) { log('Error while simulating gas batch', error); throw new Error( diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 8c1b150a5d..76bf406de4 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Estimate gas for Relay quotes using messenger actions ([#7405](https://github.com/MetaMask/core/pull/7405)) + - Submit all Relay source transactions using same gas limits estimated from quote. + ## [10.4.0] ### Changed 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 a9017c835d..dafcd58721 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 @@ -44,9 +44,11 @@ const TRANSACTION_META_MOCK = { txParams: {} } as TransactionMeta; const TOKEN_TRANSFER_RECIPIENT_MOCK = '0x5678901234567890123456789012345678901234'; const NESTED_TRANSACTION_DATA_MOCK = '0xdef' as Hex; +const FROM_MOCK = '0x1234567890123456789012345678901234567891' as Hex; +const NETWORK_CLIENT_ID_MOCK = 'networkClientIdMock'; const QUOTE_REQUEST_MOCK: QuoteRequest = { - from: '0x1234567890123456789012345678901234567891', + from: FROM_MOCK, sourceBalanceRaw: '10000000000000000000', sourceChainId: '0x1', sourceTokenAddress: '0xabc', @@ -79,6 +81,9 @@ const QUOTE_MOCK = { amountUsd: '1.11', }, }, + metamask: { + gasLimits: [21000], + }, steps: [ { items: [ @@ -90,7 +95,7 @@ const QUOTE_MOCK = { data: { chainId: 1, data: '0x123' as Hex, - from: '0x1' as Hex, + from: FROM_MOCK, gas: '21000', maxFeePerGas: '1000000000', maxPriorityFeePerGas: '2000000000', @@ -141,6 +146,9 @@ describe('Relay Quotes Utils', () => { const { messenger, + estimateGasMock, + estimateGasBatchMock, + findNetworkClientIdByChainIdMock, getDelegationTransactionMock, getGasFeeTokensMock, getRemoteFeatureFlagControllerStateMock, @@ -182,6 +190,7 @@ describe('Relay Quotes Utils', () => { getDelegationTransactionMock.mockResolvedValue(DELEGATION_RESULT_MOCK); getGasFeeTokensMock.mockResolvedValue([]); + findNetworkClientIdByChainIdMock.mockReturnValue(NETWORK_CLIENT_ID_MOCK); }); describe('getRelayQuotes', () => { @@ -1045,5 +1054,199 @@ describe('Relay Quotes Utils', () => { }), ); }); + + it('estimates gas for single transaction', async () => { + const quoteMock = cloneDeep(QUOTE_MOCK); + delete quoteMock.steps[0].items[0].data.gas; + + successfulFetchMock.mockResolvedValue({ + json: async () => quoteMock, + } as never); + + estimateGasMock.mockResolvedValue({ + gas: toHex(50000), + simulationFails: undefined, + }); + + await getRelayQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + expect(estimateGasMock).toHaveBeenCalledWith( + { + data: quoteMock.steps[0].items[0].data.data, + from: quoteMock.steps[0].items[0].data.from, + to: quoteMock.steps[0].items[0].data.to, + value: toHex(300000), + }, + NETWORK_CLIENT_ID_MOCK, + ); + + expect(calculateGasCostMock).toHaveBeenCalledWith( + expect.objectContaining({ gas: 50000 }), + ); + }); + + it('uses fallback gas when estimateGas throws', async () => { + const quoteMock = cloneDeep(QUOTE_MOCK); + delete quoteMock.steps[0].items[0].data.gas; + + successfulFetchMock.mockResolvedValue({ + json: async () => quoteMock, + } as never); + + estimateGasMock.mockRejectedValue(new Error('Estimation failed')); + + await getRelayQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + expect(calculateGasCostMock).toHaveBeenCalledWith( + expect.objectContaining({ gas: 900000 }), + ); + }); + + it('uses fallback gas when estimation fails', async () => { + const quoteMock = cloneDeep(QUOTE_MOCK); + delete quoteMock.steps[0].items[0].data.gas; + + successfulFetchMock.mockResolvedValue({ + json: async () => quoteMock, + } as never); + + estimateGasMock.mockResolvedValue({ + gas: toHex(50000), + simulationFails: { + debug: {}, + }, + }); + + await getRelayQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + expect(calculateGasCostMock).toHaveBeenCalledWith( + expect.objectContaining({ gas: 900000 }), + ); + }); + + it('uses estimated gas for multiple transactions', async () => { + const quoteMock = cloneDeep(QUOTE_MOCK); + quoteMock.steps[0].items.push({ + data: { + chainId: 1, + from: FROM_MOCK, + to: '0x3' as Hex, + data: '0x456' as Hex, + }, + } as never); + + successfulFetchMock.mockResolvedValue({ + json: async () => quoteMock, + } as never); + + estimateGasBatchMock.mockResolvedValue({ + totalGasLimit: 100000, + gasLimits: [50000, 50000], + }); + + await getRelayQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + expect(estimateGasBatchMock).toHaveBeenCalledWith({ + chainId: '0x1', + from: FROM_MOCK, + transactions: [ + expect.objectContaining({ + data: quoteMock.steps[0].items[0].data.data, + to: quoteMock.steps[0].items[0].data.to, + }), + expect.objectContaining({ + data: quoteMock.steps[0].items[1].data.data, + to: quoteMock.steps[0].items[1].data.to, + }), + ], + }); + + expect(calculateGasCostMock).toHaveBeenCalledWith( + expect.objectContaining({ gas: 100000 }), + ); + }); + + it('uses fallback gas when estimateGasBatch fails', async () => { + const quoteMock = cloneDeep(QUOTE_MOCK); + quoteMock.steps[0].items.push({ + data: { + chainId: 1, + from: FROM_MOCK, + to: '0x3' as Hex, + data: '0x456' as Hex, + }, + } as never); + + successfulFetchMock.mockResolvedValue({ + json: async () => quoteMock, + } as never); + + estimateGasBatchMock.mockRejectedValue( + new Error('Batch estimation failed'), + ); + + await getRelayQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + expect(calculateGasCostMock).toHaveBeenCalledWith( + expect.objectContaining({ gas: 900000 + 21000 }), + ); + }); + + it('includes gas limits in quote', async () => { + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as never); + + const result = await getRelayQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + expect(result[0].original.metamask).toStrictEqual({ + gasLimits: [21000], + }); + }); + + it('includes empty value when not defined', async () => { + const quoteMock = cloneDeep(QUOTE_MOCK); + delete quoteMock.steps[0].items[0].data.value; + delete quoteMock.steps[0].items[0].data.gas; + + successfulFetchMock.mockResolvedValue({ + json: async () => quoteMock, + } as never); + + await getRelayQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + expect(estimateGasMock).toHaveBeenCalledWith( + expect.objectContaining({ value: '0x0' }), + NETWORK_CLIENT_ID_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 e317668609..99f41977c2 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -2,14 +2,17 @@ import { Interface } from '@ethersproject/abi'; import { successfulFetch, toHex } from '@metamask/controller-utils'; -import type { Hex, Json } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; import { createModuleLogger } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; import { CHAIN_ID_HYPERCORE, TOKEN_TRANSFER_FOUR_BYTE } from './constants'; import type { RelayQuote, RelayQuoteRequest } from './types'; import { TransactionPayStrategy } from '../..'; -import type { TransactionMeta } from '../../../../transaction-controller/src'; +import type { + BatchTransactionParams, + TransactionMeta, +} from '../../../../transaction-controller/src'; import { ARBITRUM_USDC_ADDRESS, CHAIN_ID_ARBITRUM, @@ -26,7 +29,7 @@ import type { TransactionPayControllerMessenger, TransactionPayQuote, } from '../../types'; -import { getFeatureFlags } from '../../utils/feature-flags'; +import { getFeatureFlags, getGasBuffer } from '../../utils/feature-flags'; import { calculateGasCost, calculateGasFeeTokenCost } from '../../utils/gas'; import { getNativeToken, @@ -134,7 +137,7 @@ async function getSingleQuote( async function processTransactions( transaction: TransactionMeta, request: QuoteRequest, - requestBody: Record, + requestBody: RelayQuoteRequest, messenger: TransactionPayControllerMessenger, ): Promise { const { nestedTransactions, txParams } = transaction; @@ -171,6 +174,8 @@ async function processTransactions( ...a, chainId: Number(a.chainId), nonce: Number(a.nonce), + r: a.r as Hex, + s: a.s as Hex, yParity: Number(a.yParity), }), ); @@ -272,8 +277,11 @@ async function normalizeQuote( usdToFiatRate, ); - const { isGasFeeToken: isSourceGasFeeToken, ...sourceNetwork } = - await calculateSourceNetworkCost(quote, messenger, request); + const { + gasLimits, + isGasFeeToken: isSourceGasFeeToken, + ...sourceNetwork + } = await calculateSourceNetworkCost(quote, messenger, request); const targetNetwork = { usd: '0', @@ -286,6 +294,10 @@ async function normalizeQuote( ...getFiatValueFromUsd(new BigNumber(currencyIn.amountUsd), usdToFiatRate), }; + const metamask = { + gasLimits, + }; + return { dust, estimatedDuration: details.timeEstimate, @@ -295,7 +307,10 @@ async function normalizeQuote( sourceNetwork, targetNetwork, }, - original: quote, + original: { + ...quote, + metamask, + }, request, sourceAmount, strategy: TransactionPayStrategy.Relay, @@ -395,6 +410,7 @@ async function calculateSourceNetworkCost( request: QuoteRequest, ): Promise< TransactionPayQuote['fees']['sourceNetwork'] & { + gasLimits: number[]; isGasFeeToken?: boolean; } > { @@ -409,27 +425,18 @@ async function calculateSourceNetworkCost( const { chainId, data, maxFeePerGas, maxPriorityFeePerGas, to, value } = allParams[0]; - const totalGasLimitEstimate = calculateSourceNetworkGasLimit( - allParams, - messenger, - { - isMax: false, - }, - ); + const { totalGasEstimate, totalGasLimit, gasLimits } = + await calculateSourceNetworkGasLimit(allParams, messenger); - const totalGasLimitMax = calculateSourceNetworkGasLimit( - allParams, - messenger, - { - isMax: true, - }, - ); - - log('Total gas limit', { totalGasLimitEstimate, totalGasLimitMax }); + log('Gas limit', { + totalGasEstimate, + totalGasLimit, + gasLimits, + }); const estimate = calculateGasCost({ chainId, - gas: totalGasLimitEstimate, + gas: totalGasEstimate, maxFeePerGas, maxPriorityFeePerGas, messenger, @@ -437,7 +444,7 @@ async function calculateSourceNetworkCost( const max = calculateGasCost({ chainId, - gas: totalGasLimitMax, + gas: totalGasLimit, maxFeePerGas, maxPriorityFeePerGas, messenger, @@ -451,8 +458,10 @@ async function calculateSourceNetworkCost( getNativeToken(sourceChainId), ); + const result = { estimate, max, gasLimits }; + if (new BigNumber(nativeBalance).isGreaterThanOrEqualTo(max.raw)) { - return { estimate, max }; + return result; } if (relayDisabledGasStationChains.includes(sourceChainId)) { @@ -461,7 +470,7 @@ async function calculateSourceNetworkCost( disabledChainIds: relayDisabledGasStationChains, }); - return { estimate, max }; + return result; } log('Checking gas fee tokens as insufficient native balance', { @@ -494,7 +503,7 @@ async function calculateSourceNetworkCost( gasFeeTokens, }); - return { estimate, max }; + return result; } let finalAmount = gasFeeToken.amount; @@ -505,14 +514,14 @@ async function calculateSourceNetworkCost( 16, ); - const finalAmountValue = gasRate.multipliedBy(totalGasLimitEstimate); + const finalAmountValue = gasRate.multipliedBy(totalGasEstimate); finalAmount = toHex(finalAmountValue.toFixed(0)); log('Estimated gas fee token amount for batch', { finalAmount: finalAmountValue.toString(10), gasRate: gasRate.toString(10), - totalGasLimitEstimate, + totalGasEstimate, }); } @@ -525,7 +534,7 @@ async function calculateSourceNetworkCost( }); if (!gasFeeTokenCost) { - return { estimate, max }; + return result; } log('Using gas fee token for source network', { @@ -536,6 +545,7 @@ async function calculateSourceNetworkCost( isGasFeeToken: true, estimate: gasFeeTokenCost, max: gasFeeTokenCost, + gasLimits, }; } @@ -544,38 +554,21 @@ async function calculateSourceNetworkCost( * * @param params - Array of transaction parameters. * @param messenger - Controller messenger. - * @param options - Options. - * @param options.isMax - Whether to calculate the maximum gas limit. * @returns - Total gas limit. */ -function calculateSourceNetworkGasLimit( +async function calculateSourceNetworkGasLimit( params: RelayQuote['steps'][0]['items'][0]['data'][], messenger: TransactionPayControllerMessenger, - { isMax }: { isMax: boolean }, -): number { - const allParamsHasGas = params.every( - (singleParams) => singleParams.gas !== undefined, - ); - - if (allParamsHasGas) { - return params.reduce( - (total, singleParams) => - total + new BigNumber(singleParams.gas as string).toNumber(), - 0, - ); +): Promise<{ + totalGasEstimate: number; + totalGasLimit: number; + gasLimits: number[]; +}> { + if (params.length === 1) { + return calculateSourceNetworkGasLimitSingle(params[0], messenger); } - // In future, call `TransactionController:estimateGas` - // or `TransactionController:estimateGasBatch` based on params length. - - const fallbackGas = getFeatureFlags(messenger).relayFallbackGas; - - return params.reduce((total, singleParams) => { - const fallback = isMax ? fallbackGas.max : fallbackGas.estimate; - const gas = singleParams.gas ?? fallback; - - return total + new BigNumber(gas).toNumber(); - }, 0); + return calculateSourceNetworkGasLimitBatch(params, messenger); } /** @@ -607,8 +600,191 @@ function buildTokenTransferData(recipient: Hex, amountRaw: string): Hex { * @param data - Token transfer data. * @returns Transfer recipient. */ -function getTransferRecipient(data: Hex): Hex | undefined { +function getTransferRecipient(data: Hex): Hex { return new Interface(['function transfer(address to, uint256 amount)']) .decodeFunctionData('transfer', data) .to.toLowerCase(); } + +async function calculateSourceNetworkGasLimitSingle( + params: RelayQuote['steps'][0]['items'][0]['data'], + messenger: TransactionPayControllerMessenger, +): Promise<{ + totalGasEstimate: number; + totalGasLimit: number; + gasLimits: number[]; +}> { + const paramGasLimit = params.gas + ? new BigNumber(params.gas).toNumber() + : undefined; + + if (paramGasLimit) { + log('Using single gas limit from params', { paramGasLimit }); + + return { + totalGasEstimate: paramGasLimit, + totalGasLimit: paramGasLimit, + gasLimits: [paramGasLimit], + }; + } + + try { + const { + chainId: chainIdNumber, + data, + from, + to, + value: valueString, + } = params; + + const chainId = toHex(chainIdNumber); + const value = toHex(valueString ?? '0'); + const gasBuffer = getGasBuffer(messenger, chainId); + + const networkClientId = messenger.call( + 'NetworkController:findNetworkClientIdByChainId', + chainId, + ); + + const { gas: gasHex, simulationFails } = await messenger.call( + 'TransactionController:estimateGas', + { from, data, to, value }, + networkClientId, + ); + + const estimatedGas = new BigNumber(gasHex).toNumber(); + const bufferedGas = Math.ceil(estimatedGas * gasBuffer); + + if (!simulationFails) { + log('Estimated gas limit for single transaction', { + chainId, + estimatedGas, + bufferedGas, + gasBuffer, + }); + + return { + totalGasEstimate: bufferedGas, + totalGasLimit: bufferedGas, + gasLimits: [bufferedGas], + }; + } + } catch (error) { + log('Failed to estimate gas limit for single transaction', error); + } + + const fallbackGas = getFeatureFlags(messenger).relayFallbackGas; + + log('Using fallback gas for single transaction', { fallbackGas }); + + return { + totalGasEstimate: fallbackGas.estimate, + totalGasLimit: fallbackGas.max, + gasLimits: [fallbackGas.max], + }; +} + +/** + * Calculate the gas limits for a batch of transactions. + * + * @param params - Array of transaction parameters. + * @param messenger - Controller messenger. + * @returns - Gas limits. + */ +async function calculateSourceNetworkGasLimitBatch( + params: RelayQuote['steps'][0]['items'][0]['data'][], + messenger: TransactionPayControllerMessenger, +): Promise<{ + totalGasEstimate: number; + totalGasLimit: number; + gasLimits: number[]; +}> { + try { + const { chainId: chainIdNumber, from } = params[0]; + const chainId = toHex(chainIdNumber); + const gasBuffer = getGasBuffer(messenger, chainId); + + const transactions: BatchTransactionParams[] = params.map( + (singleParams) => ({ + ...singleParams, + gas: singleParams.gas ? toHex(singleParams.gas) : undefined, + maxFeePerGas: undefined, + maxPriorityFeePerGas: undefined, + value: toHex(singleParams.value ?? '0'), + }), + ); + + const paramGasLimits = params.map((singleParams) => + singleParams.gas ? new BigNumber(singleParams.gas).toNumber() : undefined, + ); + + const { totalGasLimit, gasLimits } = await messenger.call( + 'TransactionController:estimateGasBatch', + { + chainId, + from, + transactions, + }, + ); + + const bufferedGasLimits = gasLimits.map((limit, index) => { + const useBuffer = + gasLimits.length === 1 || paramGasLimits[index] === gasLimits[index]; + + const buffer = useBuffer ? gasBuffer : 1; + + return Math.ceil(limit * buffer); + }); + + const bufferedTotalGasLimit = bufferedGasLimits.reduce( + (acc, limit) => acc + limit, + 0, + ); + + log('Estimated gas limit for batch', { + chainId, + totalGasLimit, + gasLimits, + bufferedTotalGasLimit, + bufferedGasLimits, + gasBuffer, + }); + + return { + totalGasEstimate: bufferedTotalGasLimit, + totalGasLimit: bufferedTotalGasLimit, + gasLimits: bufferedGasLimits, + }; + } catch (error) { + log('Failed to estimate gas limit for batch', error); + } + + const fallbackGas = getFeatureFlags(messenger).relayFallbackGas; + + const totalGasEstimate = params.reduce((acc, singleParams) => { + const gas = singleParams.gas ?? fallbackGas.estimate; + return acc + new BigNumber(gas).toNumber(); + }, 0); + + const gasLimits = params.map((singleParams) => { + const gas = singleParams.gas ?? fallbackGas.max; + return new BigNumber(gas).toNumber(); + }); + + const totalGasLimit = gasLimits.reduce( + (acc, singleGasLimit) => acc + singleGasLimit, + 0, + ); + + log('Using fallback gas for batch', { + totalGasEstimate, + totalGasLimit, + gasLimits, + }); + + return { + totalGasEstimate, + totalGasLimit, + gasLimits, + }; +} 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 2c1966205c..8208c74517 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 @@ -1,8 +1,4 @@ -import { - ORIGIN_METAMASK, - successfulFetch, - toHex, -} from '@metamask/controller-utils'; +import { ORIGIN_METAMASK, successfulFetch } from '@metamask/controller-utils'; import { TransactionType } from '@metamask/transaction-controller'; import type { TransactionMeta } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; @@ -60,6 +56,9 @@ const ORIGINAL_QUOTE_MOCK = { }, }, }, + metamask: { + gasLimits: [21000, 21000], + }, request: {}, steps: [ { @@ -186,6 +185,7 @@ describe('Relay Submit Utils', () => { networkClientId: NETWORK_CLIENT_ID_MOCK, origin: ORIGIN_METAMASK, requireApproval: false, + type: TransactionType.relayDeposit, }, ); }); @@ -296,6 +296,8 @@ describe('Relay Submit Utils', () => { expect(addTransactionBatchMock).toHaveBeenCalledTimes(1); expect(addTransactionBatchMock).toHaveBeenCalledWith({ + disableHook: false, + disableSequential: false, from: FROM_MOCK, networkClientId: NETWORK_CLIENT_ID_MOCK, origin: ORIGIN_METAMASK, @@ -322,6 +324,7 @@ describe('Relay Submit Utils', () => { to: '0xfedcb', value: '0x4d2', }, + type: TransactionType.relayDeposit, }, ], }); @@ -356,7 +359,7 @@ describe('Relay Submit Utils', () => { expect(addTransactionMock).toHaveBeenCalledTimes(1); expect(addTransactionMock).toHaveBeenCalledWith( expect.objectContaining({ - gas: toHex(123), + gas: '0x5208', value: '0x0', }), expect.anything(), @@ -469,5 +472,67 @@ describe('Relay Submit Utils', () => { TRANSACTION_META_MOCK.id, ]); }); + + it('adds transaction batch with single gasLimit7702', async () => { + request.quotes[0].original.steps[0].items.push({ + ...request.quotes[0].original.steps[0].items[0], + }); + + request.quotes[0].original.metamask.gasLimits = [42000]; + + await submitRelayQuotes(request); + + expect(addTransactionBatchMock).toHaveBeenCalledTimes(1); + expect(addTransactionBatchMock).toHaveBeenCalledWith( + expect.objectContaining({ + disableHook: true, + disableSequential: true, + gasLimit7702: '0xa410', + transactions: [ + expect.objectContaining({ + params: expect.objectContaining({ + gas: undefined, + }), + }), + expect.objectContaining({ + params: expect.objectContaining({ + gas: 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], + }); + + request.quotes[0].original.metamask.gasLimits = [21000, 22000]; + + await submitRelayQuotes(request); + + expect(addTransactionBatchMock).toHaveBeenCalledTimes(1); + expect(addTransactionBatchMock).toHaveBeenCalledWith( + expect.objectContaining({ + disableHook: false, + disableSequential: false, + gasLimit7702: undefined, + transactions: [ + expect.objectContaining({ + params: expect.objectContaining({ + gas: '0x5208', + }), + }), + expect.objectContaining({ + params: expect.objectContaining({ + gas: '0x55f0', + }), + }), + ], + }), + ); + }); }); }); 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 ff38f254f5..43eb2d1c6f 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts @@ -247,36 +247,56 @@ async function submitTransactions( })) : undefined; + const { gasLimits } = quote.original.metamask; + if (params.length === 1) { + const transactionParams = { + ...normalizedParams[0], + authorizationList, + gas: toHex(gasLimits[0]), + }; + result = await messenger.call( 'TransactionController:addTransaction', - { ...normalizedParams[0], authorizationList }, + transactionParams, { gasFeeToken, networkClientId, origin: ORIGIN_METAMASK, requireApproval: false, + type: TransactionType.relayDeposit, }, ); } else { + const gasLimit7702 = + gasLimits.length === 1 ? toHex(gasLimits[0]) : undefined; + + const transactions = normalizedParams.map((singleParams, index) => ({ + params: { + data: singleParams.data as Hex, + gas: gasLimit7702 ? undefined : toHex(gasLimits[index]), + maxFeePerGas: singleParams.maxFeePerGas as Hex, + maxPriorityFeePerGas: singleParams.maxPriorityFeePerGas as Hex, + to: singleParams.to as Hex, + value: singleParams.value as Hex, + }, + type: + index === 0 + ? TransactionType.tokenMethodApprove + : TransactionType.relayDeposit, + })); + await messenger.call('TransactionController:addTransactionBatch', { from, + disableHook: Boolean(gasLimit7702), + disableSequential: Boolean(gasLimit7702), gasFeeToken, + gasLimit7702, networkClientId, origin: ORIGIN_METAMASK, overwriteUpgrade: true, requireApproval: false, - transactions: normalizedParams.map((singleParams, index) => ({ - params: { - data: singleParams.data as Hex, - gas: singleParams.gas as Hex, - maxFeePerGas: singleParams.maxFeePerGas as Hex, - maxPriorityFeePerGas: singleParams.maxPriorityFeePerGas as Hex, - to: singleParams.to as Hex, - value: singleParams.value as Hex, - }, - type: index === 0 ? TransactionType.tokenMethodApprove : undefined, - })), + transactions, }); } diff --git a/packages/transaction-pay-controller/src/strategy/relay/types.ts b/packages/transaction-pay-controller/src/strategy/relay/types.ts index cbf17d4929..91fab5f6d1 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/types.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/types.ts @@ -56,6 +56,9 @@ export type RelayQuote = { amountUsd: string; }; }; + metamask: { + gasLimits: number[]; + }; request: RelayQuoteRequest; steps: { items: { @@ -71,7 +74,7 @@ export type RelayQuote = { maxFeePerGas: string; maxPriorityFeePerGas: string; to: Hex; - value: string; + value?: string; }; status: 'complete' | 'incomplete'; }[]; diff --git a/packages/transaction-pay-controller/src/tests/messenger-mock.ts b/packages/transaction-pay-controller/src/tests/messenger-mock.ts index 826b358d24..b6d69d4f22 100644 --- a/packages/transaction-pay-controller/src/tests/messenger-mock.ts +++ b/packages/transaction-pay-controller/src/tests/messenger-mock.ts @@ -15,6 +15,8 @@ import type { RemoteFeatureFlagControllerGetStateAction } from '@metamask/remote import type { TransactionControllerAddTransactionAction, TransactionControllerAddTransactionBatchAction, + TransactionControllerEstimateGasAction, + TransactionControllerEstimateGasBatchAction, TransactionControllerGetGasFeeTokensAction, TransactionControllerGetStateAction, } from '@metamask/transaction-controller'; @@ -117,6 +119,14 @@ export function getMessengerMock({ TransactionControllerGetGasFeeTokensAction['handler'] > = jest.fn(); + const estimateGasMock: jest.MockedFn< + TransactionControllerEstimateGasAction['handler'] + > = jest.fn(); + + const estimateGasBatchMock: jest.MockedFn< + TransactionControllerEstimateGasBatchAction['handler'] + > = jest.fn(); + const messenger: RootMessenger = new Messenger({ namespace: MOCK_ANY_NAMESPACE, }); @@ -221,6 +231,16 @@ export function getMessengerMock({ 'TransactionController:getGasFeeTokens', getGasFeeTokensMock, ); + + messenger.registerActionHandler( + 'TransactionController:estimateGas', + estimateGasMock, + ); + + messenger.registerActionHandler( + 'TransactionController:estimateGasBatch', + estimateGasBatchMock, + ); } const publish = messenger.publish.bind(messenger); @@ -228,6 +248,8 @@ export function getMessengerMock({ return { addTransactionMock, addTransactionBatchMock, + estimateGasMock, + estimateGasBatchMock, fetchQuotesMock, findNetworkClientIdByChainIdMock, getAccountTrackerControllerStateMock, diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index fa13bada97..1a2595f6d3 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -19,6 +19,8 @@ import type { RemoteFeatureFlagControllerGetStateAction } from '@metamask/remote import type { AuthorizationList, TransactionControllerAddTransactionBatchAction, + TransactionControllerEstimateGasAction, + TransactionControllerEstimateGasBatchAction, TransactionControllerUnapprovedTransactionAddedEvent, } from '@metamask/transaction-controller'; import type { @@ -50,6 +52,8 @@ export type AllowedActions = | TokensControllerGetStateAction | TransactionControllerAddTransactionAction | TransactionControllerAddTransactionBatchAction + | TransactionControllerEstimateGasAction + | TransactionControllerEstimateGasBatchAction | TransactionControllerGetGasFeeTokensAction | TransactionControllerGetStateAction | TransactionControllerUpdateTransactionAction; diff --git a/packages/transaction-pay-controller/src/utils/feature-flags.ts b/packages/transaction-pay-controller/src/utils/feature-flags.ts index c18ae48fe9..9a40e182fe 100644 --- a/packages/transaction-pay-controller/src/utils/feature-flags.ts +++ b/packages/transaction-pay-controller/src/utils/feature-flags.ts @@ -7,12 +7,23 @@ import { RELAY_URL_BASE } from '../strategy/relay/constants'; const log = createModuleLogger(projectLogger, 'feature-flags'); +export const DEFAULT_GAS_BUFFER = 1.0; export const DEFAULT_RELAY_FALLBACK_GAS_ESTIMATE = 900000; export const DEFAULT_RELAY_FALLBACK_GAS_MAX = 1500000; export const DEFAULT_RELAY_QUOTE_URL = `${RELAY_URL_BASE}/quote`; export const DEFAULT_SLIPPAGE = 0.005; type FeatureFlagsRaw = { + gasBuffer?: { + default?: number; + perChainConfig?: Record< + Hex, + { + name?: string; + buffer?: number; + } + >; + }; relayDisabledGasStationChains?: Hex[]; relayFallbackGas?: { estimate?: number; @@ -41,10 +52,7 @@ export type FeatureFlags = { export function getFeatureFlags( messenger: TransactionPayControllerMessenger, ): FeatureFlags { - const state = messenger.call('RemoteFeatureFlagController:getState'); - - const featureFlags: FeatureFlagsRaw = - (state.remoteFeatureFlags.confirmations_pay as FeatureFlagsRaw) ?? {}; + const featureFlags = getFeatureFlagsRaw(messenger); const estimate = featureFlags.relayFallbackGas?.estimate ?? @@ -74,3 +82,36 @@ export function getFeatureFlags( return result; } + +/** + * Get the gas buffer value for a specific chain ID. + * + * @param messenger - Controller messenger. + * @param chainId - Chain ID to get gas buffer for. + * @returns Gas buffer value. + */ +export function getGasBuffer( + messenger: TransactionPayControllerMessenger, + chainId: Hex, +): number { + const featureFlags = getFeatureFlagsRaw(messenger); + + return ( + featureFlags.gasBuffer?.perChainConfig?.[chainId]?.buffer ?? + featureFlags.gasBuffer?.default ?? + DEFAULT_GAS_BUFFER + ); +} + +/** + * Get the raw feature flags from the remote feature flag controller. + * + * @param messenger - Controller messenger. + * @returns Raw feature flags. + */ +function getFeatureFlagsRaw( + messenger: TransactionPayControllerMessenger, +): FeatureFlagsRaw { + const state = messenger.call('RemoteFeatureFlagController:getState'); + return (state.remoteFeatureFlags.confirmations_pay as FeatureFlagsRaw) ?? {}; +} From ca348568cfe398e7ca799bd6cf6bc42767b84813 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Tue, 9 Dec 2025 12:56:59 +0000 Subject: [PATCH 2/4] Add buffer unit tests --- .../src/strategy/relay/relay-quotes.test.ts | 179 +++++++++++++++++- .../src/strategy/relay/relay-quotes.ts | 2 +- .../src/utils/feature-flags.test.ts | 99 ++++++++++ 3 files changed, 278 insertions(+), 2 deletions(-) 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 dafcd58721..8f17983a1e 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 @@ -20,7 +20,10 @@ import type { GetDelegationTransactionCallback, QuoteRequest, } from '../../types'; -import { DEFAULT_RELAY_QUOTE_URL } from '../../utils/feature-flags'; +import { + DEFAULT_RELAY_QUOTE_URL, + getGasBuffer, +} from '../../utils/feature-flags'; import { calculateGasCost, calculateGasFeeTokenCost, @@ -34,6 +37,10 @@ import { jest.mock('../../utils/token'); jest.mock('../../utils/gas'); +jest.mock('../../utils/feature-flags', () => ({ + ...jest.requireActual('../../utils/feature-flags'), + getGasBuffer: jest.fn(), +})); jest.mock('@metamask/controller-utils', () => ({ ...jest.requireActual('@metamask/controller-utils'), @@ -139,6 +146,7 @@ describe('Relay Quotes Utils', () => { const calculateGasFeeTokenCostMock = jest.mocked(calculateGasFeeTokenCost); const getNativeTokenMock = jest.mocked(getNativeToken); const getTokenBalanceMock = jest.mocked(getTokenBalance); + const getGasBufferMock = jest.mocked(getGasBuffer); const calculateTransactionGasCostMock = jest.mocked( calculateTransactionGasCost, @@ -188,6 +196,7 @@ describe('Relay Quotes Utils', () => { remoteFeatureFlags: {}, }); + getGasBufferMock.mockReturnValue(1.0); getDelegationTransactionMock.mockResolvedValue(DELEGATION_RESULT_MOCK); getGasFeeTokensMock.mockResolvedValue([]); findNetworkClientIdByChainIdMock.mockReturnValue(NETWORK_CLIENT_ID_MOCK); @@ -1248,5 +1257,173 @@ describe('Relay Quotes Utils', () => { NETWORK_CLIENT_ID_MOCK, ); }); + + describe('gas buffer support', () => { + it('applies buffer to single transaction gas estimate', async () => { + const quoteMock = cloneDeep(QUOTE_MOCK); + delete quoteMock.steps[0].items[0].data.gas; + + successfulFetchMock.mockResolvedValue({ + json: async () => quoteMock, + } as never); + + estimateGasMock.mockResolvedValue({ + gas: toHex(50000), + simulationFails: undefined, + }); + + getGasBufferMock.mockReturnValue(1.5); + + await getRelayQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + expect(calculateGasCostMock).toHaveBeenCalledWith( + expect.objectContaining({ gas: 75000 }), + ); + }); + + it('applies buffer to batch transaction gas estimates when estimates do not match params', async () => { + const quoteMock = cloneDeep(QUOTE_MOCK); + quoteMock.steps[0].items[0].data.gas = '30000'; + quoteMock.steps[0].items.push({ + data: { + chainId: 1, + from: FROM_MOCK, + to: '0x3' as Hex, + data: '0x456' as Hex, + gas: '40000', + }, + } as never); + + successfulFetchMock.mockResolvedValue({ + json: async () => quoteMock, + } as never); + + estimateGasBatchMock.mockResolvedValue({ + totalGasLimit: 80000, + gasLimits: [35000, 45000], + }); + + getGasBufferMock.mockReturnValue(1.5); + + await getRelayQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + expect(calculateGasCostMock).toHaveBeenCalledWith( + expect.objectContaining({ gas: 120000 }), + ); + }); + + it('does not apply buffer to batch transaction gas estimates when estimates match params', async () => { + const quoteMock = cloneDeep(QUOTE_MOCK); + quoteMock.steps[0].items[0].data.gas = '30000'; + quoteMock.steps[0].items.push({ + data: { + chainId: 1, + from: FROM_MOCK, + to: '0x3' as Hex, + data: '0x456' as Hex, + gas: '40000', + }, + } as never); + + successfulFetchMock.mockResolvedValue({ + json: async () => quoteMock, + } as never); + + estimateGasBatchMock.mockResolvedValue({ + totalGasLimit: 70000, + gasLimits: [30000, 40000], + }); + + getGasBufferMock.mockReturnValue(1.5); + + await getRelayQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + expect(calculateGasCostMock).toHaveBeenCalledWith( + expect.objectContaining({ gas: 70000 }), + ); + }); + + it('applies buffer to batch with single transaction', async () => { + const quoteMock = cloneDeep(QUOTE_MOCK); + quoteMock.steps[0].items[0].data.gas = '30000'; + quoteMock.steps[0].items.push({ + data: { + chainId: 1, + from: FROM_MOCK, + to: '0x3' as Hex, + data: '0x456' as Hex, + gas: '40000', + }, + } as never); + + successfulFetchMock.mockResolvedValue({ + json: async () => quoteMock, + } as never); + + estimateGasBatchMock.mockResolvedValue({ + totalGasLimit: 60000, + gasLimits: [60000], + }); + + getGasBufferMock.mockReturnValue(1.5); + + await getRelayQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + expect(calculateGasCostMock).toHaveBeenCalledWith( + expect.objectContaining({ gas: 90000 }), + ); + }); + + it('applies mixed buffer to batch transactions when some match params and others do not', async () => { + const quoteMock = cloneDeep(QUOTE_MOCK); + quoteMock.steps[0].items[0].data.gas = '30000'; + quoteMock.steps[0].items.push({ + data: { + chainId: 1, + from: FROM_MOCK, + to: '0x3' as Hex, + data: '0x456' as Hex, + gas: '40000', + }, + } as never); + + successfulFetchMock.mockResolvedValue({ + json: async () => quoteMock, + } as never); + + estimateGasBatchMock.mockResolvedValue({ + totalGasLimit: 70000, + gasLimits: [30000, 50000], + }); + + getGasBufferMock.mockReturnValue(1.5); + + await getRelayQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + expect(calculateGasCostMock).toHaveBeenCalledWith( + expect.objectContaining({ gas: 105000 }), + ); + }); + }); }); }); 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 99f41977c2..3ddb047878 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -729,7 +729,7 @@ async function calculateSourceNetworkGasLimitBatch( const bufferedGasLimits = gasLimits.map((limit, index) => { const useBuffer = - gasLimits.length === 1 || paramGasLimits[index] === gasLimits[index]; + gasLimits.length === 1 || paramGasLimits[index] !== gasLimits[index]; const buffer = useBuffer ? gasBuffer : 1; diff --git a/packages/transaction-pay-controller/src/utils/feature-flags.test.ts b/packages/transaction-pay-controller/src/utils/feature-flags.test.ts index c5c29e9177..31e789d8e7 100644 --- a/packages/transaction-pay-controller/src/utils/feature-flags.test.ts +++ b/packages/transaction-pay-controller/src/utils/feature-flags.test.ts @@ -1,9 +1,11 @@ import { + DEFAULT_GAS_BUFFER, DEFAULT_RELAY_FALLBACK_GAS_ESTIMATE, DEFAULT_RELAY_FALLBACK_GAS_MAX, DEFAULT_RELAY_QUOTE_URL, DEFAULT_SLIPPAGE, getFeatureFlags, + getGasBuffer, } from './feature-flags'; import { getMessengerMock } from '../tests/messenger-mock'; @@ -12,6 +14,10 @@ const GAS_FALLBACK_MAX_MOCK = 456; const RELAY_QUOTE_URL_MOCK = 'https://test.com/test'; const RELAY_GAS_STATION_DISABLED_CHAINS_MOCK = ['0x1', '0x2']; const SLIPPAGE_MOCK = 0.01; +const GAS_BUFFER_DEFAULT_MOCK = 1.5; +const GAS_BUFFER_CHAIN_SPECIFIC_MOCK = 2.0; +const CHAIN_ID_MOCK = '0x1'; +const CHAIN_ID_DIFFERENT_MOCK = '0x89'; describe('Feature Flags Utils', () => { const { messenger, getRemoteFeatureFlagControllerStateMock } = @@ -71,4 +77,97 @@ describe('Feature Flags Utils', () => { }); }); }); + + describe('getGasBuffer', () => { + it('returns default gas buffer when none are set', () => { + const gasBuffer = getGasBuffer(messenger, CHAIN_ID_MOCK); + + expect(gasBuffer).toBe(DEFAULT_GAS_BUFFER); + }); + + it('returns default gas buffer from feature flags when set', () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + cacheTimestamp: 0, + remoteFeatureFlags: { + confirmations_pay: { + gasBuffer: { + default: GAS_BUFFER_DEFAULT_MOCK, + }, + }, + }, + }); + + const gasBuffer = getGasBuffer(messenger, CHAIN_ID_MOCK); + + expect(gasBuffer).toBe(GAS_BUFFER_DEFAULT_MOCK); + }); + + it('returns per-chain gas buffer when set for specific chain', () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + cacheTimestamp: 0, + remoteFeatureFlags: { + confirmations_pay: { + gasBuffer: { + default: GAS_BUFFER_DEFAULT_MOCK, + perChainConfig: { + [CHAIN_ID_MOCK]: { + name: 'Ethereum Mainnet', + buffer: GAS_BUFFER_CHAIN_SPECIFIC_MOCK, + }, + }, + }, + }, + }, + }); + + const gasBuffer = getGasBuffer(messenger, CHAIN_ID_MOCK); + + expect(gasBuffer).toBe(GAS_BUFFER_CHAIN_SPECIFIC_MOCK); + }); + + it('falls back to default gas buffer when per-chain config exists but specific chain is not found', () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + cacheTimestamp: 0, + remoteFeatureFlags: { + confirmations_pay: { + gasBuffer: { + default: GAS_BUFFER_DEFAULT_MOCK, + perChainConfig: { + [CHAIN_ID_MOCK]: { + name: 'Ethereum Mainnet', + buffer: GAS_BUFFER_CHAIN_SPECIFIC_MOCK, + }, + }, + }, + }, + }, + }); + + const gasBuffer = getGasBuffer(messenger, CHAIN_ID_DIFFERENT_MOCK); + + expect(gasBuffer).toBe(GAS_BUFFER_DEFAULT_MOCK); + }); + + it('falls back to hardcoded default when per-chain config exists but no default is set', () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + cacheTimestamp: 0, + remoteFeatureFlags: { + confirmations_pay: { + gasBuffer: { + perChainConfig: { + [CHAIN_ID_MOCK]: { + name: 'Ethereum Mainnet', + buffer: GAS_BUFFER_CHAIN_SPECIFIC_MOCK, + }, + }, + }, + }, + }, + }); + + const gasBuffer = getGasBuffer(messenger, CHAIN_ID_DIFFERENT_MOCK); + + expect(gasBuffer).toBe(DEFAULT_GAS_BUFFER); + }); + }); }); From 70f6da567a3e6bdb670a2c66239b4f12d232490a Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Tue, 9 Dec 2025 14:40:15 +0000 Subject: [PATCH 3/4] Disable 7702 if multiple gas limits --- .../src/strategy/relay/relay-submit.test.ts | 3 +++ .../src/strategy/relay/relay-submit.ts | 1 + 2 files changed, 4 insertions(+) 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 8208c74517..adce74ba80 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 @@ -296,6 +296,7 @@ describe('Relay Submit Utils', () => { expect(addTransactionBatchMock).toHaveBeenCalledTimes(1); expect(addTransactionBatchMock).toHaveBeenCalledWith({ + disable7702: true, disableHook: false, disableSequential: false, from: FROM_MOCK, @@ -485,6 +486,7 @@ describe('Relay Submit Utils', () => { expect(addTransactionBatchMock).toHaveBeenCalledTimes(1); expect(addTransactionBatchMock).toHaveBeenCalledWith( expect.objectContaining({ + disable7702: false, disableHook: true, disableSequential: true, gasLimit7702: '0xa410', @@ -516,6 +518,7 @@ describe('Relay Submit Utils', () => { expect(addTransactionBatchMock).toHaveBeenCalledTimes(1); expect(addTransactionBatchMock).toHaveBeenCalledWith( expect.objectContaining({ + disable7702: true, disableHook: false, disableSequential: false, gasLimit7702: undefined, 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 43eb2d1c6f..30a18d54df 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts @@ -288,6 +288,7 @@ async function submitTransactions( await messenger.call('TransactionController:addTransactionBatch', { from, + disable7702: !gasLimit7702, disableHook: Boolean(gasLimit7702), disableSequential: Boolean(gasLimit7702), gasFeeToken, From 565150e957dbec22851bdf66e385e28746df1eac Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Tue, 9 Dec 2025 14:42:44 +0000 Subject: [PATCH 4/4] Fix changelog --- packages/transaction-controller/CHANGELOG.md | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index 6e0e0511cd..93779d3a5c 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -20,11 +20,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Prevent `TransactionController:transactionApproved` event firing if keyring throws during signing ([#7410](https://github.com/MetaMask/core/pull/7410)) -### Added - -- Add `estimateGasBatch` function and messenger action to estimate gas for batch transactions ([#7405](https://github.com/MetaMask/core/pull/7405)) - - Add optional `gasLimit7702` property to `TransactionBatchRequest`. - ## [62.5.0] ### Changed