diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index ec19a6982d4..5ebed73bc7d 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Zero out source network fees in Relay strategy when quote indicates execute flow ([#8181](https://github.com/MetaMask/core/pull/8181)) +- Remove duplication in gas estimation for Relay and Across strategies ([#8145](https://github.com/MetaMask/core/pull/8145)) ## [16.5.0] 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..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 @@ -5,6 +5,7 @@ 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'; @@ -12,6 +13,7 @@ import { getMessengerMock } from '../../tests/messenger-mock'; import type { QuoteRequest } from '../../types'; import { getGasBuffer, 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 +24,7 @@ jest.mock('../../utils/gas', () => ({ jest.mock('../../utils/feature-flags', () => ({ ...jest.requireActual('../../utils/feature-flags'), getGasBuffer: jest.fn(), + isEIP7702Chain: jest.fn(), getSlippage: jest.fn(), })); @@ -115,6 +118,7 @@ describe('Across Quotes', () => { const { messenger, estimateGasMock, + estimateGasBatchMock, findNetworkClientIdByChainIdMock, getRemoteFeatureFlagControllerStateMock, } = getMessengerMock(); @@ -738,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 () => ({ @@ -793,20 +791,162 @@ 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 a combined batch gas limit when batch estimation returns a single gas limit', async () => { + 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).toStrictEqual([ + { + estimate: 51000, + max: 51000, + }, + ]); + expect(result[0].original.metamask.is7702).toBe(true); + 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('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'), + ); + + successfulFetchMock.mockResolvedValue({ + json: async () => ({ + ...QUOTE_MOCK, + approvalTxns: [ + { + chainId: 1, + data: '0xaaaa' as Hex, + to: '0xapprove1' as Hex, + value: '0x1' as Hex, + }, + ], + }), + } as Response); + + 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).not.toHaveBeenCalled(); }); it('uses swapTx.gas from Across response when provided', async () => { @@ -827,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', @@ -857,10 +999,161 @@ 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 () => { + 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, + 'estimateQuoteGasLimits', + ); + const orderedTransactionsSpy = jest.spyOn( + acrossTransactions, + 'getAcrossOrderedTransactions', + ); + + estimateQuoteGasLimitsSpy.mockResolvedValueOnce({ + gasLimits: [ + { + estimate: 30000, + max: 35000, + }, + { + estimate: 21000, + max: 22000, + }, + ], + is7702: false, + 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 () => { @@ -877,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 2ef8eea5630..3d1f40afe5b 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'; @@ -307,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, @@ -350,6 +352,7 @@ async function normalizeQuote( const metamask = { gasLimits, + is7702, }; return { @@ -495,139 +498,115 @@ async function calculateSourceNetworkCost( ): Promise<{ sourceNetwork: TransactionPayQuote['fees']['sourceNetwork']; gasLimits: AcrossGasLimits; + is7702: boolean; }> { 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 { batchGasLimit, is7702 } = gasEstimates; - 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', - }); - - if (gas.usedFallback) { - log('Gas estimate failed, using fallback', { - error: gas.error, - transactionType: 'approval', - }); - } - - return { chainId, gas }; - }), - ); + if (is7702) { + if (!batchGasLimit) { + throw new Error('Across combined batch gas estimate missing'); + } - 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', + const estimate = calculateGasCost({ + chainId: swapChainId, + gas: batchGasLimit.estimate, + maxFeePerGas: swapTx.maxFeePerGas, + maxPriorityFeePerGas: swapTx.maxPriorityFeePerGas, + messenger, }); - } else if (swapGas.usedFallback) { - log('Gas estimate failed, using fallback', { - error: swapGas.error, - transactionType: 'swap', + const max = calculateGasCost({ + chainId: swapChainId, + gas: batchGasLimit.max, + isMax: true, + maxFeePerGas: swapTx.maxFeePerGas, + maxPriorityFeePerGas: swapTx.maxPriorityFeePerGas, + messenger, }); + + return { + sourceNetwork: { + estimate, + max, + }, + is7702: true, + gasLimits: [ + { + estimate: batchGasLimit.estimate, + max: batchGasLimit.max, + }, + ], + }; } - const estimate = sumAmounts([ - ...approvalGasResults.map(({ chainId, gas }) => + 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, + transaction, + }; + }); + + const estimate = sumAmounts( + transactionGasLimits.map(({ gasEstimate, transaction }) => calculateGasCost({ - chainId, - gas: gas.estimate, + 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([ - ...approvalGasResults.map(({ chainId, gas }) => + const max = sumAmounts( + transactionGasLimits.map(({ gasEstimate, transaction }) => calculateGasCost({ - chainId, - gas: gas.max, + 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: approvalGasResults.map(({ gas }) => ({ - estimate: gas.estimate, - max: gas.max, - })), - swap: { - estimate: swapGas.estimate, - max: swapGas.max, - }, - }, + is7702: false, + gasLimits: transactionGasLimits.map(({ gasEstimate }) => ({ + estimate: gasEstimate.estimate, + max: gasEstimate.max, + })), }; } - -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..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 @@ -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'; @@ -44,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: [ @@ -209,6 +211,54 @@ 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: [ + { + estimate: 43000, + max: 64000, + }, + ], + is7702: true, + }, + }, + } 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, @@ -237,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, @@ -327,6 +401,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, @@ -746,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, @@ -781,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, @@ -811,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 9626c467883..b4b73039a52 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,57 +116,59 @@ async function submitTransactions( acrossDepositType: TransactionType, messenger: TransactionPayControllerMessenger, ): Promise { - const { approvalTxns, swapTx } = quote.original.quote; - const { gasLimits: quoteGasLimits } = quote.original.metamask; + const { swapTx } = quote.original.quote; + 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 transactions: PreparedAcrossTransaction[] = []; + const batchGasLimit = + is7702 && orderedTransactions.length > 1 + ? quoteGasLimits[0]?.max + : undefined; + + 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 (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}`, - ); + 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); } - 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 +214,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 +353,7 @@ function buildTransactionParams( params: { chainId: number; data: Hex; - gasLimit: number; + gasLimit?: number; to: Hex; value?: Hex; maxFeePerGas?: string; @@ -354,12 +361,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..b7e668de82f 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,20 +76,17 @@ export type AcrossSwapApprovalResponse = { swapTx: AcrossSwapTransaction; }; -export type AcrossGasLimits = { - approval: { - estimate: number; - max: number; - }[]; - swap: { - estimate: number; - max: number; - }; +export type AcrossGasLimit = { + estimate: number; + max: number; }; +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 3af5f22b493..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 @@ -46,7 +46,11 @@ 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'), isEIP7702Chain: jest.fn(), @@ -111,6 +115,7 @@ const QUOTE_MOCK = { }, metamask: { gasLimits: [21000], + is7702: false, }, steps: [ { @@ -845,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 () => { @@ -879,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 () => { @@ -940,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 () => { @@ -1000,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 () => { @@ -1030,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 () => { @@ -1382,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); @@ -1408,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 () => { @@ -1831,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); @@ -1839,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, }, }, ], @@ -1853,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, @@ -1901,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]); @@ -1928,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); @@ -1964,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 () => { @@ -2676,8 +2721,9 @@ describe('Relay Quotes Utils', () => { ); }); - it('uses fallback gas when estimateGasBatch fails', 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({ data: { chainId: 1, @@ -2691,18 +2737,52 @@ describe('Relay Quotes Utils', () => { json: async () => quoteMock, } as never); - estimateGasBatchMock.mockRejectedValue( - new Error('Batch estimation failed'), - ); + isEIP7702ChainMock.mockReturnValue(false); + estimateGasBatchMock.mockResolvedValue({ + totalGasLimit: 80000, + gasLimits: [30000, 50000], + }); - await getRelayQuotes({ + const result = await getRelayQuotes({ messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, }); - expect(calculateGasCostMock).toHaveBeenCalledWith( - expect.objectContaining({ gas: 900000 + 21000 }), + expect(estimateGasBatchMock).toHaveBeenCalledTimes(1); + expect(estimateGasMock).not.toHaveBeenCalled(); + expect(result[0].original.metamask.gasLimits).toStrictEqual([ + 30000, 50000, + ]); + }); + + it('throws when estimateGasBatch fails', async () => { + const quoteMock = cloneDeep(QUOTE_MOCK); + quoteMock.steps[0].items.push({ + data: { + chainId: 1, + from: FROM_MOCK, + to: '0x3' as Hex, + data: '0x456' as Hex, + }, + } as never); + + successfulFetchMock.mockResolvedValue({ + json: async () => quoteMock, + } as never); + + estimateGasBatchMock.mockRejectedValue( + new Error('Batch estimation failed'), + ); + + await expect( + getRelayQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }), + ).rejects.toThrow( + 'Failed to fetch Relay quotes: Error: Batch estimation failed', ); }); @@ -2719,6 +2799,7 @@ describe('Relay Quotes Utils', () => { expect(result[0].original.metamask).toStrictEqual({ gasLimits: [21000], + is7702: false, }); }); @@ -2743,6 +2824,57 @@ describe('Relay Quotes Utils', () => { ); }); + it('throws when later relay transactions omit required estimation fields', async () => { + const quoteMock = cloneDeep(QUOTE_MOCK); + quoteMock.steps[0].items.push({ + data: {}, + } as never); + + successfulFetchMock.mockResolvedValue({ + json: async () => quoteMock, + } as never); + + 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 1', + ); + + expect(estimateGasBatchMock).not.toHaveBeenCalled(); + expect(estimateGasMock).not.toHaveBeenCalled(); + }); + + 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: {}, + }, + ]; + + successfulFetchMock.mockResolvedValue({ + json: async () => quoteMock, + } as never); + + 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', + ); + + expect(estimateGasBatchMock).not.toHaveBeenCalled(); + expect(estimateGasMock).not.toHaveBeenCalled(); + }); + describe('gas buffer support', () => { it('applies buffer to single transaction gas estimate', async () => { const quoteMock = cloneDeep(QUOTE_MOCK); @@ -2770,16 +2902,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 +3015,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..5a939181ff2 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,15 @@ 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 type { QuoteGasTransaction } from '../../utils/quote-gas'; import { getNativeToken, getTokenBalance, @@ -427,6 +425,7 @@ async function normalizeQuote( const { gasLimits, + is7702, isGasFeeToken: isSourceGasFeeToken, ...sourceNetwork } = await calculateSourceNetworkCost( @@ -475,6 +474,7 @@ async function normalizeQuote( const metamask = { ...quote.metamask, gasLimits, + is7702, }; return { @@ -585,6 +585,7 @@ async function calculateSourceNetworkCost( TransactionPayQuote['fees']['sourceNetwork'] & { gasLimits: number[]; isGasFeeToken?: boolean; + is7702: boolean; } > { const { from, sourceChainId, sourceTokenAddress } = request; @@ -594,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 @@ -615,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, @@ -649,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; @@ -714,6 +722,7 @@ async function calculateSourceNetworkCost( estimate: gasFeeTokenCost, max: gasFeeTokenCost, gasLimits, + is7702, }; } @@ -749,6 +758,7 @@ async function calculateSourceNetworkCost( estimate: gasFeeTokenCost, max: gasFeeTokenCost, gasLimits, + is7702, }; } @@ -770,10 +780,55 @@ async function calculateSourceNetworkGasLimit( totalGasEstimate: number; totalGasLimit: number; gasLimits: number[]; + is7702: boolean; }> { - return params.length === 1 - ? calculateSourceNetworkGasLimitSingle(params[0], messenger, fromOverride) - : calculateSourceNetworkGasLimitBatch(params, messenger, fromOverride); + const transactions = params.map((singleParams, index) => + toRelayQuoteGasTransaction(singleParams, index, fromOverride), + ); + + const relayGasResult = await estimateQuoteGasLimits({ + fallbackGas: getFeatureFlags(messenger).relayFallbackGas, + fallbackOnSimulationFailure: true, + messenger, + 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', + }; } /** @@ -787,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. */ @@ -795,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; @@ -807,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 @@ -825,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, + }; } /** @@ -868,197 +933,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/strategy/relay/relay-submit.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts index 51b474c7d00..9895c87d5b1 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 @@ -61,6 +61,7 @@ const ORIGINAL_QUOTE_MOCK = { }, metamask: { gasLimits: [21000, 21000], + is7702: false, }, request: {}, steps: [ @@ -805,6 +806,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); @@ -831,6 +833,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 () => { @@ -839,6 +852,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 2cf1571863c..437c60f8a10 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts @@ -490,7 +490,7 @@ async function submitViaTransactionController( })) : undefined; - const { gasLimits } = quote.original.metamask; + const { gasLimits, is7702 } = quote.original.metamask; if (allParams.length === 1) { const transactionParams = { @@ -511,10 +511,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 c8a0ed5cade..8d8b0706765 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/quote-gas.test.ts b/packages/transaction-pay-controller/src/utils/quote-gas.test.ts new file mode 100644 index 00000000000..c11ab1f04ef --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/quote-gas.test.ts @@ -0,0 +1,329 @@ +import type { Hex } from '@metamask/utils'; + +import { getGasBuffer } 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(), +})); + +jest.mock('./gas', () => ({ + ...jest.requireActual('./gas'), + estimateGasLimit: jest.fn(), +})); + +describe('quote gas estimation', () => { + const getGasBufferMock = jest.mocked(getGasBuffer); + 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); + }); + + 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(); + }); + + 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({ + fallbackOnSimulationFailure: true, + messenger, + transactions: TRANSACTIONS_MOCK, + }); + + expect(estimateGasBatchMock).toHaveBeenCalledTimes(1); + expect(estimateGasLimitMock).not.toHaveBeenCalled(); + expect(result).toStrictEqual({ + gasLimits: [ + { + estimate: 21000, + max: 21000, + }, + { + estimate: 30000, + max: 30000, + }, + ], + is7702: false, + totalGasEstimate: 51000, + totalGasLimit: 51000, + usedBatch: 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({ + messenger, + transactions: [TRANSACTIONS_MOCK[0]], + }); + + expect(estimateGasBatchMock).not.toHaveBeenCalled(); + expect(result.is7702).toBe(false); + expect(result.usedBatch).toBe(false); + }); + + it('uses batch estimation when the source chain supports EIP-7702', async () => { + 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({ + batchGasLimit: { + estimate: 75000, + max: 75000, + }, + gasLimits: [ + { + estimate: 75000, + max: 75000, + }, + ], + is7702: true, + totalGasEstimate: 75000, + totalGasLimit: 75000, + usedBatch: true, + }); + }); + + it('uses per-transaction batch gas limits and preserves provided gas when it already matches', async () => { + 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, + }, + { + estimate: 30000, + max: 30000, + }, + ], + is7702: false, + totalGasEstimate: 61500, + totalGasLimit: 61500, + usedBatch: true, + }); + }); + + it('buffers per-transaction batch gas when a provided gas value is overridden', async () => { + 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, + }, + { + estimate: 52500, + max: 52500, + }, + ], + is7702: false, + totalGasEstimate: 84000, + totalGasLimit: 84000, + usedBatch: true, + }); + }); + + it('throws when batch estimation fails', async () => { + estimateGasBatchMock.mockRejectedValue( + new Error('Batch estimation failed'), + ); + + await expect( + estimateQuoteGasLimits({ + fallbackOnSimulationFailure: true, + messenger, + transactions: TRANSACTIONS_MOCK, + }), + ).rejects.toThrow('Batch estimation failed'); + + expect(estimateGasBatchMock).toHaveBeenCalledTimes(1); + expect(estimateGasLimitMock).not.toHaveBeenCalled(); + }); + + it('throws when batch returns an unexpected gas limit count', async () => { + estimateGasBatchMock.mockResolvedValue({ + totalGasLimit: 123000, + gasLimits: [21000, 30000, 72000], + }); + + await expect( + estimateQuoteGasLimits({ + messenger, + transactions: TRANSACTIONS_MOCK, + }), + ).rejects.toThrow('Unexpected batch gas limit count'); + + expect(estimateGasBatchMock).toHaveBeenCalledTimes(1); + expect(estimateGasLimitMock).not.toHaveBeenCalled(); + }); + + 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, + }, + ], + is7702: false, + 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 () => { + 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..af7bcf2ef0e --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/quote-gas.ts @@ -0,0 +1,241 @@ +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 } 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; +}; + +export async function estimateQuoteGasLimits({ + fallbackGas, + fallbackOnSimulationFailure = false, + messenger, + transactions, +}: { + fallbackGas?: { + estimate: number; + max: number; + }; + fallbackOnSimulationFailure?: boolean; + messenger: TransactionPayControllerMessenger; + transactions: QuoteGasTransaction[]; +}): Promise<{ + batchGasLimit?: QuoteGasLimit; + gasLimits: QuoteGasLimit[]; + is7702: boolean; + totalGasEstimate: number; + totalGasLimit: number; + usedBatch: boolean; +}> { + if (transactions.length === 0) { + throw new Error('Quote gas estimation requires at least one transaction'); + } + + const useBatch = transactions.length > 1; + + if (useBatch) { + return { + ...(await estimateQuoteGasLimitsBatch(transactions, messenger)), + usedBatch: true, + }; + } + + return { + ...(await estimateQuoteGasLimitSingle({ + fallbackGas, + fallbackOnSimulationFailure, + messenger, + transaction: transactions[0], + })), + is7702: false, + usedBatch: false, + }; +} + +async function estimateQuoteGasLimitsBatch( + transactions: QuoteGasTransaction[], + messenger: TransactionPayControllerMessenger, +): Promise<{ + batchGasLimit?: QuoteGasLimit; + gasLimits: QuoteGasLimit[]; + is7702: boolean; + 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, + }; + }); + + 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 estimateQuoteGasLimitSingle({ + fallbackGas, + fallbackOnSimulationFailure, + messenger, + transaction, +}: { + fallbackGas?: { + estimate: number; + max: number; + }; + fallbackOnSimulationFailure: boolean; + messenger: TransactionPayControllerMessenger; + transaction: QuoteGasTransaction; +}): Promise<{ + gasLimits: QuoteGasLimit[]; + totalGasEstimate: number; + totalGasLimit: number; +}> { + const providedGasLimit = parseGasLimit(transaction.gas); + + if (providedGasLimit !== undefined) { + log('Using provided gas limit', { + chainId: transaction.chainId, + gas: providedGasLimit, + index: 0, + to: transaction.to, + }); + + return { + gasLimits: [ + { + estimate: providedGasLimit, + max: providedGasLimit, + }, + ], + totalGasEstimate: providedGasLimit, + totalGasLimit: providedGasLimit, + }; + } + + 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.usedFallback) { + log('Gas estimate failed, using fallback', { + chainId: transaction.chainId, + error: gasLimitResult.error, + index: 0, + to: transaction.to, + }); + } + + const gasLimit = { + estimate: gasLimitResult.estimate, + max: gasLimitResult.max, + }; + + return { + gasLimits: [gasLimit], + totalGasEstimate: gasLimit.estimate, + totalGasLimit: gasLimit.max, + }; +} + +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; + } + + const parsedGas = new BigNumber(gas); + + if ( + !parsedGas.isFinite() || + parsedGas.isNaN() || + !parsedGas.isInteger() || + parsedGas.lte(0) + ) { + return undefined; + } + + return parsedGas.toNumber(); +}