From f832f375e759fad9a722c885c23a62f0efd285d3 Mon Sep 17 00:00:00 2001 From: Pedro Figueiredo Date: Mon, 9 Mar 2026 11:16:55 +0000 Subject: [PATCH 1/3] feat(transaction-pay-controller): Add shared EIP-7702 quote gas estimation --- .../transaction-pay-controller/CHANGELOG.md | 1 + .../src/strategy/across/across-quotes.test.ts | 242 ++++++++++- .../src/strategy/across/across-quotes.ts | 163 +++---- .../src/strategy/across/across-submit.test.ts | 89 ++++ .../src/strategy/across/across-submit.ts | 91 ++-- .../src/strategy/across/transactions.test.ts | 51 +++ .../src/strategy/across/transactions.ts | 40 ++ .../src/strategy/across/types.ts | 18 +- .../src/strategy/relay/relay-quotes.test.ts | 216 +++++++++- .../src/strategy/relay/relay-quotes.ts | 253 +++-------- .../src/utils/feature-flags.ts | 26 +- .../src/utils/quote-gas.test.ts | 398 ++++++++++++++++++ .../src/utils/quote-gas.ts | 269 ++++++++++++ 13 files changed, 1512 insertions(+), 345 deletions(-) create mode 100644 packages/transaction-pay-controller/src/strategy/across/transactions.test.ts create mode 100644 packages/transaction-pay-controller/src/strategy/across/transactions.ts create mode 100644 packages/transaction-pay-controller/src/utils/quote-gas.test.ts create mode 100644 packages/transaction-pay-controller/src/utils/quote-gas.ts diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 1b95077c939..f3cf2f1becf 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -55,6 +55,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/assets-controllers` from `^100.2.0` to `^100.2.1` ([#8162](https://github.com/MetaMask/core/pull/8162)) - Bump `@metamask/bridge-controller` from `^69.0.0` to `^69.1.0` ([#8162](https://github.com/MetaMask/core/pull/8162), [#8168](https://github.com/MetaMask/core/pull/8168)) - Bump `@metamask/bridge-status-controller` from `^68.0.1` to `^68.1.0` ([#8162](https://github.com/MetaMask/core/pull/8162), [#8168](https://github.com/MetaMask/core/pull/8168)) +- Use shared quote gas estimation for Across and Relay, including EIP-7702 batch estimation on supported source chains with per-transaction fallback when batching is unavailable or fails ([#8145](https://github.com/MetaMask/core/pull/8145)) ### Fixed 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 b1c50ebb43c..7453adf389d 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 @@ -5,13 +5,19 @@ import type { TransactionMeta } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; import { getAcrossQuotes } from './across-quotes'; +import * as acrossTransactions from './transactions'; import type { AcrossSwapApprovalResponse } from './types'; import { getDefaultRemoteFeatureFlagControllerState } from '../../../../remote-feature-flag-controller/src/remote-feature-flag-controller'; import { TransactionPayStrategy } from '../../constants'; import { getMessengerMock } from '../../tests/messenger-mock'; import type { QuoteRequest } from '../../types'; -import { getGasBuffer, getSlippage } from '../../utils/feature-flags'; +import { + getGasBuffer, + isEIP7702Chain, + getSlippage, +} from '../../utils/feature-flags'; import { calculateGasCost } from '../../utils/gas'; +import * as quoteGasUtils from '../../utils/quote-gas'; import { getTokenFiatRate } from '../../utils/token'; jest.mock('../../utils/token'); @@ -22,6 +28,7 @@ jest.mock('../../utils/gas', () => ({ jest.mock('../../utils/feature-flags', () => ({ ...jest.requireActual('../../utils/feature-flags'), getGasBuffer: jest.fn(), + isEIP7702Chain: jest.fn(), getSlippage: jest.fn(), })); @@ -109,12 +116,14 @@ describe('Across Quotes', () => { const successfulFetchMock = jest.mocked(successfulFetch); const getTokenFiatRateMock = jest.mocked(getTokenFiatRate); const getGasBufferMock = jest.mocked(getGasBuffer); + const isEIP7702ChainMock = jest.mocked(isEIP7702Chain); const getSlippageMock = jest.mocked(getSlippage); const calculateGasCostMock = jest.mocked(calculateGasCost); const { messenger, estimateGasMock, + estimateGasBatchMock, findNetworkClientIdByChainIdMock, getRemoteFeatureFlagControllerStateMock, } = getMessengerMock(); @@ -149,6 +158,7 @@ describe('Across Quotes', () => { }); getGasBufferMock.mockReturnValue(1.0); + isEIP7702ChainMock.mockReturnValue(false); getSlippageMock.mockReturnValue(0.005); findNetworkClientIdByChainIdMock.mockReturnValue('mainnet'); @@ -809,6 +819,127 @@ describe('Across Quotes', () => { }); }); + it('uses batch gas estimation on EIP-7702-supported chains when multiple transactions are submitted', async () => { + isEIP7702ChainMock.mockReturnValue(true); + estimateGasBatchMock.mockResolvedValue({ + totalGasLimit: 51000, + gasLimits: [51000], + }); + + 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(estimateGasBatchMock).toHaveBeenCalledWith({ + chainId: '0x1', + from: FROM_MOCK, + transactions: [ + expect.objectContaining({ + data: '0xaaaa', + to: '0xapprove1', + value: '0x1', + }), + expect.objectContaining({ + data: QUOTE_MOCK.swapTx.data, + to: QUOTE_MOCK.swapTx.to, + }), + ], + }); + expect( + (result[0].original.metamask.gasLimits as { batch?: unknown }).batch, + ).toStrictEqual({ + estimate: 51000, + max: 51000, + }); + expect(calculateGasCostMock).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + chainId: '0x1', + gas: 51000, + maxFeePerGas: '0x1', + maxPriorityFeePerGas: '0x1', + }), + ); + expect(calculateGasCostMock).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + chainId: '0x1', + gas: 51000, + isMax: true, + maxFeePerGas: '0x1', + maxPriorityFeePerGas: '0x1', + }), + ); + }); + + it('falls back to per-transaction gas estimation when batch estimation fails on EIP-7702-supported chains', async () => { + isEIP7702ChainMock.mockReturnValue(true); + estimateGasBatchMock.mockRejectedValue( + new Error('Batch estimation failed'), + ); + estimateGasMock + .mockResolvedValueOnce({ + gas: '0x7530', + simulationFails: undefined, + }) + .mockResolvedValueOnce({ + 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(estimateGasBatchMock).toHaveBeenCalledTimes(1); + expect(estimateGasMock).toHaveBeenCalledTimes(2); + expect( + (result[0].original.metamask.gasLimits as { batch?: unknown }).batch, + ).toBeUndefined(); + expect(result[0].original.metamask.gasLimits.approval).toStrictEqual([ + { + estimate: 30000, + max: 30000, + }, + ]); + expect(result[0].original.metamask.gasLimits.swap).toStrictEqual({ + estimate: 21000, + max: 21000, + }); + }); + it('uses swapTx.gas from Across response when provided', async () => { successfulFetchMock.mockResolvedValue({ json: async () => ({ @@ -863,6 +994,115 @@ describe('Across Quotes', () => { }); }); + it('throws when the shared gas estimator omits the swap gas result', async () => { + const estimateQuoteGasLimitsSpy = jest.spyOn( + quoteGasUtils, + 'estimateQuoteGasLimits', + ); + + estimateQuoteGasLimitsSpy.mockResolvedValueOnce({ + gasLimits: [], + totalGasEstimate: 0, + totalGasLimit: 0, + usedBatch: false, + }); + + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as Response); + + await expect( + getAcrossQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }), + ).rejects.toThrow( + 'Failed to fetch Across quotes: Error: Across swap gas estimate missing', + ); + + estimateQuoteGasLimitsSpy.mockRestore(); + }); + + it('falls back to the swap chain id when an approval transaction chain id is missing during cost calculation', async () => { + const estimateQuoteGasLimitsSpy = jest.spyOn( + quoteGasUtils, + 'estimateQuoteGasLimits', + ); + const orderedTransactionsSpy = jest.spyOn( + acrossTransactions, + 'getAcrossOrderedTransactions', + ); + + estimateQuoteGasLimitsSpy.mockResolvedValueOnce({ + gasLimits: [ + { + estimate: 30000, + max: 35000, + source: 'estimated', + }, + { + estimate: 21000, + max: 22000, + source: 'estimated', + }, + ], + totalGasEstimate: 51000, + totalGasLimit: 57000, + usedBatch: false, + }); + orderedTransactionsSpy.mockReturnValueOnce([ + { + chainId: 1, + data: '0xaaaa' as Hex, + kind: 'approval', + to: '0xapprove1' as Hex, + }, + { + ...QUOTE_MOCK.swapTx, + kind: 'swap', + }, + ]); + + successfulFetchMock.mockResolvedValue({ + json: async () => ({ + ...QUOTE_MOCK, + approvalTxns: [ + { + chainId: undefined, + data: '0xaaaa' as Hex, + to: '0xapprove1' as Hex, + }, + ], + }), + } as Response); + + await getAcrossQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + expect(calculateGasCostMock).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + chainId: '0x1', + gas: 30000, + }), + ); + expect(calculateGasCostMock).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + chainId: '0x1', + gas: 35000, + isMax: true, + }), + ); + + orderedTransactionsSpy.mockRestore(); + estimateQuoteGasLimitsSpy.mockRestore(); + }); + it('handles missing approval transactions in Across quote response', async () => { successfulFetchMock.mockResolvedValue({ json: async () => ({ 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 2ef8eea5630..6e20a586c96 100644 --- a/packages/transaction-pay-controller/src/strategy/across/across-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/across/across-quotes.ts @@ -6,6 +6,7 @@ import type { Hex } from '@metamask/utils'; import { createModuleLogger } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; +import { getAcrossOrderedTransactions } from './transactions'; import type { AcrossAction, AcrossActionRequestBody, @@ -25,7 +26,8 @@ import type { } from '../../types'; import { getFiatValueFromUsd, sumAmounts } from '../../utils/amounts'; import { getPayStrategiesConfig, getSlippage } from '../../utils/feature-flags'; -import { calculateGasCost, estimateGasLimit } from '../../utils/gas'; +import { calculateGasCost } from '../../utils/gas'; +import { estimateQuoteGasLimits } from '../../utils/quote-gas'; import { getTokenFiatRate } from '../../utils/token'; import { TOKEN_TRANSFER_FOUR_BYTE } from '../relay/constants'; @@ -499,69 +501,91 @@ async function calculateSourceNetworkCost( const acrossFallbackGas = getPayStrategiesConfig(messenger).across.fallbackGas; const { from } = request; - const approvalTxns = quote.approvalTxns ?? []; + const orderedTransactions = getAcrossOrderedTransactions({ quote }); const { swapTx } = quote; const swapChainId = toHex(swapTx.chainId); + const gasEstimates = await estimateQuoteGasLimits({ + fallbackGas: acrossFallbackGas, + messenger, + transactions: orderedTransactions.map((transaction) => ({ + chainId: toHex(transaction.chainId), + data: transaction.data, + from, + gas: transaction.gas, + to: transaction.to, + value: transaction.value ?? '0x0', + })), + }); - const approvalGasResults = await Promise.all( - approvalTxns.map(async (approval) => { - const chainId = toHex(approval.chainId); - const gas = await estimateGasLimit({ - chainId, - data: approval.data, - fallbackGas: acrossFallbackGas, - from, - messenger, - to: approval.to, - value: approval.value ?? '0x0', + const batchGasLimit = + gasEstimates.usedBatch && + gasEstimates.gasLimits.length === 1 && + orderedTransactions.length > 1 + ? gasEstimates.gasLimits[0] + : undefined; + + if (batchGasLimit) { + const estimate = calculateGasCost({ + chainId: swapChainId, + gas: batchGasLimit.estimate, + maxFeePerGas: swapTx.maxFeePerGas, + maxPriorityFeePerGas: swapTx.maxPriorityFeePerGas, + messenger, + }); + const max = calculateGasCost({ + chainId: swapChainId, + gas: batchGasLimit.max, + isMax: true, + maxFeePerGas: swapTx.maxFeePerGas, + maxPriorityFeePerGas: swapTx.maxPriorityFeePerGas, + messenger, + }); + + return { + sourceNetwork: { + estimate, + max, + }, + gasLimits: { + batch: { + estimate: batchGasLimit.estimate, + max: batchGasLimit.max, + }, + }, + }; + } + + orderedTransactions.forEach((transaction, index) => { + const gasEstimate = gasEstimates.gasLimits[index]; + + if (gasEstimate?.source === 'fallback') { + log('Gas estimate failed, using fallback', { + error: gasEstimate.error, + transactionType: transaction.kind, }); + } - if (gas.usedFallback) { - log('Gas estimate failed, using fallback', { - error: gas.error, - transactionType: 'approval', - }); - } + if (transaction.kind === 'swap' && gasEstimate?.source === 'provided') { + log('Using Across-provided swap gas limit', { + gas: gasEstimate.estimate, + transactionType: transaction.kind, + }); + } + }); - return { chainId, gas }; - }), - ); + const approvalCount = quote.approvalTxns?.length ?? 0; + const approvalGasLimits = gasEstimates.gasLimits.slice(0, approvalCount); + const swapGas = gasEstimates.gasLimits[approvalCount]; - const swapGasFromQuote = parseAcrossSwapGasLimit(swapTx.gas); - const swapGas = - swapGasFromQuote === undefined - ? await estimateGasLimit({ - chainId: swapChainId, - data: swapTx.data, - fallbackGas: acrossFallbackGas, - from, - messenger, - to: swapTx.to, - value: swapTx.value ?? '0x0', - }) - : { - estimate: swapGasFromQuote, - max: swapGasFromQuote, - usedFallback: false, - }; - - if (swapGasFromQuote !== undefined) { - log('Using Across-provided swap gas limit', { - gas: swapGasFromQuote, - transactionType: 'swap', - }); - } else if (swapGas.usedFallback) { - log('Gas estimate failed, using fallback', { - error: swapGas.error, - transactionType: 'swap', - }); + if (!swapGas) { + throw new Error('Across swap gas estimate missing'); } const estimate = sumAmounts([ - ...approvalGasResults.map(({ chainId, gas }) => + ...approvalGasLimits.map((gasEstimate, index) => calculateGasCost({ - chainId, - gas: gas.estimate, + chainId: toHex(quote.approvalTxns?.[index]?.chainId ?? swapTx.chainId), + gas: gasEstimate.estimate, messenger, }), ), @@ -575,10 +599,10 @@ async function calculateSourceNetworkCost( ]); const max = sumAmounts([ - ...approvalGasResults.map(({ chainId, gas }) => + ...approvalGasLimits.map((gasEstimate, index) => calculateGasCost({ - chainId, - gas: gas.max, + chainId: toHex(quote.approvalTxns?.[index]?.chainId ?? swapTx.chainId), + gas: gasEstimate.max, isMax: true, messenger, }), @@ -599,9 +623,9 @@ async function calculateSourceNetworkCost( max, }, gasLimits: { - approval: approvalGasResults.map(({ gas }) => ({ - estimate: gas.estimate, - max: gas.max, + approval: approvalGasLimits.map((gasEstimate) => ({ + estimate: gasEstimate.estimate, + max: gasEstimate.max, })), swap: { estimate: swapGas.estimate, @@ -610,24 +634,3 @@ async function calculateSourceNetworkCost( }, }; } - -function parseAcrossSwapGasLimit(gas?: string): number | undefined { - if (!gas) { - return undefined; - } - - const parsedGas = gas.startsWith('0x') - ? new BigNumber(gas.slice(2), 16) - : new BigNumber(gas); - - if ( - !parsedGas.isFinite() || - parsedGas.isNaN() || - !parsedGas.isInteger() || - parsedGas.lte(0) - ) { - return undefined; - } - - return parsedGas.toNumber(); -} 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 bc414d1d5e9..dca58d15040 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 @@ -10,6 +10,7 @@ import type { import type { Hex } from '@metamask/utils'; import { submitAcrossQuotes } from './across-submit'; +import * as acrossTransactions from './transactions'; import type { AcrossQuote } from './types'; import { getDefaultRemoteFeatureFlagControllerState } from '../../../../remote-feature-flag-controller/src/remote-feature-flag-controller'; import { TransactionPayStrategy } from '../../constants'; @@ -209,6 +210,53 @@ describe('Across Submit', () => { ); }); + it('submits a 7702 batch when the quote contains a combined batch gas limit', async () => { + const batchGasQuote = { + ...QUOTE_MOCK, + original: { + ...QUOTE_MOCK.original, + metamask: { + gasLimits: { + batch: { + estimate: 43000, + max: 64000, + }, + }, + }, + }, + } as unknown as TransactionPayQuote; + + await submitAcrossQuotes({ + messenger, + quotes: [batchGasQuote], + transaction: TRANSACTION_META_MOCK, + isSmartTransaction: jest.fn(), + }); + + expect(addTransactionBatchMock).toHaveBeenCalledWith( + expect.objectContaining({ + disable7702: false, + disableHook: true, + disableSequential: true, + gasLimit7702: toHex(64000), + transactions: [ + expect.objectContaining({ + params: expect.not.objectContaining({ + gas: expect.anything(), + }), + type: TransactionType.tokenMethodApprove, + }), + expect.objectContaining({ + params: expect.not.objectContaining({ + gas: expect.anything(), + }), + type: TransactionType.perpsAcrossDeposit, + }), + ], + }), + ); + }); + it('submits a single transaction when no approvals', async () => { const noApprovalQuote = { ...QUOTE_MOCK, @@ -327,6 +375,47 @@ describe('Across Submit', () => { ); }); + it('falls back to the Across deposit type when an ordered swap transaction has no explicit type', async () => { + const orderedTransactionsSpy = jest.spyOn( + acrossTransactions, + 'getAcrossOrderedTransactions', + ); + const noApprovalQuote = { + ...QUOTE_MOCK, + original: { + ...QUOTE_MOCK.original, + quote: { + ...QUOTE_MOCK.original.quote, + approvalTxns: [], + }, + }, + } as TransactionPayQuote; + + orderedTransactionsSpy.mockReturnValueOnce([ + { + ...QUOTE_MOCK.original.quote.swapTx, + kind: 'swap', + type: undefined, + }, + ]); + + await submitAcrossQuotes({ + messenger, + quotes: [noApprovalQuote], + transaction: TRANSACTION_META_MOCK, + isSmartTransaction: jest.fn(), + }); + + expect(addTransactionMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + type: TransactionType.perpsAcrossDeposit, + }), + ); + + orderedTransactionsSpy.mockRestore(); + }); + it('removes nonce from skipped transaction', 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 9626c467883..9c25f065be1 100644 --- a/packages/transaction-pay-controller/src/strategy/across/across-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/across/across-submit.ts @@ -12,6 +12,7 @@ import type { import type { Hex } from '@metamask/utils'; import { createModuleLogger } from '@metamask/utils'; +import { getAcrossOrderedTransactions } from './transactions'; import type { AcrossQuote } from './types'; import { projectLogger } from '../../logger'; import type { @@ -115,7 +116,7 @@ async function submitTransactions( acrossDepositType: TransactionType, messenger: TransactionPayControllerMessenger, ): Promise { - const { approvalTxns, swapTx } = quote.original.quote; + const { swapTx } = quote.original.quote; const { gasLimits: quoteGasLimits } = quote.original.metamask; const { from } = quote.request; const chainId = toHex(swapTx.chainId); @@ -125,47 +126,54 @@ async function submitTransactions( chainId, ); - const transactions: PreparedAcrossTransaction[] = []; + const gasLimit7702 = + quoteGasLimits?.batch && quote.original.quote.approvalTxns?.length + ? toHex(quoteGasLimits.batch.max) + : undefined; + + let approvalIndex = 0; + const transactions: PreparedAcrossTransaction[] = + getAcrossOrderedTransactions({ + quote: quote.original.quote, + swapType: acrossDepositType, + }).map((transaction) => { + let gasLimit = gasLimit7702 ? undefined : quoteGasLimits?.swap?.max; + + if (transaction.kind === 'approval') { + gasLimit = gasLimit7702 + ? undefined + : quoteGasLimits?.approval?.[approvalIndex]?.max; + + if (gasLimit === undefined && !gasLimit7702) { + throw new Error( + `Missing quote gas limit for Across approval transaction at index ${approvalIndex}`, + ); + } + + approvalIndex += 1; + } - if (approvalTxns?.length) { - for (const [index, approval] of approvalTxns.entries()) { - const approvalGasLimit = quoteGasLimits?.approval[index]?.max; - if (approvalGasLimit === undefined) { - throw new Error( - `Missing quote gas limit for Across approval transaction at index ${index}`, - ); + if ( + transaction.kind === 'swap' && + gasLimit === undefined && + !gasLimit7702 + ) { + throw new Error('Missing quote gas limit for Across swap transaction'); } - transactions.push({ + return { params: buildTransactionParams(from, { - chainId: approval.chainId, - data: approval.data, - gasLimit: approvalGasLimit, - to: approval.to, - value: approval.value, + chainId: transaction.chainId, + data: transaction.data, + gasLimit, + maxFeePerGas: transaction.maxFeePerGas, + maxPriorityFeePerGas: transaction.maxPriorityFeePerGas, + to: transaction.to, + value: transaction.value, }), - type: TransactionType.tokenMethodApprove, - }); - } - } - - const swapGasLimit = quoteGasLimits?.swap?.max; - if (swapGasLimit === undefined) { - throw new Error('Missing quote gas limit for Across swap transaction'); - } - - transactions.push({ - params: buildTransactionParams(from, { - chainId: swapTx.chainId, - data: swapTx.data, - gasLimit: swapGasLimit, - to: swapTx.to, - value: swapTx.value, - maxFeePerGas: swapTx.maxFeePerGas, - maxPriorityFeePerGas: swapTx.maxPriorityFeePerGas, - }), - type: acrossDepositType, - }); + type: transaction.type ?? acrossDepositType, + }; + }); const transactionIds: string[] = []; @@ -211,7 +219,11 @@ async function submitTransactions( })); await messenger.call('TransactionController:addTransactionBatch', { + disable7702: !gasLimit7702, + disableHook: Boolean(gasLimit7702), + disableSequential: Boolean(gasLimit7702), from, + gasLimit7702, networkClientId, origin: ORIGIN_METAMASK, requireApproval: false, @@ -346,7 +358,7 @@ function buildTransactionParams( params: { chainId: number; data: Hex; - gasLimit: number; + gasLimit?: number; to: Hex; value?: Hex; maxFeePerGas?: string; @@ -354,12 +366,11 @@ function buildTransactionParams( }, ): TransactionParams { const value = toHex(params.value ?? '0x0'); - const gas = params.gasLimit; return { data: params.data, from, - gas: toHex(gas), + gas: params.gasLimit === undefined ? undefined : toHex(params.gasLimit), maxFeePerGas: normalizeOptionalHex(params.maxFeePerGas), maxPriorityFeePerGas: normalizeOptionalHex(params.maxPriorityFeePerGas), to: params.to, diff --git a/packages/transaction-pay-controller/src/strategy/across/transactions.test.ts b/packages/transaction-pay-controller/src/strategy/across/transactions.test.ts new file mode 100644 index 00000000000..9647e5f48ee --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/across/transactions.test.ts @@ -0,0 +1,51 @@ +import { TransactionType } from '@metamask/transaction-controller'; +import type { Hex } from '@metamask/utils'; + +import { getAcrossOrderedTransactions } from './transactions'; +import type { AcrossSwapApprovalResponse } from './types'; + +const QUOTE_MOCK: AcrossSwapApprovalResponse = { + approvalTxns: [ + { + chainId: undefined, + data: '0xaaaa' as Hex, + to: '0xapprove' as Hex, + }, + ], + inputToken: { + address: '0xabc' as Hex, + chainId: 1, + decimals: 18, + }, + outputToken: { + address: '0xdef' as Hex, + chainId: 2, + decimals: 6, + }, + swapTx: { + chainId: 10, + data: '0xdeadbeef' as Hex, + to: '0xswap' as Hex, + }, +}; + +describe('getAcrossOrderedTransactions', () => { + it('falls back to the swap chain id when an approval transaction omits chainId', () => { + expect(getAcrossOrderedTransactions({ quote: QUOTE_MOCK })).toStrictEqual([ + { + chainId: 10, + data: '0xaaaa', + kind: 'approval', + to: '0xapprove', + type: TransactionType.tokenMethodApprove, + }, + { + chainId: 10, + data: '0xdeadbeef', + kind: 'swap', + to: '0xswap', + type: undefined, + }, + ]); + }); +}); diff --git a/packages/transaction-pay-controller/src/strategy/across/transactions.ts b/packages/transaction-pay-controller/src/strategy/across/transactions.ts new file mode 100644 index 00000000000..f5cb6ef3027 --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/across/transactions.ts @@ -0,0 +1,40 @@ +import { TransactionType } from '@metamask/transaction-controller'; + +import type { AcrossSwapApprovalResponse } from './types'; + +export type AcrossOrderedTransaction = { + chainId: number; + data: `0x${string}`; + gas?: string; + kind: 'approval' | 'swap'; + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; + to: `0x${string}`; + type?: TransactionType; + value?: `0x${string}`; +}; + +export function getAcrossOrderedTransactions({ + quote, + swapType, +}: { + quote: AcrossSwapApprovalResponse; + swapType?: TransactionType; +}): AcrossOrderedTransaction[] { + const swapChainId = quote.swapTx.chainId; + const approvalTransactions = (quote.approvalTxns ?? []).map((approval) => ({ + ...approval, + chainId: approval.chainId ?? swapChainId, + kind: 'approval' as const, + type: TransactionType.tokenMethodApprove, + })); + + return [ + ...approvalTransactions, + { + ...quote.swapTx, + kind: 'swap', + type: swapType, + }, + ]; +} diff --git a/packages/transaction-pay-controller/src/strategy/across/types.ts b/packages/transaction-pay-controller/src/strategy/across/types.ts index fdcbf09bfed..7c47ca0271c 100644 --- a/packages/transaction-pay-controller/src/strategy/across/types.ts +++ b/packages/transaction-pay-controller/src/strategy/across/types.ts @@ -47,7 +47,7 @@ export type AcrossFees = { }; export type AcrossApprovalTransaction = { - chainId: number; + chainId?: number; to: Hex; data: Hex; value?: Hex; @@ -76,15 +76,15 @@ export type AcrossSwapApprovalResponse = { swapTx: AcrossSwapTransaction; }; +export type AcrossGasLimit = { + estimate: number; + max: number; +}; + export type AcrossGasLimits = { - approval: { - estimate: number; - max: number; - }[]; - swap: { - estimate: number; - max: number; - }; + approval?: AcrossGasLimit[]; + batch?: AcrossGasLimit; + swap?: AcrossGasLimit; }; export type AcrossQuote = { 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 3af5f22b493..ccd8ed767e1 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 @@ -26,12 +26,14 @@ import { DEFAULT_RELAY_ORIGIN_GAS_OVERHEAD, DEFAULT_RELAY_QUOTE_URL, DEFAULT_SLIPPAGE, + getEIP7702SupportedChains, isEIP7702Chain, isRelayExecuteEnabled, getGasBuffer, getSlippage, } from '../../utils/feature-flags'; import { calculateGasCost, calculateGasFeeTokenCost } from '../../utils/gas'; +import * as quoteGasUtils from '../../utils/quote-gas'; import { getNativeToken, getTokenBalance, @@ -46,9 +48,14 @@ jest.mock('../../utils/token', () => ({ jest.requireActual('../../utils/token') .normalizeTokenAddress, })); -jest.mock('../../utils/gas'); +jest.mock('../../utils/gas', () => ({ + ...jest.requireActual('../../utils/gas'), + calculateGasCost: jest.fn(), + calculateGasFeeTokenCost: jest.fn(), +})); jest.mock('../../utils/feature-flags', () => ({ ...jest.requireActual('../../utils/feature-flags'), + getEIP7702SupportedChains: jest.fn(), isEIP7702Chain: jest.fn(), isRelayExecuteEnabled: jest.fn(), getGasBuffer: jest.fn(), @@ -168,6 +175,7 @@ describe('Relay Quotes Utils', () => { const calculateGasFeeTokenCostMock = jest.mocked(calculateGasFeeTokenCost); const getNativeTokenMock = jest.mocked(getNativeToken); const getTokenBalanceMock = jest.mocked(getTokenBalance); + const getEIP7702SupportedChainsMock = jest.mocked(getEIP7702SupportedChains); const isEIP7702ChainMock = jest.mocked(isEIP7702Chain); const isRelayExecuteEnabledMock = jest.mocked(isRelayExecuteEnabled); const getGasBufferMock = jest.mocked(getGasBuffer); @@ -209,6 +217,9 @@ describe('Relay Quotes Utils', () => { ...getDefaultRemoteFeatureFlagControllerState(), }); + getEIP7702SupportedChainsMock.mockReturnValue([ + QUOTE_REQUEST_MOCK.sourceChainId, + ]); isEIP7702ChainMock.mockReturnValue(true); isRelayExecuteEnabledMock.mockReturnValue(false); getGasBufferMock.mockReturnValue(1.0); @@ -2676,6 +2687,41 @@ describe('Relay Quotes Utils', () => { ); }); + it('falls back to per-transaction estimation when the source chain does not support EIP-7702', 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, + }, + } as never); + + successfulFetchMock.mockResolvedValue({ + json: async () => quoteMock, + } as never); + + isEIP7702ChainMock.mockReturnValue(false); + estimateGasMock.mockResolvedValue({ + gas: toHex(50000), + simulationFails: undefined, + }); + + const result = await getRelayQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + expect(estimateGasBatchMock).not.toHaveBeenCalled(); + expect(estimateGasMock).toHaveBeenCalledTimes(1); + expect(result[0].original.metamask.gasLimits).toStrictEqual([ + 30000, 50000, + ]); + }); + it('uses fallback gas when estimateGasBatch fails', async () => { const quoteMock = cloneDeep(QUOTE_MOCK); quoteMock.steps[0].items.push({ @@ -2743,6 +2789,168 @@ describe('Relay Quotes Utils', () => { ); }); + it('reuses populated relay params when later transactions omit estimation fields', async () => { + const quoteMock = cloneDeep(QUOTE_MOCK); + delete quoteMock.steps[0].items[0].data.gas; + quoteMock.steps[0].items.push({ + data: {}, + } as never); + + 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(estimateGasBatchMock).not.toHaveBeenCalled(); + expect(findNetworkClientIdByChainIdMock).toHaveBeenNthCalledWith( + 1, + '0x1', + ); + expect(findNetworkClientIdByChainIdMock).toHaveBeenNthCalledWith( + 2, + '0x1', + ); + expect(estimateGasMock).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + data: '0x123', + from: FROM_MOCK, + to: '0x2', + value: '0x0', + }), + NETWORK_CLIENT_ID_MOCK, + ); + }); + + it('preserves falsy fallback relay params when later transactions omit estimation fields', async () => { + const estimateQuoteGasLimitsSpy = jest.spyOn( + quoteGasUtils, + 'estimateQuoteGasLimits', + ); + const quoteMock = cloneDeep(QUOTE_MOCK); + + quoteMock.steps[0].items = [ + { + ...quoteMock.steps[0].items[0], + data: { + chainId: 0, + data: '' as Hex, + from: '' as Hex, + to: '' as Hex, + }, + }, + { + ...quoteMock.steps[0].items[0], + data: { + chainId: 1, + data: '0x456' as Hex, + from: FROM_MOCK, + to: '0x3' as Hex, + }, + }, + { + ...quoteMock.steps[0].items[0], + data: {}, + }, + ]; + + successfulFetchMock.mockResolvedValue({ + json: async () => quoteMock, + } as never); + + estimateQuoteGasLimitsSpy.mockResolvedValueOnce({ + gasLimits: [ + { estimate: 21000, max: 21000, source: 'estimated' }, + { estimate: 22000, max: 22000, source: 'estimated' }, + { estimate: 23000, max: 23000, source: 'estimated' }, + ], + totalGasEstimate: 66000, + totalGasLimit: 66000, + usedBatch: false, + }); + + await getRelayQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + expect(estimateQuoteGasLimitsSpy).toHaveBeenCalledWith( + expect.objectContaining({ + allowBatch: false, + transactions: [ + expect.objectContaining({ + chainId: '0x0', + data: '', + from: '', + to: '', + }), + expect.objectContaining({ + chainId: '0x1', + data: '0x456', + from: FROM_MOCK, + to: '0x3', + }), + expect.objectContaining({ + chainId: '0x0', + data: '', + from: '', + to: '', + }), + ], + }), + ); + + estimateQuoteGasLimitsSpy.mockRestore(); + }); + + it('uses placeholder relay params when no estimation fields are provided', async () => { + const quoteMock = cloneDeep(QUOTE_MOCK); + quoteMock.steps[0].items = [ + { + ...quoteMock.steps[0].items[0], + data: {}, + }, + ]; + + 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(estimateGasBatchMock).not.toHaveBeenCalled(); + expect(findNetworkClientIdByChainIdMock).toHaveBeenCalledWith('0x0'); + expect(estimateGasMock).toHaveBeenCalledWith( + expect.objectContaining({ + data: '0x', + from: '0x0000000000000000000000000000000000000000', + to: '0x0000000000000000000000000000000000000000', + value: '0x0', + }), + NETWORK_CLIENT_ID_MOCK, + ); + }); + describe('gas buffer support', () => { it('applies buffer to single transaction gas estimate', async () => { const quoteMock = cloneDeep(QUOTE_MOCK); @@ -2770,16 +2978,15 @@ describe('Relay Quotes Utils', () => { ); }); - it('applies buffer to batch transaction gas estimates when estimates do not match params', async () => { + it('applies buffer to per-entry batch gas estimates when transactions are estimated', async () => { const quoteMock = cloneDeep(QUOTE_MOCK); - quoteMock.steps[0].items[0].data.gas = '30000'; + delete quoteMock.steps[0].items[0].data.gas; quoteMock.steps[0].items.push({ data: { chainId: 1, from: FROM_MOCK, to: '0x3' as Hex, data: '0x456' as Hex, - gas: '40000', }, } as never); @@ -2884,7 +3091,6 @@ describe('Relay Quotes Utils', () => { from: FROM_MOCK, to: '0x3' as Hex, data: '0x456' as Hex, - gas: '40000', }, } as never); 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 5c68becd685..d9b497a4b26 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -8,17 +8,14 @@ import { BigNumber } from 'bignumber.js'; import { TOKEN_TRANSFER_FOUR_BYTE } from './constants'; import { - getGasStationEligibility, getGasStationCostInSourceTokenRaw, + getGasStationEligibility, } from './gas-station'; import { fetchRelayQuote } from './relay-api'; import { getRelayMaxGasStationQuote } from './relay-max-gas-station'; import type { RelayQuote, RelayQuoteRequest } from './types'; import { TransactionPayStrategy } from '../..'; -import type { - BatchTransactionParams, - TransactionMeta, -} from '../../../../transaction-controller/src'; +import type { TransactionMeta } from '../../../../transaction-controller/src'; import { ARBITRUM_USDC_ADDRESS, CHAIN_ID_ARBITRUM, @@ -38,14 +35,14 @@ import type { } from '../../types'; import { getFiatValueFromUsd } from '../../utils/amounts'; import { - isEIP7702Chain, - isRelayExecuteEnabled, getFeatureFlags, - getGasBuffer, getRelayOriginGasOverhead, getSlippage, + isEIP7702Chain, + isRelayExecuteEnabled, } from '../../utils/feature-flags'; import { calculateGasCost } from '../../utils/gas'; +import { estimateQuoteGasLimits } from '../../utils/quote-gas'; import { getNativeToken, getTokenBalance, @@ -771,9 +768,52 @@ async function calculateSourceNetworkGasLimit( totalGasLimit: number; gasLimits: number[]; }> { - return params.length === 1 - ? calculateSourceNetworkGasLimitSingle(params[0], messenger, fromOverride) - : calculateSourceNetworkGasLimitBatch(params, messenger, fromOverride); + const fallbackChainId = params.find( + (singleParams) => singleParams.chainId !== undefined, + )?.chainId; + const fallbackFrom = params.find( + (singleParams) => singleParams.from !== undefined, + )?.from; + const fallbackTo = params.find( + (singleParams) => singleParams.to !== undefined, + )?.to; + const fallbackData = params.find( + (singleParams) => singleParams.data !== undefined, + )?.data; + + const relayGasResult = await estimateQuoteGasLimits({ + allowBatch: params.every( + (singleParams) => + singleParams.chainId !== undefined && + singleParams.from !== undefined && + singleParams.to !== undefined && + singleParams.data !== undefined, + ), + fallbackGas: getFeatureFlags(messenger).relayFallbackGas, + fallbackOnSimulationFailure: true, + messenger, + transactions: params.map((singleParams) => ({ + chainId: toHex(singleParams.chainId ?? fallbackChainId ?? 0), + data: singleParams.data ?? fallbackData ?? '0x', + from: + fromOverride ?? + singleParams.from ?? + fallbackFrom ?? + '0x0000000000000000000000000000000000000000', + gas: fromOverride ? undefined : singleParams.gas, + to: + singleParams.to ?? + fallbackTo ?? + '0x0000000000000000000000000000000000000000', + value: singleParams.value ?? '0', + })), + }); + + return { + gasLimits: relayGasResult.gasLimits.map((gasLimit) => gasLimit.max), + totalGasEstimate: relayGasResult.totalGasEstimate, + totalGasLimit: relayGasResult.totalGasLimit, + }; } /** @@ -868,197 +908,6 @@ function getTransferRecipient(data: Hex): Hex { .to.toLowerCase(); } -async function calculateSourceNetworkGasLimitSingle( - params: RelayQuote['steps'][0]['items'][0]['data'], - messenger: TransactionPayControllerMessenger, - fromOverride?: Hex, -): Promise<{ - totalGasEstimate: number; - totalGasLimit: number; - gasLimits: number[]; -}> { - const paramGasLimit = params.gas - ? new BigNumber(params.gas).toNumber() - : undefined; - - if (paramGasLimit && !fromOverride) { - log('Using single gas limit from params', { paramGasLimit }); - - return { - totalGasEstimate: paramGasLimit, - totalGasLimit: paramGasLimit, - gasLimits: [paramGasLimit], - }; - } - - try { - const { - chainId: chainIdNumber, - data, - from: paramsFrom, - to, - value: valueString, - } = params; - - const from = fromOverride ?? paramsFrom; - 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. - * @param fromOverride - Optional address to use as `from` in gas estimation. - * @returns - Gas limits. - */ -async function calculateSourceNetworkGasLimitBatch( - params: RelayQuote['steps'][0]['items'][0]['data'][], - messenger: TransactionPayControllerMessenger, - fromOverride?: Hex, -): Promise<{ - totalGasEstimate: number; - totalGasLimit: number; - gasLimits: number[]; -}> { - try { - const { chainId: chainIdNumber, from: paramsFrom } = params[0]; - const from = fromOverride ?? paramsFrom; - const chainId = toHex(chainIdNumber); - const gasBuffer = getGasBuffer(messenger, chainId); - - const transactions: BatchTransactionParams[] = params.map( - (singleParams) => ({ - ...singleParams, - gas: - !fromOverride && 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, - }; -} - function getSubsidizedFeeAmountUsd(quote: RelayQuote): BigNumber { const subsidizedFee = quote.fees?.subsidized; const amountUsd = new BigNumber(subsidizedFee?.amountUsd ?? '0'); diff --git a/packages/transaction-pay-controller/src/utils/feature-flags.ts b/packages/transaction-pay-controller/src/utils/feature-flags.ts index b8528a8904c..3f7a4d14863 100644 --- a/packages/transaction-pay-controller/src/utils/feature-flags.ts +++ b/packages/transaction-pay-controller/src/utils/feature-flags.ts @@ -379,24 +379,34 @@ function getCaseInsensitive( } /** - * Checks if a chain supports EIP-7702. + * Get the chains that support EIP-7702. * * @param messenger - Controller messenger. - * @param chainId - Chain ID to check. - * @returns Whether the chain supports EIP-7702. + * @returns Supported chain IDs. */ -export function isEIP7702Chain( +export function getEIP7702SupportedChains( messenger: TransactionPayControllerMessenger, - chainId: Hex, -): boolean { +): Hex[] { const state = messenger.call('RemoteFeatureFlagController:getState'); const eip7702Flags = state.remoteFeatureFlags.confirmations_eip_7702 as | { supportedChains?: Hex[] } | undefined; - const supportedChains = eip7702Flags?.supportedChains ?? []; + return eip7702Flags?.supportedChains ?? []; +} - return supportedChains.some( +/** + * Checks if a chain supports EIP-7702. + * + * @param messenger - Controller messenger. + * @param chainId - Chain ID to check. + * @returns Whether the chain supports EIP-7702. + */ +export function isEIP7702Chain( + messenger: TransactionPayControllerMessenger, + chainId: Hex, +): boolean { + return getEIP7702SupportedChains(messenger).some( (supported) => supported.toLowerCase() === chainId.toLowerCase(), ); } diff --git a/packages/transaction-pay-controller/src/utils/quote-gas.test.ts b/packages/transaction-pay-controller/src/utils/quote-gas.test.ts new file mode 100644 index 00000000000..7e7daa811dc --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/quote-gas.test.ts @@ -0,0 +1,398 @@ +import type { Hex } from '@metamask/utils'; + +import { getGasBuffer, isEIP7702Chain } from './feature-flags'; +import { estimateGasLimit } from './gas'; +import { estimateQuoteGasLimits } from './quote-gas'; +import { getMessengerMock } from '../tests/messenger-mock'; + +jest.mock('./feature-flags', () => ({ + ...jest.requireActual('./feature-flags'), + getGasBuffer: jest.fn(), + isEIP7702Chain: jest.fn(), +})); + +jest.mock('./gas', () => ({ + ...jest.requireActual('./gas'), + estimateGasLimit: jest.fn(), +})); + +describe('quote gas estimation', () => { + const getGasBufferMock = jest.mocked(getGasBuffer); + const isEIP7702ChainMock = jest.mocked(isEIP7702Chain); + const estimateGasLimitMock = jest.mocked(estimateGasLimit); + + const { estimateGasBatchMock, messenger } = getMessengerMock(); + + const TRANSACTIONS_MOCK = [ + { + chainId: '0x1' as Hex, + data: '0xaaaa' as Hex, + from: '0x1234567890123456789012345678901234567891' as Hex, + to: '0x1111111111111111111111111111111111111111' as Hex, + value: '0x0' as Hex, + }, + { + chainId: '0x1' as Hex, + data: '0xbbbb' as Hex, + from: '0x1234567890123456789012345678901234567891' as Hex, + gas: '30000', + to: '0x2222222222222222222222222222222222222222' as Hex, + value: '0x0' as Hex, + }, + ]; + + beforeEach(() => { + jest.resetAllMocks(); + + getGasBufferMock.mockReturnValue(1); + isEIP7702ChainMock.mockReturnValue(false); + }); + + it('returns empty gas limits when there are no transactions', async () => { + const result = await estimateQuoteGasLimits({ + messenger, + transactions: [], + }); + + expect(estimateGasBatchMock).not.toHaveBeenCalled(); + expect(estimateGasLimitMock).not.toHaveBeenCalled(); + expect(result).toStrictEqual({ + gasLimits: [], + totalGasEstimate: 0, + totalGasLimit: 0, + usedBatch: false, + }); + }); + + it('uses per-transaction estimation when the source chain does not support EIP-7702', async () => { + estimateGasLimitMock.mockResolvedValueOnce({ + estimate: 21000, + max: 21000, + usedFallback: false, + }); + + const result = await estimateQuoteGasLimits({ + fallbackOnSimulationFailure: true, + messenger, + transactions: TRANSACTIONS_MOCK, + }); + + expect(estimateGasBatchMock).not.toHaveBeenCalled(); + expect(estimateGasLimitMock).toHaveBeenCalledTimes(1); + expect(result).toStrictEqual({ + gasLimits: [ + { + estimate: 21000, + max: 21000, + source: 'estimated', + }, + { + estimate: 30000, + max: 30000, + source: 'provided', + }, + ], + totalGasEstimate: 51000, + totalGasLimit: 51000, + usedBatch: false, + }); + }); + + it('uses per-transaction estimation when batch estimation is explicitly disabled', async () => { + isEIP7702ChainMock.mockReturnValue(true); + estimateGasLimitMock.mockResolvedValueOnce({ + estimate: 21000, + max: 21000, + usedFallback: false, + }); + + const result = await estimateQuoteGasLimits({ + allowBatch: false, + messenger, + transactions: TRANSACTIONS_MOCK, + }); + + expect(estimateGasBatchMock).not.toHaveBeenCalled(); + expect(result.usedBatch).toBe(false); + }); + + it('uses per-transaction estimation when transactions do not share a batch context', async () => { + isEIP7702ChainMock.mockReturnValue(true); + estimateGasLimitMock + .mockResolvedValueOnce({ + estimate: 21000, + max: 21000, + usedFallback: false, + }) + .mockResolvedValueOnce({ + estimate: 22000, + max: 22000, + usedFallback: false, + }); + + const result = await estimateQuoteGasLimits({ + messenger, + transactions: [ + TRANSACTIONS_MOCK[0], + { + ...TRANSACTIONS_MOCK[1], + from: '0x9999999999999999999999999999999999999999' as Hex, + gas: undefined, + }, + ], + }); + + expect(estimateGasBatchMock).not.toHaveBeenCalled(); + expect(estimateGasLimitMock).toHaveBeenCalledTimes(2); + expect(result.usedBatch).toBe(false); + }); + + it('uses batch estimation when the source chain supports EIP-7702', async () => { + isEIP7702ChainMock.mockReturnValue(true); + getGasBufferMock.mockReturnValue(1.5); + estimateGasBatchMock.mockResolvedValue({ + totalGasLimit: 50000, + gasLimits: [50000], + }); + + const result = await estimateQuoteGasLimits({ + fallbackOnSimulationFailure: true, + messenger, + transactions: TRANSACTIONS_MOCK, + }); + + expect(estimateGasLimitMock).not.toHaveBeenCalled(); + expect(estimateGasBatchMock).toHaveBeenCalledWith({ + chainId: '0x1', + from: TRANSACTIONS_MOCK[0].from, + transactions: [ + expect.objectContaining({ + data: TRANSACTIONS_MOCK[0].data, + to: TRANSACTIONS_MOCK[0].to, + }), + expect.objectContaining({ + data: TRANSACTIONS_MOCK[1].data, + gas: '0x7530', + to: TRANSACTIONS_MOCK[1].to, + }), + ], + }); + expect(result).toStrictEqual({ + gasLimits: [ + { + estimate: 75000, + max: 75000, + source: 'batch', + }, + ], + totalGasEstimate: 75000, + totalGasLimit: 75000, + usedBatch: true, + }); + }); + + it('uses per-transaction batch gas limits and preserves provided gas when it already matches', async () => { + isEIP7702ChainMock.mockReturnValue(true); + getGasBufferMock.mockReturnValue(1.5); + estimateGasBatchMock.mockResolvedValue({ + totalGasLimit: 51000, + gasLimits: [21000, 30000], + }); + + const result = await estimateQuoteGasLimits({ + messenger, + transactions: TRANSACTIONS_MOCK, + }); + + expect(result).toStrictEqual({ + gasLimits: [ + { + estimate: 31500, + max: 31500, + source: 'batch', + }, + { + estimate: 30000, + max: 30000, + source: 'batch', + }, + ], + totalGasEstimate: 61500, + totalGasLimit: 61500, + usedBatch: true, + }); + }); + + it('buffers per-transaction batch gas when a provided gas value is overridden', async () => { + isEIP7702ChainMock.mockReturnValue(true); + getGasBufferMock.mockReturnValue(1.5); + estimateGasBatchMock.mockResolvedValue({ + totalGasLimit: 56000, + gasLimits: [21000, 35000], + }); + + const result = await estimateQuoteGasLimits({ + messenger, + transactions: TRANSACTIONS_MOCK, + }); + + expect(result).toStrictEqual({ + gasLimits: [ + { + estimate: 31500, + max: 31500, + source: 'batch', + }, + { + estimate: 52500, + max: 52500, + source: 'batch', + }, + ], + totalGasEstimate: 84000, + totalGasLimit: 84000, + usedBatch: true, + }); + }); + + it('falls back to per-transaction estimation when batch estimation fails', async () => { + isEIP7702ChainMock.mockReturnValue(true); + estimateGasBatchMock.mockRejectedValue( + new Error('Batch estimation failed'), + ); + estimateGasLimitMock.mockResolvedValueOnce({ + estimate: 21000, + max: 21000, + usedFallback: false, + }); + + const result = await estimateQuoteGasLimits({ + fallbackOnSimulationFailure: true, + messenger, + transactions: TRANSACTIONS_MOCK, + }); + + expect(estimateGasBatchMock).toHaveBeenCalledTimes(1); + expect(estimateGasLimitMock).toHaveBeenCalledTimes(1); + expect(result).toStrictEqual({ + gasLimits: [ + { + estimate: 21000, + max: 21000, + source: 'estimated', + }, + { + estimate: 30000, + max: 30000, + source: 'provided', + }, + ], + totalGasEstimate: 51000, + totalGasLimit: 51000, + usedBatch: false, + }); + }); + + it('falls back to per-transaction estimation when batch returns an unexpected gas limit count', async () => { + isEIP7702ChainMock.mockReturnValue(true); + estimateGasBatchMock.mockResolvedValue({ + totalGasLimit: 123000, + gasLimits: [21000, 30000, 72000], + }); + estimateGasLimitMock.mockResolvedValueOnce({ + estimate: 21000, + max: 21000, + usedFallback: false, + }); + + const result = await estimateQuoteGasLimits({ + messenger, + transactions: TRANSACTIONS_MOCK, + }); + + expect(estimateGasBatchMock).toHaveBeenCalledTimes(1); + expect(estimateGasLimitMock).toHaveBeenCalledTimes(1); + expect(result.usedBatch).toBe(false); + }); + + it('treats numeric gas values as provided gas limits', async () => { + const result = await estimateQuoteGasLimits({ + messenger, + transactions: [ + { + ...TRANSACTIONS_MOCK[0], + gas: 42000, + }, + ], + }); + + expect(estimateGasBatchMock).not.toHaveBeenCalled(); + expect(estimateGasLimitMock).not.toHaveBeenCalled(); + expect(result).toStrictEqual({ + gasLimits: [ + { + estimate: 42000, + max: 42000, + source: 'provided', + }, + ], + totalGasEstimate: 42000, + totalGasLimit: 42000, + usedBatch: false, + }); + }); + + it('defaults missing transaction values to zero for per-transaction estimation', async () => { + estimateGasLimitMock.mockResolvedValueOnce({ + estimate: 21000, + max: 21000, + usedFallback: false, + }); + + await estimateQuoteGasLimits({ + messenger, + transactions: [ + { + chainId: '0x1' as Hex, + data: '0xaaaa' as Hex, + from: '0x1234567890123456789012345678901234567891' as Hex, + to: '0x1111111111111111111111111111111111111111' as Hex, + }, + ], + }); + + expect(estimateGasLimitMock).toHaveBeenCalledWith( + expect.objectContaining({ + value: '0x0', + }), + ); + }); + + it('defaults missing transaction values to zero for batch estimation', async () => { + isEIP7702ChainMock.mockReturnValue(true); + estimateGasBatchMock.mockResolvedValue({ + totalGasLimit: 50000, + gasLimits: [50000], + }); + + await estimateQuoteGasLimits({ + messenger, + transactions: TRANSACTIONS_MOCK.map(({ value, ...transaction }) => ({ + ...transaction, + })), + }); + + expect(estimateGasBatchMock).toHaveBeenCalledWith({ + chainId: '0x1', + from: TRANSACTIONS_MOCK[0].from, + transactions: [ + expect.objectContaining({ + value: '0x0', + }), + expect.objectContaining({ + value: '0x0', + }), + ], + }); + }); +}); diff --git a/packages/transaction-pay-controller/src/utils/quote-gas.ts b/packages/transaction-pay-controller/src/utils/quote-gas.ts new file mode 100644 index 00000000000..c2439e1f869 --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/quote-gas.ts @@ -0,0 +1,269 @@ +import { toHex } from '@metamask/controller-utils'; +import type { BatchTransactionParams } from '@metamask/transaction-controller'; +import type { Hex } from '@metamask/utils'; +import { createModuleLogger } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; + +import { getGasBuffer, isEIP7702Chain } from './feature-flags'; +import { estimateGasLimit } from './gas'; +import type { TransactionPayControllerMessenger } from '..'; +import { projectLogger } from '../logger'; + +const log = createModuleLogger(projectLogger, 'quote-gas'); + +export type QuoteGasTransaction = { + chainId: Hex; + data: Hex; + from: Hex; + gas?: number | string; + to: Hex; + value?: number | string | Hex; +}; + +export type QuoteGasLimit = { + estimate: number; + max: number; + source: 'batch' | 'estimated' | 'fallback' | 'provided'; + error?: unknown; +}; + +export async function estimateQuoteGasLimits({ + allowBatch = true, + fallbackGas, + fallbackOnSimulationFailure = false, + messenger, + transactions, +}: { + allowBatch?: boolean; + fallbackGas?: { + estimate: number; + max: number; + }; + fallbackOnSimulationFailure?: boolean; + messenger: TransactionPayControllerMessenger; + transactions: QuoteGasTransaction[]; +}): Promise<{ + gasLimits: QuoteGasLimit[]; + totalGasEstimate: number; + totalGasLimit: number; + usedBatch: boolean; +}> { + if (transactions.length === 0) { + return { + gasLimits: [], + totalGasEstimate: 0, + totalGasLimit: 0, + usedBatch: false, + }; + } + + const [firstTransaction] = transactions; + const useBatch = + allowBatch && + transactions.length > 1 && + hasUniformBatchContext(transactions) && + isEIP7702Chain(messenger, firstTransaction.chainId); + + if (useBatch) { + try { + return { + ...(await estimateQuoteGasLimitsBatch(transactions, messenger)), + usedBatch: true, + }; + } catch (error) { + log('Batch gas estimation failed, falling back to per-transaction path', { + chainId: firstTransaction.chainId, + error, + }); + } + } + + return { + ...(await estimateQuoteGasLimitsIndividually({ + fallbackGas, + fallbackOnSimulationFailure, + messenger, + transactions, + })), + usedBatch: false, + }; +} + +async function estimateQuoteGasLimitsBatch( + transactions: QuoteGasTransaction[], + messenger: TransactionPayControllerMessenger, +): Promise<{ + gasLimits: QuoteGasLimit[]; + totalGasEstimate: number; + totalGasLimit: number; +}> { + const [firstTransaction] = transactions; + const gasBuffer = getGasBuffer(messenger, firstTransaction.chainId); + + const paramGasLimits = transactions.map((transaction) => + parseGasLimit(transaction.gas), + ); + + const { gasLimits } = await messenger.call( + 'TransactionController:estimateGasBatch', + { + chainId: firstTransaction.chainId, + from: firstTransaction.from, + transactions: transactions.map(toBatchTransactionParams), + }, + ); + + if (gasLimits.length !== 1 && gasLimits.length !== transactions.length) { + throw new Error('Unexpected batch gas limit count'); + } + + const bufferedGasLimits = gasLimits.map((gasLimit, index) => { + const providedGasLimit = paramGasLimits[index]; + const providedGasWasPreserved = + providedGasLimit !== undefined && providedGasLimit === gasLimit; + + // Per-entry batch results currently preserve validated input gas values + // for transactions that already provided gas. If that contract changes + // and batch estimation returns a different value, treat it as a fresh + // estimate and apply the buffer. A single combined 7702 result is always + // buffered because it is a fresh batch estimate. + const useBuffer = gasLimits.length === 1 || !providedGasWasPreserved; + const bufferedGas = Math.ceil(gasLimit * (useBuffer ? gasBuffer : 1)); + + return { + estimate: bufferedGas, + max: bufferedGas, + source: 'batch', + } as QuoteGasLimit; + }); + + const totalGasLimit = bufferedGasLimits.reduce( + (acc, gasLimit) => acc + gasLimit.max, + 0, + ); + + return { + gasLimits: bufferedGasLimits, + totalGasEstimate: totalGasLimit, + totalGasLimit, + }; +} + +async function estimateQuoteGasLimitsIndividually({ + fallbackGas, + fallbackOnSimulationFailure, + messenger, + transactions, +}: { + fallbackGas?: { + estimate: number; + max: number; + }; + fallbackOnSimulationFailure: boolean; + messenger: TransactionPayControllerMessenger; + transactions: QuoteGasTransaction[]; +}): Promise<{ + gasLimits: QuoteGasLimit[]; + totalGasEstimate: number; + totalGasLimit: number; +}> { + const gasLimits = await Promise.all( + transactions.map(async (transaction) => { + const providedGasLimit = parseGasLimit(transaction.gas); + + if (providedGasLimit !== undefined) { + return { + estimate: providedGasLimit, + max: providedGasLimit, + source: 'provided', + } as QuoteGasLimit; + } + + const gasLimitResult = await estimateGasLimit({ + chainId: transaction.chainId, + data: transaction.data, + fallbackGas, + fallbackOnSimulationFailure, + from: transaction.from, + messenger, + to: transaction.to, + value: toHex(transaction.value ?? '0'), + }); + + const gasEstimate: QuoteGasLimit = { + estimate: gasLimitResult.estimate, + max: gasLimitResult.max, + source: gasLimitResult.usedFallback ? 'fallback' : 'estimated', + }; + + if (gasLimitResult.error === undefined) { + return gasEstimate; + } + + return { + ...gasEstimate, + error: gasLimitResult.error, + }; + }), + ); + + return { + gasLimits, + totalGasEstimate: gasLimits.reduce( + (acc, gasLimit) => acc + gasLimit.estimate, + 0, + ), + totalGasLimit: gasLimits.reduce((acc, gasLimit) => acc + gasLimit.max, 0), + }; +} + +function hasUniformBatchContext(transactions: QuoteGasTransaction[]): boolean { + const [firstTransaction] = transactions; + + return transactions.every( + (transaction) => + transaction.chainId.toLowerCase() === + firstTransaction.chainId.toLowerCase() && + transaction.from.toLowerCase() === firstTransaction.from.toLowerCase(), + ); +} + +function toBatchTransactionParams( + transaction: QuoteGasTransaction, +): BatchTransactionParams { + return { + data: transaction.data, + gas: transaction.gas === undefined ? undefined : toHex(transaction.gas), + maxFeePerGas: undefined, + maxPriorityFeePerGas: undefined, + to: transaction.to, + value: toHex(transaction.value ?? '0'), + }; +} + +function parseGasLimit(gas?: number | string): number | undefined { + if (gas === undefined) { + return undefined; + } + + let parsedGas: BigNumber; + + if (typeof gas === 'number') { + parsedGas = new BigNumber(gas); + } else if (gas.startsWith('0x')) { + parsedGas = new BigNumber(gas.slice(2), 16); + } else { + parsedGas = new BigNumber(gas); + } + + if ( + !parsedGas.isFinite() || + parsedGas.isNaN() || + !parsedGas.isInteger() || + parsedGas.lte(0) + ) { + return undefined; + } + + return parsedGas.toNumber(); +} From ffd2d325214bd1101443e760bf834c64f1f187ed Mon Sep 17 00:00:00 2001 From: Pedro Figueiredo Date: Thu, 12 Mar 2026 10:10:09 +0000 Subject: [PATCH 2/3] Address PR feedback --- .../transaction-pay-controller/CHANGELOG.md | 2 +- .../src/strategy/across/across-quotes.test.ts | 202 +++++++++------ .../src/strategy/across/across-quotes.ts | 104 +++----- .../src/strategy/across/across-submit.test.ts | 59 +++-- .../src/strategy/across/across-submit.ts | 59 ++--- .../src/strategy/across/types.ts | 7 +- .../src/strategy/relay/relay-quotes.test.ts | 244 ++++++------------ .../src/strategy/relay/relay-quotes.ts | 119 +++++---- .../src/strategy/relay/relay-submit.test.ts | 14 + .../src/strategy/relay/relay-submit.ts | 13 +- .../src/strategy/relay/types.ts | 1 + .../src/utils/feature-flags.ts | 26 +- .../src/utils/quote-gas.test.ts | 157 ++++------- .../src/utils/quote-gas.ts | 160 +++++------- 14 files changed, 534 insertions(+), 633 deletions(-) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index f3cf2f1becf..c4e82c68b24 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/assets-controller` from `^2.4.0` to `^3.0.0` ([#8232](https://github.com/MetaMask/core/pull/8232)) - Bump `@metamask/assets-controllers` from `^101.0.0` to `^101.0.1` ([#8232](https://github.com/MetaMask/core/pull/8232)) +- Remove duplication in gas estimation for Relay and Across strategies ([#8145](https://github.com/MetaMask/core/pull/8145)) ## [17.1.0] @@ -55,7 +56,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/assets-controllers` from `^100.2.0` to `^100.2.1` ([#8162](https://github.com/MetaMask/core/pull/8162)) - Bump `@metamask/bridge-controller` from `^69.0.0` to `^69.1.0` ([#8162](https://github.com/MetaMask/core/pull/8162), [#8168](https://github.com/MetaMask/core/pull/8168)) - Bump `@metamask/bridge-status-controller` from `^68.0.1` to `^68.1.0` ([#8162](https://github.com/MetaMask/core/pull/8162), [#8168](https://github.com/MetaMask/core/pull/8168)) -- Use shared quote gas estimation for Across and Relay, including EIP-7702 batch estimation on supported source chains with per-transaction fallback when batching is unavailable or fails ([#8145](https://github.com/MetaMask/core/pull/8145)) ### Fixed 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 7453adf389d..d391edd9881 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 @@ -11,11 +11,7 @@ import { getDefaultRemoteFeatureFlagControllerState } from '../../../../remote-f import { TransactionPayStrategy } from '../../constants'; import { getMessengerMock } from '../../tests/messenger-mock'; import type { QuoteRequest } from '../../types'; -import { - getGasBuffer, - isEIP7702Chain, - getSlippage, -} from '../../utils/feature-flags'; +import { getGasBuffer, getSlippage } from '../../utils/feature-flags'; import { calculateGasCost } from '../../utils/gas'; import * as quoteGasUtils from '../../utils/quote-gas'; import { getTokenFiatRate } from '../../utils/token'; @@ -116,7 +112,6 @@ describe('Across Quotes', () => { const successfulFetchMock = jest.mocked(successfulFetch); const getTokenFiatRateMock = jest.mocked(getTokenFiatRate); const getGasBufferMock = jest.mocked(getGasBuffer); - const isEIP7702ChainMock = jest.mocked(isEIP7702Chain); const getSlippageMock = jest.mocked(getSlippage); const calculateGasCostMock = jest.mocked(calculateGasCost); @@ -158,7 +153,6 @@ describe('Across Quotes', () => { }); getGasBufferMock.mockReturnValue(1.0); - isEIP7702ChainMock.mockReturnValue(false); getSlippageMock.mockReturnValue(0.005); findNetworkClientIdByChainIdMock.mockReturnValue('mainnet'); @@ -748,16 +742,10 @@ describe('Across Quotes', () => { }); it('includes approval gas costs and gas limits when approval transactions exist', async () => { - estimateGasMock - .mockRejectedValueOnce(new Error('Approval gas estimation failed')) - .mockResolvedValueOnce({ - gas: '0x7530', - simulationFails: undefined, - }) - .mockResolvedValueOnce({ - gas: '0x5208', - simulationFails: undefined, - }); + estimateGasBatchMock.mockResolvedValue({ + totalGasLimit: 951000, + gasLimits: [900000, 30000, 21000], + }); successfulFetchMock.mockResolvedValue({ json: async () => ({ @@ -803,24 +791,23 @@ describe('Across Quotes', () => { gas: 21000, }), ); - expect(result[0].original.metamask.gasLimits.approval).toStrictEqual([ + expect(result[0].original.metamask.gasLimits).toStrictEqual([ { estimate: 900000, - max: 1500000, + max: 900000, }, { estimate: 30000, max: 30000, }, + { + estimate: 21000, + max: 21000, + }, ]); - expect(result[0].original.metamask.gasLimits.swap).toStrictEqual({ - estimate: 21000, - max: 21000, - }); }); - it('uses batch gas estimation on EIP-7702-supported chains when multiple transactions are submitted', async () => { - isEIP7702ChainMock.mockReturnValue(true); + it('uses a combined batch gas limit when batch estimation returns a single gas limit', async () => { estimateGasBatchMock.mockResolvedValue({ totalGasLimit: 51000, gasLimits: [51000], @@ -861,12 +848,13 @@ describe('Across Quotes', () => { }), ], }); - expect( - (result[0].original.metamask.gasLimits as { batch?: unknown }).batch, - ).toStrictEqual({ - estimate: 51000, - max: 51000, - }); + expect(result[0].original.metamask.gasLimits).toStrictEqual([ + { + estimate: 51000, + max: 51000, + }, + ]); + expect(result[0].original.metamask.is7702).toBe(true); expect(calculateGasCostMock).toHaveBeenNthCalledWith( 1, expect.objectContaining({ @@ -888,20 +876,50 @@ describe('Across Quotes', () => { ); }); - it('falls back to per-transaction gas estimation when batch estimation fails on EIP-7702-supported chains', async () => { - isEIP7702ChainMock.mockReturnValue(true); + it('throws when the shared gas estimator marks a quote as 7702 without a combined gas limit', async () => { + const estimateQuoteGasLimitsSpy = jest.spyOn( + quoteGasUtils, + 'estimateQuoteGasLimits', + ); + + estimateQuoteGasLimitsSpy.mockResolvedValueOnce({ + gasLimits: [], + is7702: true, + totalGasEstimate: 0, + totalGasLimit: 0, + usedBatch: true, + }); + + successfulFetchMock.mockResolvedValue({ + json: async () => ({ + ...QUOTE_MOCK, + approvalTxns: [ + { + chainId: 1, + data: '0xaaaa' as Hex, + to: '0xapprove1' as Hex, + }, + ], + }), + } as Response); + + await expect( + getAcrossQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }), + ).rejects.toThrow( + 'Failed to fetch Across quotes: Error: Across combined batch gas estimate missing', + ); + + estimateQuoteGasLimitsSpy.mockRestore(); + }); + + it('throws when batch estimation fails for multiple transactions', async () => { estimateGasBatchMock.mockRejectedValue( new Error('Batch estimation failed'), ); - estimateGasMock - .mockResolvedValueOnce({ - gas: '0x7530', - simulationFails: undefined, - }) - .mockResolvedValueOnce({ - gas: '0x5208', - simulationFails: undefined, - }); successfulFetchMock.mockResolvedValue({ json: async () => ({ @@ -917,27 +935,18 @@ describe('Across Quotes', () => { }), } as Response); - const result = await getAcrossQuotes({ - messenger, - requests: [QUOTE_REQUEST_MOCK], - transaction: TRANSACTION_META_MOCK, - }); + await expect( + getAcrossQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }), + ).rejects.toThrow( + 'Failed to fetch Across quotes: Error: Batch estimation failed', + ); expect(estimateGasBatchMock).toHaveBeenCalledTimes(1); - expect(estimateGasMock).toHaveBeenCalledTimes(2); - expect( - (result[0].original.metamask.gasLimits as { batch?: unknown }).batch, - ).toBeUndefined(); - expect(result[0].original.metamask.gasLimits.approval).toStrictEqual([ - { - estimate: 30000, - max: 30000, - }, - ]); - expect(result[0].original.metamask.gasLimits.swap).toStrictEqual({ - estimate: 21000, - max: 21000, - }); + expect(estimateGasMock).not.toHaveBeenCalled(); }); it('uses swapTx.gas from Across response when provided', async () => { @@ -958,10 +967,12 @@ describe('Across Quotes', () => { }); expect(estimateGasMock).not.toHaveBeenCalled(); - expect(result[0].original.metamask.gasLimits.swap).toStrictEqual({ - estimate: 24576, - max: 24576, - }); + expect(result[0].original.metamask.gasLimits).toStrictEqual([ + { + estimate: 24576, + max: 24576, + }, + ]); expect(calculateGasCostMock).toHaveBeenCalledWith( expect.objectContaining({ chainId: '0x1', @@ -988,10 +999,12 @@ describe('Across Quotes', () => { }); expect(estimateGasMock).toHaveBeenCalledTimes(1); - expect(result[0].original.metamask.gasLimits.swap).toStrictEqual({ - estimate: 21000, - max: 21000, - }); + expect(result[0].original.metamask.gasLimits).toStrictEqual([ + { + estimate: 21000, + max: 21000, + }, + ]); }); it('throws when the shared gas estimator omits the swap gas result', async () => { @@ -1002,6 +1015,7 @@ describe('Across Quotes', () => { estimateQuoteGasLimitsSpy.mockResolvedValueOnce({ gasLimits: [], + is7702: false, totalGasEstimate: 0, totalGasLimit: 0, usedBatch: false, @@ -1024,6 +1038,46 @@ describe('Across Quotes', () => { estimateQuoteGasLimitsSpy.mockRestore(); }); + it('throws when the shared gas estimator omits an approval gas result', async () => { + const estimateQuoteGasLimitsSpy = jest.spyOn( + quoteGasUtils, + 'estimateQuoteGasLimits', + ); + + estimateQuoteGasLimitsSpy.mockResolvedValueOnce({ + gasLimits: [], + is7702: false, + totalGasEstimate: 0, + totalGasLimit: 0, + usedBatch: false, + }); + + successfulFetchMock.mockResolvedValue({ + json: async () => ({ + ...QUOTE_MOCK, + approvalTxns: [ + { + chainId: 1, + data: '0xaaaa' as Hex, + to: '0xapprove1' as Hex, + }, + ], + }), + } as Response); + + await expect( + getAcrossQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }), + ).rejects.toThrow( + 'Failed to fetch Across quotes: Error: Across approval gas estimate missing at index 0', + ); + + estimateQuoteGasLimitsSpy.mockRestore(); + }); + it('falls back to the swap chain id when an approval transaction chain id is missing during cost calculation', async () => { const estimateQuoteGasLimitsSpy = jest.spyOn( quoteGasUtils, @@ -1039,14 +1093,13 @@ describe('Across Quotes', () => { { estimate: 30000, max: 35000, - source: 'estimated', }, { estimate: 21000, max: 22000, - source: 'estimated', }, ], + is7702: false, totalGasEstimate: 51000, totalGasLimit: 57000, usedBatch: false, @@ -1117,7 +1170,12 @@ describe('Across Quotes', () => { transaction: TRANSACTION_META_MOCK, }); - expect(result[0].original.metamask.gasLimits.approval).toStrictEqual([]); + expect(result[0].original.metamask.gasLimits).toStrictEqual([ + { + estimate: 21000, + max: 21000, + }, + ]); expect(calculateGasCostMock).toHaveBeenCalledTimes(2); }); 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 6e20a586c96..3d1f40afe5b 100644 --- a/packages/transaction-pay-controller/src/strategy/across/across-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/across/across-quotes.ts @@ -309,7 +309,7 @@ async function normalizeQuote( const dustUsd = calculateDustUsd(quote, request, targetFiatRate); const dust = getFiatValueFromUsd(dustUsd, usdToFiatRate); - const { sourceNetwork, gasLimits } = await calculateSourceNetworkCost( + const { gasLimits, is7702, sourceNetwork } = await calculateSourceNetworkCost( quote, messenger, request, @@ -352,6 +352,7 @@ async function normalizeQuote( const metamask = { gasLimits, + is7702, }; return { @@ -497,6 +498,7 @@ async function calculateSourceNetworkCost( ): Promise<{ sourceNetwork: TransactionPayQuote['fees']['sourceNetwork']; gasLimits: AcrossGasLimits; + is7702: boolean; }> { const acrossFallbackGas = getPayStrategiesConfig(messenger).across.fallbackGas; @@ -516,15 +518,13 @@ async function calculateSourceNetworkCost( value: transaction.value ?? '0x0', })), }); + const { batchGasLimit, is7702 } = gasEstimates; - const batchGasLimit = - gasEstimates.usedBatch && - gasEstimates.gasLimits.length === 1 && - orderedTransactions.length > 1 - ? gasEstimates.gasLimits[0] - : undefined; + if (is7702) { + if (!batchGasLimit) { + throw new Error('Across combined batch gas estimate missing'); + } - if (batchGasLimit) { const estimate = calculateGasCost({ chainId: swapChainId, gas: batchGasLimit.estimate, @@ -546,91 +546,67 @@ async function calculateSourceNetworkCost( estimate, max, }, - gasLimits: { - batch: { + is7702: true, + gasLimits: [ + { estimate: batchGasLimit.estimate, max: batchGasLimit.max, }, - }, + ], }; } - orderedTransactions.forEach((transaction, index) => { + const transactionGasLimits = orderedTransactions.map((transaction, index) => { const gasEstimate = gasEstimates.gasLimits[index]; - if (gasEstimate?.source === 'fallback') { - log('Gas estimate failed, using fallback', { - error: gasEstimate.error, - transactionType: transaction.kind, - }); + if (!gasEstimate) { + throw new Error( + transaction.kind === 'swap' + ? 'Across swap gas estimate missing' + : `Across approval gas estimate missing at index ${index}`, + ); } - if (transaction.kind === 'swap' && gasEstimate?.source === 'provided') { - log('Using Across-provided swap gas limit', { - gas: gasEstimate.estimate, - transactionType: transaction.kind, - }); - } + return { + gasEstimate, + transaction, + }; }); - const approvalCount = quote.approvalTxns?.length ?? 0; - const approvalGasLimits = gasEstimates.gasLimits.slice(0, approvalCount); - const swapGas = gasEstimates.gasLimits[approvalCount]; - - if (!swapGas) { - throw new Error('Across swap gas estimate missing'); - } - - const estimate = sumAmounts([ - ...approvalGasLimits.map((gasEstimate, index) => + const estimate = sumAmounts( + transactionGasLimits.map(({ gasEstimate, transaction }) => calculateGasCost({ - chainId: toHex(quote.approvalTxns?.[index]?.chainId ?? swapTx.chainId), + chainId: toHex(transaction.chainId), gas: gasEstimate.estimate, + maxFeePerGas: transaction.maxFeePerGas, + maxPriorityFeePerGas: transaction.maxPriorityFeePerGas, messenger, }), ), - calculateGasCost({ - chainId: swapChainId, - gas: swapGas.estimate, - maxFeePerGas: swapTx.maxFeePerGas, - maxPriorityFeePerGas: swapTx.maxPriorityFeePerGas, - messenger, - }), - ]); + ); - const max = sumAmounts([ - ...approvalGasLimits.map((gasEstimate, index) => + const max = sumAmounts( + transactionGasLimits.map(({ gasEstimate, transaction }) => calculateGasCost({ - chainId: toHex(quote.approvalTxns?.[index]?.chainId ?? swapTx.chainId), + chainId: toHex(transaction.chainId), gas: gasEstimate.max, isMax: true, + maxFeePerGas: transaction.maxFeePerGas, + maxPriorityFeePerGas: transaction.maxPriorityFeePerGas, messenger, }), ), - calculateGasCost({ - chainId: swapChainId, - gas: swapGas.max, - isMax: true, - maxFeePerGas: swapTx.maxFeePerGas, - maxPriorityFeePerGas: swapTx.maxPriorityFeePerGas, - messenger, - }), - ]); + ); return { sourceNetwork: { estimate, max, }, - gasLimits: { - approval: approvalGasLimits.map((gasEstimate) => ({ - estimate: gasEstimate.estimate, - max: gasEstimate.max, - })), - swap: { - estimate: swapGas.estimate, - max: swapGas.max, - }, - }, + is7702: false, + gasLimits: transactionGasLimits.map(({ gasEstimate }) => ({ + estimate: gasEstimate.estimate, + max: gasEstimate.max, + })), }; } 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 dca58d15040..dbf8ab7cdc8 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 @@ -45,10 +45,11 @@ const QUOTE_MOCK: TransactionPayQuote = { }, original: { metamask: { - gasLimits: { - approval: [{ estimate: 21000, max: 21000 }], - swap: { estimate: 22000, max: 22000 }, - }, + gasLimits: [ + { estimate: 21000, max: 21000 }, + { estimate: 22000, max: 22000 }, + ], + is7702: false, }, quote: { approvalTxns: [ @@ -216,12 +217,13 @@ describe('Across Submit', () => { original: { ...QUOTE_MOCK.original, metamask: { - gasLimits: { - batch: { + gasLimits: [ + { estimate: 43000, max: 64000, }, - }, + ], + is7702: true, }, }, } as unknown as TransactionPayQuote; @@ -285,6 +287,30 @@ describe('Across Submit', () => { ); }); + it('throws when the combined 7702 batch gas limit is missing', async () => { + const missingBatchGasQuote = { + ...QUOTE_MOCK, + original: { + ...QUOTE_MOCK.original, + metamask: { + gasLimits: [], + is7702: true, + }, + }, + } as TransactionPayQuote; + + await expect( + submitAcrossQuotes({ + messenger, + quotes: [missingBatchGasQuote], + transaction: TRANSACTION_META_MOCK, + isSmartTransaction: jest.fn(), + }), + ).rejects.toThrow('Missing quote gas limit for Across 7702 batch'); + + expect(addTransactionBatchMock).not.toHaveBeenCalled(); + }); + it('uses predict deposit type when transaction is predict deposit', async () => { const noApprovalQuote = { ...QUOTE_MOCK, @@ -835,14 +861,7 @@ describe('Across Submit', () => { original: { ...QUOTE_MOCK.original, metamask: { - gasLimits: { - ...QUOTE_MOCK.original.metamask.gasLimits, - swap: { - ...QUOTE_MOCK.original.metamask.gasLimits.swap, - estimate: 22000, - max: 33000, - }, - }, + gasLimits: [{ estimate: 22000, max: 33000 }], }, quote: { ...QUOTE_MOCK.original.quote, @@ -870,10 +889,7 @@ describe('Across Submit', () => { original: { ...QUOTE_MOCK.original, metamask: { - gasLimits: { - ...QUOTE_MOCK.original.metamask.gasLimits, - swap: undefined, - }, + gasLimits: [], }, quote: { ...QUOTE_MOCK.original.quote, @@ -900,10 +916,7 @@ describe('Across Submit', () => { original: { ...QUOTE_MOCK.original, metamask: { - gasLimits: { - ...QUOTE_MOCK.original.metamask.gasLimits, - approval: [], - }, + gasLimits: [], }, }, } as TransactionPayQuote; 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 9c25f065be1..b4b73039a52 100644 --- a/packages/transaction-pay-controller/src/strategy/across/across-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/across/across-submit.ts @@ -117,48 +117,42 @@ async function submitTransactions( messenger: TransactionPayControllerMessenger, ): Promise { const { swapTx } = quote.original.quote; - const { gasLimits: quoteGasLimits } = quote.original.metamask; + const { gasLimits: quoteGasLimits, is7702 } = quote.original.metamask; const { from } = quote.request; const chainId = toHex(swapTx.chainId); + const orderedTransactions = getAcrossOrderedTransactions({ + quote: quote.original.quote, + swapType: acrossDepositType, + }); const networkClientId = messenger.call( 'NetworkController:findNetworkClientIdByChainId', chainId, ); - const gasLimit7702 = - quoteGasLimits?.batch && quote.original.quote.approvalTxns?.length - ? toHex(quoteGasLimits.batch.max) + const batchGasLimit = + is7702 && orderedTransactions.length > 1 + ? quoteGasLimits[0]?.max : undefined; - let approvalIndex = 0; - const transactions: PreparedAcrossTransaction[] = - getAcrossOrderedTransactions({ - quote: quote.original.quote, - swapType: acrossDepositType, - }).map((transaction) => { - let gasLimit = gasLimit7702 ? undefined : quoteGasLimits?.swap?.max; - - if (transaction.kind === 'approval') { - gasLimit = gasLimit7702 - ? undefined - : quoteGasLimits?.approval?.[approvalIndex]?.max; - - if (gasLimit === undefined && !gasLimit7702) { - throw new Error( - `Missing quote gas limit for Across approval transaction at index ${approvalIndex}`, - ); - } - - approvalIndex += 1; - } + if (is7702 && orderedTransactions.length > 1 && batchGasLimit === undefined) { + throw new Error('Missing quote gas limit for Across 7702 batch'); + } + + const gasLimit7702 = + batchGasLimit === undefined ? undefined : toHex(batchGasLimit); - if ( - transaction.kind === 'swap' && - gasLimit === undefined && - !gasLimit7702 - ) { - throw new Error('Missing quote gas limit for Across swap transaction'); + const transactions: PreparedAcrossTransaction[] = orderedTransactions.map( + (transaction, index) => { + const gasLimit = gasLimit7702 ? undefined : quoteGasLimits[index]?.max; + + if (gasLimit === undefined && !gasLimit7702) { + const errorMessage = + transaction.kind === 'approval' + ? `Missing quote gas limit for Across approval transaction at index ${index}` + : 'Missing quote gas limit for Across swap transaction'; + + throw new Error(errorMessage); } return { @@ -173,7 +167,8 @@ async function submitTransactions( }), type: transaction.type ?? acrossDepositType, }; - }); + }, + ); const transactionIds: string[] = []; diff --git a/packages/transaction-pay-controller/src/strategy/across/types.ts b/packages/transaction-pay-controller/src/strategy/across/types.ts index 7c47ca0271c..b7e668de82f 100644 --- a/packages/transaction-pay-controller/src/strategy/across/types.ts +++ b/packages/transaction-pay-controller/src/strategy/across/types.ts @@ -81,15 +81,12 @@ export type AcrossGasLimit = { max: number; }; -export type AcrossGasLimits = { - approval?: AcrossGasLimit[]; - batch?: AcrossGasLimit; - swap?: AcrossGasLimit; -}; +export type AcrossGasLimits = AcrossGasLimit[]; export type AcrossQuote = { metamask: { gasLimits: AcrossGasLimits; + is7702?: boolean; }; quote: AcrossSwapApprovalResponse; request: { 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 ccd8ed767e1..f9856c98ae5 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 @@ -26,14 +26,12 @@ import { DEFAULT_RELAY_ORIGIN_GAS_OVERHEAD, DEFAULT_RELAY_QUOTE_URL, DEFAULT_SLIPPAGE, - getEIP7702SupportedChains, isEIP7702Chain, isRelayExecuteEnabled, getGasBuffer, getSlippage, } from '../../utils/feature-flags'; import { calculateGasCost, calculateGasFeeTokenCost } from '../../utils/gas'; -import * as quoteGasUtils from '../../utils/quote-gas'; import { getNativeToken, getTokenBalance, @@ -55,7 +53,6 @@ jest.mock('../../utils/gas', () => ({ })); jest.mock('../../utils/feature-flags', () => ({ ...jest.requireActual('../../utils/feature-flags'), - getEIP7702SupportedChains: jest.fn(), isEIP7702Chain: jest.fn(), isRelayExecuteEnabled: jest.fn(), getGasBuffer: jest.fn(), @@ -118,6 +115,7 @@ const QUOTE_MOCK = { }, metamask: { gasLimits: [21000], + is7702: false, }, steps: [ { @@ -175,7 +173,6 @@ describe('Relay Quotes Utils', () => { const calculateGasFeeTokenCostMock = jest.mocked(calculateGasFeeTokenCost); const getNativeTokenMock = jest.mocked(getNativeToken); const getTokenBalanceMock = jest.mocked(getTokenBalance); - const getEIP7702SupportedChainsMock = jest.mocked(getEIP7702SupportedChains); const isEIP7702ChainMock = jest.mocked(isEIP7702Chain); const isRelayExecuteEnabledMock = jest.mocked(isRelayExecuteEnabled); const getGasBufferMock = jest.mocked(getGasBuffer); @@ -217,9 +214,6 @@ describe('Relay Quotes Utils', () => { ...getDefaultRemoteFeatureFlagControllerState(), }); - getEIP7702SupportedChainsMock.mockReturnValue([ - QUOTE_REQUEST_MOCK.sourceChainId, - ]); isEIP7702ChainMock.mockReturnValue(true); isRelayExecuteEnabledMock.mockReturnValue(false); getGasBufferMock.mockReturnValue(1.0); @@ -856,8 +850,10 @@ describe('Relay Quotes Utils', () => { } as TransactionMeta, }); - // Single relay gas limit (21000) + original tx gas (0x13498 = 79000) = 100000 - expect(result[0].original.metamask.gasLimits).toStrictEqual([100000]); + expect(result[0].original.metamask.gasLimits).toStrictEqual([ + 79000, 21000, + ]); + expect(result[0].original.metamask.is7702).toBe(false); }); it('prefers nestedTransactions gas over txParams.gas for post-quote', async () => { @@ -890,9 +886,10 @@ describe('Relay Quotes Utils', () => { } as TransactionMeta, }); - // nestedTransactions gas (0xC350 = 50000) used instead of txParams.gas (79000) - // Single relay gas limit (21000) + original tx gas (50000) = 71000 - expect(result[0].original.metamask.gasLimits).toStrictEqual([71000]); + expect(result[0].original.metamask.gasLimits).toStrictEqual([ + 50000, 21000, + ]); + expect(result[0].original.metamask.is7702).toBe(false); }); it('adds original transaction gas to EIP-7702 combined gas limit for post-quote', async () => { @@ -951,6 +948,7 @@ describe('Relay Quotes Utils', () => { // EIP-7702: original tx gas (79000) added to combined relay gas (51000) expect(result[0].original.metamask.gasLimits).toStrictEqual([130000]); + expect(result[0].original.metamask.is7702).toBe(true); }); it('prepends original transaction gas to multiple relay gas limits for post-quote', async () => { @@ -1011,6 +1009,7 @@ describe('Relay Quotes Utils', () => { expect(result[0].original.metamask.gasLimits).toStrictEqual([ 79000, 21000, 30000, ]); + expect(result[0].original.metamask.is7702).toBe(false); }); it('skips original transaction gas when txParams.gas is missing for post-quote', async () => { @@ -1041,6 +1040,7 @@ describe('Relay Quotes Utils', () => { // No gas on txParams or nestedTransactions — only relay gas limits expect(result[0].original.metamask.gasLimits).toStrictEqual([21000]); + expect(result[0].original.metamask.is7702).toBe(false); }); it('preserves estimate vs limit distinction when using fallback gas for post-quote', async () => { @@ -1393,7 +1393,7 @@ describe('Relay Quotes Utils', () => { expect(result[0].fees.isSourceGasFeeToken).toBe(true); }); - it('simulates with proxy address for predictWithdraw post-quote gas fee token', async () => { + it('simulates with proxy address and scales gas fee token for predictWithdraw post-quote', async () => { successfulFetchMock.mockResolvedValue({ json: async () => QUOTE_MOCK, } as never); @@ -1419,6 +1419,11 @@ describe('Relay Quotes Utils', () => { from: '0xproxy', }), ); + calculateGasFeeTokenCostMock.mock.calls.forEach(([params]) => { + expect(Number(params.gasFeeToken.amount)).toBeGreaterThan( + Number(GAS_FEE_TOKEN_MOCK.amount), + ); + }); }); it('falls back to native gas cost for predictWithdraw post-quote when simulation returns no matching token', async () => { @@ -1842,7 +1847,11 @@ describe('Relay Quotes Utils', () => { quoteMock.steps[0].items.push({ data: { + chainId: 1, + data: '0x456' as Hex, + from: FROM_MOCK, gas: '480000', + to: '0x3' as Hex, }, } as never); @@ -1850,12 +1859,20 @@ describe('Relay Quotes Utils', () => { items: [ { data: { + chainId: 1, + data: '0x789' as Hex, + from: FROM_MOCK, gas: '1000', + to: '0x4' as Hex, }, }, { data: { + chainId: 1, + data: '0xabc' as Hex, + from: FROM_MOCK, gas: '2000', + to: '0x5' as Hex, }, }, ], @@ -1864,6 +1881,10 @@ describe('Relay Quotes Utils', () => { successfulFetchMock.mockResolvedValue({ json: async () => quoteMock, } as never); + estimateGasBatchMock.mockResolvedValue({ + totalGasLimit: 504000, + gasLimits: [21000, 480000, 1000, 2000], + }); await getRelayQuotes({ messenger, @@ -1912,13 +1933,21 @@ describe('Relay Quotes Utils', () => { quote.steps[0].items.push({ data: { + chainId: 1, + data: '0x456' as Hex, + from: FROM_MOCK, gas: '21000', + to: '0x3' as Hex, }, } as never); successfulFetchMock.mockResolvedValue({ json: async () => quote, } as never); + estimateGasBatchMock.mockResolvedValue({ + totalGasLimit: 42000, + gasLimits: [21000, 21000], + }); getTokenBalanceMock.mockReturnValue('1724999999999999'); getGasFeeTokensMock.mockResolvedValue([GAS_FEE_TOKEN_MOCK]); @@ -1939,7 +1968,7 @@ describe('Relay Quotes Utils', () => { ); }); - it('uses proxy simulation for predictWithdraw post-quote with single relay param', async () => { + it('uses proxy simulation and scales gas fee token amount for post-quote with a single relay param', async () => { successfulFetchMock.mockResolvedValue({ json: async () => QUOTE_MOCK, } as never); @@ -1975,6 +2004,11 @@ describe('Relay Quotes Utils', () => { from: '0xproxy', }), ); + calculateGasFeeTokenCostMock.mock.calls.forEach(([params]) => { + expect(Number(params.gasFeeToken.amount)).toBeGreaterThan( + Number(GAS_FEE_TOKEN_MOCK.amount), + ); + }); }); it('not using gas fee token if sufficient native balance', async () => { @@ -2687,7 +2721,7 @@ describe('Relay Quotes Utils', () => { ); }); - it('falls back to per-transaction estimation when the source chain does not support EIP-7702', async () => { + it('uses batch estimation for multiple transactions even when the source chain does not support EIP-7702', async () => { const quoteMock = cloneDeep(QUOTE_MOCK); quoteMock.steps[0].items[0].data.gas = '30000'; quoteMock.steps[0].items.push({ @@ -2704,9 +2738,9 @@ describe('Relay Quotes Utils', () => { } as never); isEIP7702ChainMock.mockReturnValue(false); - estimateGasMock.mockResolvedValue({ - gas: toHex(50000), - simulationFails: undefined, + estimateGasBatchMock.mockResolvedValue({ + totalGasLimit: 80000, + gasLimits: [30000, 50000], }); const result = await getRelayQuotes({ @@ -2715,14 +2749,14 @@ describe('Relay Quotes Utils', () => { transaction: TRANSACTION_META_MOCK, }); - expect(estimateGasBatchMock).not.toHaveBeenCalled(); - expect(estimateGasMock).toHaveBeenCalledTimes(1); + expect(estimateGasBatchMock).toHaveBeenCalledTimes(1); + expect(estimateGasMock).not.toHaveBeenCalled(); expect(result[0].original.metamask.gasLimits).toStrictEqual([ 30000, 50000, ]); }); - it('uses fallback gas when estimateGasBatch fails', async () => { + it('throws when estimateGasBatch fails', async () => { const quoteMock = cloneDeep(QUOTE_MOCK); quoteMock.steps[0].items.push({ data: { @@ -2741,14 +2775,14 @@ describe('Relay Quotes Utils', () => { new Error('Batch estimation failed'), ); - await getRelayQuotes({ - messenger, - requests: [QUOTE_REQUEST_MOCK], - transaction: TRANSACTION_META_MOCK, - }); - - expect(calculateGasCostMock).toHaveBeenCalledWith( - expect.objectContaining({ gas: 900000 + 21000 }), + await expect( + getRelayQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }), + ).rejects.toThrow( + 'Failed to fetch Relay quotes: Error: Batch estimation failed', ); }); @@ -2765,6 +2799,7 @@ describe('Relay Quotes Utils', () => { expect(result[0].original.metamask).toStrictEqual({ gasLimits: [21000], + is7702: false, }); }); @@ -2789,9 +2824,8 @@ describe('Relay Quotes Utils', () => { ); }); - it('reuses populated relay params when later transactions omit estimation fields', async () => { + it('throws when later relay transactions omit required estimation fields', async () => { const quoteMock = cloneDeep(QUOTE_MOCK); - delete quoteMock.steps[0].items[0].data.gas; quoteMock.steps[0].items.push({ data: {}, } as never); @@ -2800,64 +2834,23 @@ describe('Relay Quotes Utils', () => { json: async () => quoteMock, } as never); - estimateGasMock.mockResolvedValue({ - gas: toHex(50000), - simulationFails: undefined, - }); - - await getRelayQuotes({ - messenger, - requests: [QUOTE_REQUEST_MOCK], - transaction: TRANSACTION_META_MOCK, - }); - - expect(estimateGasBatchMock).not.toHaveBeenCalled(); - expect(findNetworkClientIdByChainIdMock).toHaveBeenNthCalledWith( - 1, - '0x1', - ); - expect(findNetworkClientIdByChainIdMock).toHaveBeenNthCalledWith( - 2, - '0x1', - ); - expect(estimateGasMock).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - data: '0x123', - from: FROM_MOCK, - to: '0x2', - value: '0x0', + await expect( + getRelayQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, }), - NETWORK_CLIENT_ID_MOCK, + ).rejects.toThrow( + 'Failed to fetch Relay quotes: Error: Relay transaction params missing required gas estimation fields at index 1', ); + + expect(estimateGasBatchMock).not.toHaveBeenCalled(); + expect(estimateGasMock).not.toHaveBeenCalled(); }); - it('preserves falsy fallback relay params when later transactions omit estimation fields', async () => { - const estimateQuoteGasLimitsSpy = jest.spyOn( - quoteGasUtils, - 'estimateQuoteGasLimits', - ); + it('throws when relay transaction estimation fields are missing', async () => { const quoteMock = cloneDeep(QUOTE_MOCK); - quoteMock.steps[0].items = [ - { - ...quoteMock.steps[0].items[0], - data: { - chainId: 0, - data: '' as Hex, - from: '' as Hex, - to: '' as Hex, - }, - }, - { - ...quoteMock.steps[0].items[0], - data: { - chainId: 1, - data: '0x456' as Hex, - from: FROM_MOCK, - to: '0x3' as Hex, - }, - }, { ...quoteMock.steps[0].items[0], data: {}, @@ -2868,87 +2861,18 @@ describe('Relay Quotes Utils', () => { json: async () => quoteMock, } as never); - estimateQuoteGasLimitsSpy.mockResolvedValueOnce({ - gasLimits: [ - { estimate: 21000, max: 21000, source: 'estimated' }, - { estimate: 22000, max: 22000, source: 'estimated' }, - { estimate: 23000, max: 23000, source: 'estimated' }, - ], - totalGasEstimate: 66000, - totalGasLimit: 66000, - usedBatch: false, - }); - - await getRelayQuotes({ - messenger, - requests: [QUOTE_REQUEST_MOCK], - transaction: TRANSACTION_META_MOCK, - }); - - expect(estimateQuoteGasLimitsSpy).toHaveBeenCalledWith( - expect.objectContaining({ - allowBatch: false, - transactions: [ - expect.objectContaining({ - chainId: '0x0', - data: '', - from: '', - to: '', - }), - expect.objectContaining({ - chainId: '0x1', - data: '0x456', - from: FROM_MOCK, - to: '0x3', - }), - expect.objectContaining({ - chainId: '0x0', - data: '', - from: '', - to: '', - }), - ], + await expect( + getRelayQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, }), + ).rejects.toThrow( + 'Failed to fetch Relay quotes: Error: Relay transaction params missing required gas estimation fields at index 0', ); - estimateQuoteGasLimitsSpy.mockRestore(); - }); - - it('uses placeholder relay params when no estimation fields are provided', async () => { - const quoteMock = cloneDeep(QUOTE_MOCK); - quoteMock.steps[0].items = [ - { - ...quoteMock.steps[0].items[0], - data: {}, - }, - ]; - - 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(estimateGasBatchMock).not.toHaveBeenCalled(); - expect(findNetworkClientIdByChainIdMock).toHaveBeenCalledWith('0x0'); - expect(estimateGasMock).toHaveBeenCalledWith( - expect.objectContaining({ - data: '0x', - from: '0x0000000000000000000000000000000000000000', - to: '0x0000000000000000000000000000000000000000', - value: '0x0', - }), - NETWORK_CLIENT_ID_MOCK, - ); + expect(estimateGasMock).not.toHaveBeenCalled(); }); describe('gas buffer support', () => { 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 d9b497a4b26..5a939181ff2 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -43,6 +43,7 @@ import { } from '../../utils/feature-flags'; import { calculateGasCost } from '../../utils/gas'; import { estimateQuoteGasLimits } from '../../utils/quote-gas'; +import type { QuoteGasTransaction } from '../../utils/quote-gas'; import { getNativeToken, getTokenBalance, @@ -424,6 +425,7 @@ async function normalizeQuote( const { gasLimits, + is7702, isGasFeeToken: isSourceGasFeeToken, ...sourceNetwork } = await calculateSourceNetworkCost( @@ -472,6 +474,7 @@ async function normalizeQuote( const metamask = { ...quote.metamask, gasLimits, + is7702, }; return { @@ -582,6 +585,7 @@ async function calculateSourceNetworkCost( TransactionPayQuote['fees']['sourceNetwork'] & { gasLimits: number[]; isGasFeeToken?: boolean; + is7702: boolean; } > { const { from, sourceChainId, sourceTokenAddress } = request; @@ -591,7 +595,12 @@ async function calculateSourceNetworkCost( const zeroAmount = { fiat: '0', human: '0', raw: '0', usd: '0' }; - return { estimate: zeroAmount, max: zeroAmount, gasLimits: [] }; + return { + estimate: zeroAmount, + max: zeroAmount, + gasLimits: [], + is7702: false, + }; } const relayParams = quote.steps @@ -612,11 +621,13 @@ async function calculateSourceNetworkCost( fromOverride, ); - const { totalGasEstimate, totalGasLimit, gasLimits } = request.isPostQuote - ? combinePostQuoteGas(relayOnlyGas, transaction) - : relayOnlyGas; + const { gasLimits, is7702, totalGasEstimate, totalGasLimit } = + request.isPostQuote + ? combinePostQuoteGas(relayOnlyGas, transaction) + : relayOnlyGas; log('Gas limit', { + is7702, totalGasEstimate, totalGasLimit, gasLimits, @@ -646,7 +657,7 @@ async function calculateSourceNetworkCost( getNativeToken(sourceChainId), ); - const result = { estimate, max, gasLimits }; + const result = { estimate, max, gasLimits, is7702 }; if (new BigNumber(nativeBalance).isGreaterThanOrEqualTo(max.raw)) { return result; @@ -711,6 +722,7 @@ async function calculateSourceNetworkCost( estimate: gasFeeTokenCost, max: gasFeeTokenCost, gasLimits, + is7702, }; } @@ -746,6 +758,7 @@ async function calculateSourceNetworkCost( estimate: gasFeeTokenCost, max: gasFeeTokenCost, gasLimits, + is7702, }; } @@ -767,55 +780,57 @@ async function calculateSourceNetworkGasLimit( totalGasEstimate: number; totalGasLimit: number; gasLimits: number[]; + is7702: boolean; }> { - const fallbackChainId = params.find( - (singleParams) => singleParams.chainId !== undefined, - )?.chainId; - const fallbackFrom = params.find( - (singleParams) => singleParams.from !== undefined, - )?.from; - const fallbackTo = params.find( - (singleParams) => singleParams.to !== undefined, - )?.to; - const fallbackData = params.find( - (singleParams) => singleParams.data !== undefined, - )?.data; + const transactions = params.map((singleParams, index) => + toRelayQuoteGasTransaction(singleParams, index, fromOverride), + ); const relayGasResult = await estimateQuoteGasLimits({ - allowBatch: params.every( - (singleParams) => - singleParams.chainId !== undefined && - singleParams.from !== undefined && - singleParams.to !== undefined && - singleParams.data !== undefined, - ), fallbackGas: getFeatureFlags(messenger).relayFallbackGas, fallbackOnSimulationFailure: true, messenger, - transactions: params.map((singleParams) => ({ - chainId: toHex(singleParams.chainId ?? fallbackChainId ?? 0), - data: singleParams.data ?? fallbackData ?? '0x', - from: - fromOverride ?? - singleParams.from ?? - fallbackFrom ?? - '0x0000000000000000000000000000000000000000', - gas: fromOverride ? undefined : singleParams.gas, - to: - singleParams.to ?? - fallbackTo ?? - '0x0000000000000000000000000000000000000000', - value: singleParams.value ?? '0', - })), + transactions, }); return { gasLimits: relayGasResult.gasLimits.map((gasLimit) => gasLimit.max), + is7702: relayGasResult.is7702, totalGasEstimate: relayGasResult.totalGasEstimate, totalGasLimit: relayGasResult.totalGasLimit, }; } +function toRelayQuoteGasTransaction( + singleParams: RelayQuote['steps'][0]['items'][0]['data'], + index: number, + fromOverride?: Hex, +): QuoteGasTransaction { + const requiredParams = singleParams as Partial< + RelayQuote['steps'][0]['items'][0]['data'] + >; + + if ( + requiredParams.chainId === undefined || + requiredParams.data === undefined || + requiredParams.from === undefined || + requiredParams.to === undefined + ) { + throw new Error( + `Relay transaction params missing required gas estimation fields at index ${index}`, + ); + } + + return { + chainId: toHex(requiredParams.chainId), + data: requiredParams.data, + from: fromOverride ?? requiredParams.from, + gas: fromOverride ? undefined : singleParams.gas, + to: requiredParams.to, + value: singleParams.value ?? '0', + }; +} + /** * Combine the original transaction's gas with relay gas for post-quote flows. * @@ -827,6 +842,7 @@ async function calculateSourceNetworkGasLimit( * @param relayGas.totalGasEstimate - Estimated gas total. * @param relayGas.totalGasLimit - Maximum gas total. * @param relayGas.gasLimits - Per-transaction gas limits. + * @param relayGas.is7702 - Whether the relay gas came from a combined 7702 batch estimate. * @param transaction - Original transaction metadata. * @returns Combined gas estimates including the original transaction. */ @@ -835,9 +851,15 @@ function combinePostQuoteGas( totalGasEstimate: number; totalGasLimit: number; gasLimits: number[]; + is7702: boolean; }, transaction: TransactionMeta, -): { totalGasEstimate: number; totalGasLimit: number; gasLimits: number[] } { +): { + totalGasEstimate: number; + totalGasLimit: number; + gasLimits: number[]; + is7702: boolean; +} { const nestedGas = transaction.nestedTransactions?.find((tx) => tx.gas)?.gas; const rawGas = nestedGas ?? transaction.txParams.gas; const originalTxGas = rawGas ? new BigNumber(rawGas).toNumber() : undefined; @@ -847,12 +869,10 @@ function combinePostQuoteGas( } let { gasLimits } = relayGas; - // TODO: Test EIP-7702 support on the chain as well before assuming single gas limit. - const isEIP7702 = gasLimits.length === 1; - if (isEIP7702) { - // Single gas limit (either one relay param or 7702 combined) — - // add the original tx gas so the batch uses a single 7702 limit. + if (relayGas.is7702) { + // Combined 7702 gas limit — add the original tx gas so the batch + // keeps using a single 7702 limit. gasLimits = [gasLimits[0] + originalTxGas]; } else { // Multiple individual gas limits — prepend the original tx gas @@ -865,12 +885,17 @@ function combinePostQuoteGas( log('Combined original tx gas with relay gas', { originalTxGas, - isEIP7702, + is7702: relayGas.is7702, gasLimits, totalGasLimit, }); - return { totalGasEstimate, totalGasLimit, gasLimits }; + return { + totalGasEstimate, + totalGasLimit, + gasLimits, + is7702: relayGas.is7702, + }; } /** 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 6286ab2c683..00e37dcde02 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 @@ -65,6 +65,7 @@ const ORIGINAL_QUOTE_MOCK = { }, metamask: { gasLimits: [21000, 21000], + is7702: false, }, request: {}, steps: [ @@ -930,6 +931,7 @@ describe('Relay Submit Utils', () => { it('activates 7702 mode with single combined post-quote gas limit', async () => { request.quotes[0].original.metamask.gasLimits = [203093]; + request.quotes[0].original.metamask.is7702 = true; await submitRelayQuotes(request); @@ -956,6 +958,17 @@ describe('Relay Submit Utils', () => { }), ); }); + + it('throws when a 7702 post-quote batch is missing its combined gas limit', async () => { + request.quotes[0].original.metamask.gasLimits = []; + request.quotes[0].original.metamask.is7702 = true; + + await expect(submitRelayQuotes(request)).rejects.toThrow( + 'Missing quote gas limit for Relay 7702 batch', + ); + + expect(addTransactionBatchMock).not.toHaveBeenCalled(); + }); }); it('adds transaction batch with single gasLimit7702', async () => { @@ -964,6 +977,7 @@ describe('Relay Submit Utils', () => { }); request.quotes[0].original.metamask.gasLimits = [42000]; + request.quotes[0].original.metamask.is7702 = true; await submitRelayQuotes(request); 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 bbcabfe71a7..5ccf7e53544 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts @@ -520,7 +520,7 @@ async function submitViaTransactionController( })) : undefined; - const { gasLimits } = quote.original.metamask; + const { gasLimits, is7702 } = quote.original.metamask; if (allParams.length === 1) { const transactionParams = { @@ -541,10 +541,15 @@ async function submitViaTransactionController( }, ); } else { + const batchGasLimit = + is7702 && allParams.length > 1 ? gasLimits[0] : undefined; + + if (is7702 && allParams.length > 1 && batchGasLimit === undefined) { + throw new Error('Missing quote gas limit for Relay 7702 batch'); + } + const gasLimit7702 = - gasLimits.length === 1 && allParams.length > 1 - ? toHex(gasLimits[0]) - : undefined; + batchGasLimit === undefined ? undefined : toHex(batchGasLimit); const transactions = allParams.map((singleParams, index) => { const gasLimit = gasLimits[index]; diff --git a/packages/transaction-pay-controller/src/strategy/relay/types.ts b/packages/transaction-pay-controller/src/strategy/relay/types.ts index 6804b837e6b..9ae287653b8 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/types.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/types.ts @@ -75,6 +75,7 @@ export type RelayQuote = { metamask: { gasLimits: number[]; isExecute?: boolean; + is7702?: boolean; isMaxGasStation?: boolean; }; request: RelayQuoteRequest; diff --git a/packages/transaction-pay-controller/src/utils/feature-flags.ts b/packages/transaction-pay-controller/src/utils/feature-flags.ts index 3f7a4d14863..b8528a8904c 100644 --- a/packages/transaction-pay-controller/src/utils/feature-flags.ts +++ b/packages/transaction-pay-controller/src/utils/feature-flags.ts @@ -378,23 +378,6 @@ function getCaseInsensitive( return entry?.[1]; } -/** - * Get the chains that support EIP-7702. - * - * @param messenger - Controller messenger. - * @returns Supported chain IDs. - */ -export function getEIP7702SupportedChains( - messenger: TransactionPayControllerMessenger, -): Hex[] { - const state = messenger.call('RemoteFeatureFlagController:getState'); - const eip7702Flags = state.remoteFeatureFlags.confirmations_eip_7702 as - | { supportedChains?: Hex[] } - | undefined; - - return eip7702Flags?.supportedChains ?? []; -} - /** * Checks if a chain supports EIP-7702. * @@ -406,7 +389,14 @@ export function isEIP7702Chain( messenger: TransactionPayControllerMessenger, chainId: Hex, ): boolean { - return getEIP7702SupportedChains(messenger).some( + const state = messenger.call('RemoteFeatureFlagController:getState'); + const eip7702Flags = state.remoteFeatureFlags.confirmations_eip_7702 as + | { supportedChains?: Hex[] } + | undefined; + + const supportedChains = eip7702Flags?.supportedChains ?? []; + + return supportedChains.some( (supported) => supported.toLowerCase() === chainId.toLowerCase(), ); } diff --git a/packages/transaction-pay-controller/src/utils/quote-gas.test.ts b/packages/transaction-pay-controller/src/utils/quote-gas.test.ts index 7e7daa811dc..c11ab1f04ef 100644 --- a/packages/transaction-pay-controller/src/utils/quote-gas.test.ts +++ b/packages/transaction-pay-controller/src/utils/quote-gas.test.ts @@ -1,6 +1,6 @@ import type { Hex } from '@metamask/utils'; -import { getGasBuffer, isEIP7702Chain } from './feature-flags'; +import { getGasBuffer } from './feature-flags'; import { estimateGasLimit } from './gas'; import { estimateQuoteGasLimits } from './quote-gas'; import { getMessengerMock } from '../tests/messenger-mock'; @@ -8,7 +8,6 @@ import { getMessengerMock } from '../tests/messenger-mock'; jest.mock('./feature-flags', () => ({ ...jest.requireActual('./feature-flags'), getGasBuffer: jest.fn(), - isEIP7702Chain: jest.fn(), })); jest.mock('./gas', () => ({ @@ -18,7 +17,6 @@ jest.mock('./gas', () => ({ describe('quote gas estimation', () => { const getGasBufferMock = jest.mocked(getGasBuffer); - const isEIP7702ChainMock = jest.mocked(isEIP7702Chain); const estimateGasLimitMock = jest.mocked(estimateGasLimit); const { estimateGasBatchMock, messenger } = getMessengerMock(); @@ -45,30 +43,24 @@ describe('quote gas estimation', () => { jest.resetAllMocks(); getGasBufferMock.mockReturnValue(1); - isEIP7702ChainMock.mockReturnValue(false); }); - it('returns empty gas limits when there are no transactions', async () => { - const result = await estimateQuoteGasLimits({ - messenger, - transactions: [], - }); + it('throws when there are no transactions', async () => { + await expect( + estimateQuoteGasLimits({ + messenger, + transactions: [], + }), + ).rejects.toThrow('Quote gas estimation requires at least one transaction'); expect(estimateGasBatchMock).not.toHaveBeenCalled(); expect(estimateGasLimitMock).not.toHaveBeenCalled(); - expect(result).toStrictEqual({ - gasLimits: [], - totalGasEstimate: 0, - totalGasLimit: 0, - usedBatch: false, - }); }); - it('uses per-transaction estimation when the source chain does not support EIP-7702', async () => { - estimateGasLimitMock.mockResolvedValueOnce({ - estimate: 21000, - max: 21000, - usedFallback: false, + it('uses batch estimation for multiple transactions even when the chain does not support EIP-7702', async () => { + estimateGasBatchMock.mockResolvedValue({ + totalGasLimit: 51000, + gasLimits: [21000, 30000], }); const result = await estimateQuoteGasLimits({ @@ -77,78 +69,44 @@ describe('quote gas estimation', () => { transactions: TRANSACTIONS_MOCK, }); - expect(estimateGasBatchMock).not.toHaveBeenCalled(); - expect(estimateGasLimitMock).toHaveBeenCalledTimes(1); + expect(estimateGasBatchMock).toHaveBeenCalledTimes(1); + expect(estimateGasLimitMock).not.toHaveBeenCalled(); expect(result).toStrictEqual({ gasLimits: [ { estimate: 21000, max: 21000, - source: 'estimated', }, { estimate: 30000, max: 30000, - source: 'provided', }, ], + is7702: false, totalGasEstimate: 51000, totalGasLimit: 51000, - usedBatch: false, + usedBatch: true, }); }); - it('uses per-transaction estimation when batch estimation is explicitly disabled', async () => { - isEIP7702ChainMock.mockReturnValue(true); + it('uses per-transaction estimation when there is only one transaction', async () => { estimateGasLimitMock.mockResolvedValueOnce({ estimate: 21000, max: 21000, usedFallback: false, }); - const result = await estimateQuoteGasLimits({ - allowBatch: false, - messenger, - transactions: TRANSACTIONS_MOCK, - }); - - expect(estimateGasBatchMock).not.toHaveBeenCalled(); - expect(result.usedBatch).toBe(false); - }); - - it('uses per-transaction estimation when transactions do not share a batch context', async () => { - isEIP7702ChainMock.mockReturnValue(true); - estimateGasLimitMock - .mockResolvedValueOnce({ - estimate: 21000, - max: 21000, - usedFallback: false, - }) - .mockResolvedValueOnce({ - estimate: 22000, - max: 22000, - usedFallback: false, - }); - const result = await estimateQuoteGasLimits({ messenger, - transactions: [ - TRANSACTIONS_MOCK[0], - { - ...TRANSACTIONS_MOCK[1], - from: '0x9999999999999999999999999999999999999999' as Hex, - gas: undefined, - }, - ], + transactions: [TRANSACTIONS_MOCK[0]], }); expect(estimateGasBatchMock).not.toHaveBeenCalled(); - expect(estimateGasLimitMock).toHaveBeenCalledTimes(2); + expect(result.is7702).toBe(false); expect(result.usedBatch).toBe(false); }); it('uses batch estimation when the source chain supports EIP-7702', async () => { - isEIP7702ChainMock.mockReturnValue(true); getGasBufferMock.mockReturnValue(1.5); estimateGasBatchMock.mockResolvedValue({ totalGasLimit: 50000, @@ -178,13 +136,17 @@ describe('quote gas estimation', () => { ], }); expect(result).toStrictEqual({ + batchGasLimit: { + estimate: 75000, + max: 75000, + }, gasLimits: [ { estimate: 75000, max: 75000, - source: 'batch', }, ], + is7702: true, totalGasEstimate: 75000, totalGasLimit: 75000, usedBatch: true, @@ -192,7 +154,6 @@ describe('quote gas estimation', () => { }); it('uses per-transaction batch gas limits and preserves provided gas when it already matches', async () => { - isEIP7702ChainMock.mockReturnValue(true); getGasBufferMock.mockReturnValue(1.5); estimateGasBatchMock.mockResolvedValue({ totalGasLimit: 51000, @@ -209,14 +170,13 @@ describe('quote gas estimation', () => { { estimate: 31500, max: 31500, - source: 'batch', }, { estimate: 30000, max: 30000, - source: 'batch', }, ], + is7702: false, totalGasEstimate: 61500, totalGasLimit: 61500, usedBatch: true, @@ -224,7 +184,6 @@ describe('quote gas estimation', () => { }); it('buffers per-transaction batch gas when a provided gas value is overridden', async () => { - isEIP7702ChainMock.mockReturnValue(true); getGasBufferMock.mockReturnValue(1.5); estimateGasBatchMock.mockResolvedValue({ totalGasLimit: 56000, @@ -241,78 +200,51 @@ describe('quote gas estimation', () => { { estimate: 31500, max: 31500, - source: 'batch', }, { estimate: 52500, max: 52500, - source: 'batch', }, ], + is7702: false, totalGasEstimate: 84000, totalGasLimit: 84000, usedBatch: true, }); }); - it('falls back to per-transaction estimation when batch estimation fails', async () => { - isEIP7702ChainMock.mockReturnValue(true); + it('throws when batch estimation fails', async () => { estimateGasBatchMock.mockRejectedValue( new Error('Batch estimation failed'), ); - estimateGasLimitMock.mockResolvedValueOnce({ - estimate: 21000, - max: 21000, - usedFallback: false, - }); - const result = await estimateQuoteGasLimits({ - fallbackOnSimulationFailure: true, - messenger, - transactions: TRANSACTIONS_MOCK, - }); + await expect( + estimateQuoteGasLimits({ + fallbackOnSimulationFailure: true, + messenger, + transactions: TRANSACTIONS_MOCK, + }), + ).rejects.toThrow('Batch estimation failed'); expect(estimateGasBatchMock).toHaveBeenCalledTimes(1); - expect(estimateGasLimitMock).toHaveBeenCalledTimes(1); - expect(result).toStrictEqual({ - gasLimits: [ - { - estimate: 21000, - max: 21000, - source: 'estimated', - }, - { - estimate: 30000, - max: 30000, - source: 'provided', - }, - ], - totalGasEstimate: 51000, - totalGasLimit: 51000, - usedBatch: false, - }); + expect(estimateGasLimitMock).not.toHaveBeenCalled(); }); - it('falls back to per-transaction estimation when batch returns an unexpected gas limit count', async () => { - isEIP7702ChainMock.mockReturnValue(true); + it('throws when batch returns an unexpected gas limit count', async () => { estimateGasBatchMock.mockResolvedValue({ totalGasLimit: 123000, gasLimits: [21000, 30000, 72000], }); - estimateGasLimitMock.mockResolvedValueOnce({ - estimate: 21000, - max: 21000, - usedFallback: false, - }); - const result = await estimateQuoteGasLimits({ - messenger, - transactions: TRANSACTIONS_MOCK, - }); + await expect( + estimateQuoteGasLimits({ + messenger, + transactions: TRANSACTIONS_MOCK, + }), + ).rejects.toThrow('Unexpected batch gas limit count'); expect(estimateGasBatchMock).toHaveBeenCalledTimes(1); - expect(estimateGasLimitMock).toHaveBeenCalledTimes(1); - expect(result.usedBatch).toBe(false); + expect(estimateGasLimitMock).not.toHaveBeenCalled(); }); it('treats numeric gas values as provided gas limits', async () => { @@ -333,9 +265,9 @@ describe('quote gas estimation', () => { { estimate: 42000, max: 42000, - source: 'provided', }, ], + is7702: false, totalGasEstimate: 42000, totalGasLimit: 42000, usedBatch: false, @@ -369,7 +301,6 @@ describe('quote gas estimation', () => { }); it('defaults missing transaction values to zero for batch estimation', async () => { - isEIP7702ChainMock.mockReturnValue(true); estimateGasBatchMock.mockResolvedValue({ totalGasLimit: 50000, gasLimits: [50000], diff --git a/packages/transaction-pay-controller/src/utils/quote-gas.ts b/packages/transaction-pay-controller/src/utils/quote-gas.ts index c2439e1f869..af7bcf2ef0e 100644 --- a/packages/transaction-pay-controller/src/utils/quote-gas.ts +++ b/packages/transaction-pay-controller/src/utils/quote-gas.ts @@ -4,7 +4,7 @@ import type { Hex } from '@metamask/utils'; import { createModuleLogger } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; -import { getGasBuffer, isEIP7702Chain } from './feature-flags'; +import { getGasBuffer } from './feature-flags'; import { estimateGasLimit } from './gas'; import type { TransactionPayControllerMessenger } from '..'; import { projectLogger } from '../logger'; @@ -23,18 +23,14 @@ export type QuoteGasTransaction = { export type QuoteGasLimit = { estimate: number; max: number; - source: 'batch' | 'estimated' | 'fallback' | 'provided'; - error?: unknown; }; export async function estimateQuoteGasLimits({ - allowBatch = true, fallbackGas, fallbackOnSimulationFailure = false, messenger, transactions, }: { - allowBatch?: boolean; fallbackGas?: { estimate: number; max: number; @@ -43,48 +39,34 @@ export async function estimateQuoteGasLimits({ messenger: TransactionPayControllerMessenger; transactions: QuoteGasTransaction[]; }): Promise<{ + batchGasLimit?: QuoteGasLimit; gasLimits: QuoteGasLimit[]; + is7702: boolean; totalGasEstimate: number; totalGasLimit: number; usedBatch: boolean; }> { if (transactions.length === 0) { - return { - gasLimits: [], - totalGasEstimate: 0, - totalGasLimit: 0, - usedBatch: false, - }; + throw new Error('Quote gas estimation requires at least one transaction'); } - const [firstTransaction] = transactions; - const useBatch = - allowBatch && - transactions.length > 1 && - hasUniformBatchContext(transactions) && - isEIP7702Chain(messenger, firstTransaction.chainId); + const useBatch = transactions.length > 1; if (useBatch) { - try { - return { - ...(await estimateQuoteGasLimitsBatch(transactions, messenger)), - usedBatch: true, - }; - } catch (error) { - log('Batch gas estimation failed, falling back to per-transaction path', { - chainId: firstTransaction.chainId, - error, - }); - } + return { + ...(await estimateQuoteGasLimitsBatch(transactions, messenger)), + usedBatch: true, + }; } return { - ...(await estimateQuoteGasLimitsIndividually({ + ...(await estimateQuoteGasLimitSingle({ fallbackGas, fallbackOnSimulationFailure, messenger, - transactions, + transaction: transactions[0], })), + is7702: false, usedBatch: false, }; } @@ -93,7 +75,9 @@ async function estimateQuoteGasLimitsBatch( transactions: QuoteGasTransaction[], messenger: TransactionPayControllerMessenger, ): Promise<{ + batchGasLimit?: QuoteGasLimit; gasLimits: QuoteGasLimit[]; + is7702: boolean; totalGasEstimate: number; totalGasLimit: number; }> { @@ -133,27 +117,30 @@ async function estimateQuoteGasLimitsBatch( return { estimate: bufferedGas, max: bufferedGas, - source: 'batch', - } as QuoteGasLimit; + }; }); const totalGasLimit = bufferedGasLimits.reduce( (acc, gasLimit) => acc + gasLimit.max, 0, ); + const is7702 = bufferedGasLimits.length === 1; + const batchGasLimit = is7702 ? bufferedGasLimits[0] : undefined; return { + ...(batchGasLimit ? { batchGasLimit } : {}), gasLimits: bufferedGasLimits, + is7702, totalGasEstimate: totalGasLimit, totalGasLimit, }; } -async function estimateQuoteGasLimitsIndividually({ +async function estimateQuoteGasLimitSingle({ fallbackGas, fallbackOnSimulationFailure, messenger, - transactions, + transaction, }: { fallbackGas?: { estimate: number; @@ -161,73 +148,66 @@ async function estimateQuoteGasLimitsIndividually({ }; fallbackOnSimulationFailure: boolean; messenger: TransactionPayControllerMessenger; - transactions: QuoteGasTransaction[]; + transaction: QuoteGasTransaction; }): Promise<{ gasLimits: QuoteGasLimit[]; totalGasEstimate: number; totalGasLimit: number; }> { - const gasLimits = await Promise.all( - transactions.map(async (transaction) => { - const providedGasLimit = parseGasLimit(transaction.gas); + const providedGasLimit = parseGasLimit(transaction.gas); + + if (providedGasLimit !== undefined) { + log('Using provided gas limit', { + chainId: transaction.chainId, + gas: providedGasLimit, + index: 0, + to: transaction.to, + }); - if (providedGasLimit !== undefined) { - return { + return { + gasLimits: [ + { estimate: providedGasLimit, max: providedGasLimit, - source: 'provided', - } as QuoteGasLimit; - } - - const gasLimitResult = await estimateGasLimit({ - chainId: transaction.chainId, - data: transaction.data, - fallbackGas, - fallbackOnSimulationFailure, - from: transaction.from, - messenger, - to: transaction.to, - value: toHex(transaction.value ?? '0'), - }); + }, + ], + totalGasEstimate: providedGasLimit, + totalGasLimit: providedGasLimit, + }; + } - const gasEstimate: QuoteGasLimit = { - estimate: gasLimitResult.estimate, - max: gasLimitResult.max, - source: gasLimitResult.usedFallback ? 'fallback' : 'estimated', - }; + const gasLimitResult = await estimateGasLimit({ + chainId: transaction.chainId, + data: transaction.data, + fallbackGas, + fallbackOnSimulationFailure, + from: transaction.from, + messenger, + to: transaction.to, + value: toHex(transaction.value ?? '0'), + }); - if (gasLimitResult.error === undefined) { - return gasEstimate; - } + if (gasLimitResult.usedFallback) { + log('Gas estimate failed, using fallback', { + chainId: transaction.chainId, + error: gasLimitResult.error, + index: 0, + to: transaction.to, + }); + } - return { - ...gasEstimate, - error: gasLimitResult.error, - }; - }), - ); + const gasLimit = { + estimate: gasLimitResult.estimate, + max: gasLimitResult.max, + }; return { - gasLimits, - totalGasEstimate: gasLimits.reduce( - (acc, gasLimit) => acc + gasLimit.estimate, - 0, - ), - totalGasLimit: gasLimits.reduce((acc, gasLimit) => acc + gasLimit.max, 0), + gasLimits: [gasLimit], + totalGasEstimate: gasLimit.estimate, + totalGasLimit: gasLimit.max, }; } -function hasUniformBatchContext(transactions: QuoteGasTransaction[]): boolean { - const [firstTransaction] = transactions; - - return transactions.every( - (transaction) => - transaction.chainId.toLowerCase() === - firstTransaction.chainId.toLowerCase() && - transaction.from.toLowerCase() === firstTransaction.from.toLowerCase(), - ); -} - function toBatchTransactionParams( transaction: QuoteGasTransaction, ): BatchTransactionParams { @@ -246,15 +226,7 @@ function parseGasLimit(gas?: number | string): number | undefined { return undefined; } - let parsedGas: BigNumber; - - if (typeof gas === 'number') { - parsedGas = new BigNumber(gas); - } else if (gas.startsWith('0x')) { - parsedGas = new BigNumber(gas.slice(2), 16); - } else { - parsedGas = new BigNumber(gas); - } + const parsedGas = new BigNumber(gas); if ( !parsedGas.isFinite() || From 845e1c3cd47ebda919ef90b5b347b560935b48d8 Mon Sep 17 00:00:00 2001 From: Pedro Figueiredo Date: Wed, 18 Mar 2026 10:13:28 +0000 Subject: [PATCH 3/3] Rely on Relay quote params instead of explicit field checks --- .../src/strategy/across/across-quotes.test.ts | 71 ------------------- .../src/strategy/across/across-quotes.ts | 20 ++---- .../src/strategy/relay/relay-quotes.test.ts | 14 +--- .../src/strategy/relay/relay-quotes.ts | 38 ++++------ .../src/strategy/relay/relay-submit.test.ts | 11 --- .../src/strategy/relay/relay-submit.ts | 15 ++-- .../src/strategy/relay/types.ts | 17 +++-- 7 files changed, 36 insertions(+), 150 deletions(-) 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 d391edd9881..11da6c8cf36 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 @@ -1007,77 +1007,6 @@ describe('Across Quotes', () => { ]); }); - it('throws when the shared gas estimator omits the swap gas result', async () => { - const estimateQuoteGasLimitsSpy = jest.spyOn( - quoteGasUtils, - 'estimateQuoteGasLimits', - ); - - estimateQuoteGasLimitsSpy.mockResolvedValueOnce({ - gasLimits: [], - is7702: false, - totalGasEstimate: 0, - totalGasLimit: 0, - usedBatch: false, - }); - - successfulFetchMock.mockResolvedValue({ - json: async () => QUOTE_MOCK, - } as Response); - - await expect( - getAcrossQuotes({ - messenger, - requests: [QUOTE_REQUEST_MOCK], - transaction: TRANSACTION_META_MOCK, - }), - ).rejects.toThrow( - 'Failed to fetch Across quotes: Error: Across swap gas estimate missing', - ); - - estimateQuoteGasLimitsSpy.mockRestore(); - }); - - it('throws when the shared gas estimator omits an approval gas result', async () => { - const estimateQuoteGasLimitsSpy = jest.spyOn( - quoteGasUtils, - 'estimateQuoteGasLimits', - ); - - estimateQuoteGasLimitsSpy.mockResolvedValueOnce({ - gasLimits: [], - is7702: false, - totalGasEstimate: 0, - totalGasLimit: 0, - usedBatch: false, - }); - - successfulFetchMock.mockResolvedValue({ - json: async () => ({ - ...QUOTE_MOCK, - approvalTxns: [ - { - chainId: 1, - data: '0xaaaa' as Hex, - to: '0xapprove1' as Hex, - }, - ], - }), - } as Response); - - await expect( - getAcrossQuotes({ - messenger, - requests: [QUOTE_REQUEST_MOCK], - transaction: TRANSACTION_META_MOCK, - }), - ).rejects.toThrow( - 'Failed to fetch Across quotes: Error: Across approval gas estimate missing at index 0', - ); - - estimateQuoteGasLimitsSpy.mockRestore(); - }); - it('falls back to the swap chain id when an approval transaction chain id is missing during cost calculation', 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 3d1f40afe5b..8de9e76ef73 100644 --- a/packages/transaction-pay-controller/src/strategy/across/across-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/across/across-quotes.ts @@ -556,22 +556,12 @@ async function calculateSourceNetworkCost( }; } - const transactionGasLimits = orderedTransactions.map((transaction, index) => { - const gasEstimate = gasEstimates.gasLimits[index]; - - if (!gasEstimate) { - throw new Error( - transaction.kind === 'swap' - ? 'Across swap gas estimate missing' - : `Across approval gas estimate missing at index ${index}`, - ); - } - - return { - gasEstimate, + const transactionGasLimits = orderedTransactions.map( + (transaction, index) => ({ + gasEstimate: gasEstimates.gasLimits[index], transaction, - }; - }); + }), + ); const estimate = sumAmounts( transactionGasLimits.map(({ gasEstimate, transaction }) => 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 f9856c98ae5..fb016e2cbf0 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 @@ -2840,12 +2840,7 @@ describe('Relay Quotes Utils', () => { requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, }), - ).rejects.toThrow( - 'Failed to fetch Relay quotes: Error: Relay transaction params missing required gas estimation fields at index 1', - ); - - expect(estimateGasBatchMock).not.toHaveBeenCalled(); - expect(estimateGasMock).not.toHaveBeenCalled(); + ).rejects.toThrow('Failed to fetch Relay quotes'); }); it('throws when relay transaction estimation fields are missing', async () => { @@ -2867,12 +2862,7 @@ describe('Relay Quotes Utils', () => { requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, }), - ).rejects.toThrow( - 'Failed to fetch Relay quotes: Error: Relay transaction params missing required gas estimation fields at index 0', - ); - - expect(estimateGasBatchMock).not.toHaveBeenCalled(); - expect(estimateGasMock).not.toHaveBeenCalled(); + ).rejects.toThrow('Failed to fetch Relay quotes'); }); describe('gas buffer support', () => { 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 5a939181ff2..ee794aad7a2 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -13,7 +13,11 @@ import { } from './gas-station'; import { fetchRelayQuote } from './relay-api'; import { getRelayMaxGasStationQuote } from './relay-max-gas-station'; -import type { RelayQuote, RelayQuoteRequest } from './types'; +import type { + RelayQuote, + RelayQuoteMetamask, + RelayQuoteRequest, +} from './types'; import { TransactionPayStrategy } from '../..'; import type { TransactionMeta } from '../../../../transaction-controller/src'; import { @@ -471,9 +475,9 @@ async function normalizeQuote( const targetAmount = getFiatValueFromUsd(targetAmountUsd, usdToFiatRate); - const metamask = { + const metamask: RelayQuoteMetamask = { ...quote.metamask, - gasLimits, + gasLimits: is7702 ? [gasLimits[0]] : gasLimits, is7702, }; @@ -782,8 +786,8 @@ async function calculateSourceNetworkGasLimit( gasLimits: number[]; is7702: boolean; }> { - const transactions = params.map((singleParams, index) => - toRelayQuoteGasTransaction(singleParams, index, fromOverride), + const transactions = params.map((singleParams) => + toRelayQuoteGasTransaction(singleParams, fromOverride), ); const relayGasResult = await estimateQuoteGasLimits({ @@ -803,30 +807,14 @@ async function calculateSourceNetworkGasLimit( function toRelayQuoteGasTransaction( singleParams: RelayQuote['steps'][0]['items'][0]['data'], - index: number, fromOverride?: Hex, ): QuoteGasTransaction { - const requiredParams = singleParams as Partial< - RelayQuote['steps'][0]['items'][0]['data'] - >; - - if ( - requiredParams.chainId === undefined || - requiredParams.data === undefined || - requiredParams.from === undefined || - requiredParams.to === undefined - ) { - throw new Error( - `Relay transaction params missing required gas estimation fields at index ${index}`, - ); - } - return { - chainId: toHex(requiredParams.chainId), - data: requiredParams.data, - from: fromOverride ?? requiredParams.from, + chainId: toHex(singleParams.chainId), + data: singleParams.data, + from: fromOverride ?? singleParams.from, gas: fromOverride ? undefined : singleParams.gas, - to: requiredParams.to, + to: singleParams.to, value: singleParams.value ?? '0', }; } 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 00e37dcde02..1f2619b83c4 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 @@ -958,17 +958,6 @@ describe('Relay Submit Utils', () => { }), ); }); - - it('throws when a 7702 post-quote batch is missing its combined gas limit', async () => { - request.quotes[0].original.metamask.gasLimits = []; - request.quotes[0].original.metamask.is7702 = true; - - await expect(submitRelayQuotes(request)).rejects.toThrow( - 'Missing quote gas limit for Relay 7702 batch', - ); - - expect(addTransactionBatchMock).not.toHaveBeenCalled(); - }); }); it('adds transaction batch with single gasLimit7702', 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 5ccf7e53544..f4b6c396ee9 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts @@ -520,7 +520,8 @@ async function submitViaTransactionController( })) : undefined; - const { gasLimits, is7702 } = quote.original.metamask; + const { metamask } = quote.original; + const { gasLimits } = metamask; if (allParams.length === 1) { const transactionParams = { @@ -541,15 +542,9 @@ async function submitViaTransactionController( }, ); } else { - const batchGasLimit = - is7702 && allParams.length > 1 ? gasLimits[0] : undefined; - - if (is7702 && allParams.length > 1 && batchGasLimit === undefined) { - throw new Error('Missing quote gas limit for Relay 7702 batch'); - } - - const gasLimit7702 = - batchGasLimit === undefined ? undefined : toHex(batchGasLimit); + const gasLimit7702 = 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/strategy/relay/types.ts b/packages/transaction-pay-controller/src/strategy/relay/types.ts index 9ae287653b8..326f6cb9cc9 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/types.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/types.ts @@ -72,12 +72,7 @@ export type RelayQuote = { minimumAmount: string; }; }; - metamask: { - gasLimits: number[]; - isExecute?: boolean; - is7702?: boolean; - isMaxGasStation?: boolean; - }; + metamask: RelayQuoteMetamask; request: RelayQuoteRequest; steps: { id: string; @@ -103,6 +98,16 @@ export type RelayQuote = { }[]; }; +type RelayQuoteMetamaskBase = { + isExecute?: boolean; + isMaxGasStation?: boolean; +}; + +export type RelayQuoteMetamask = RelayQuoteMetamaskBase & { + gasLimits: number[]; + is7702: boolean; +}; + export type RelayExecuteRequest = { executionKind: 'rawCalls'; data: {