From 84b56cb1e4a5b61ad50caed49090f8e6f811560e Mon Sep 17 00:00:00 2001 From: Pedro Figueiredo Date: Mon, 11 May 2026 12:19:59 +0100 Subject: [PATCH] Fix Across Predict withdraw gas payment edges --- packages/transaction-controller/CHANGELOG.md | 4 + .../src/utils/gas-fee-tokens.test.ts | 32 +- .../src/utils/gas-fee-tokens.ts | 41 +- .../transaction-pay-controller/CHANGELOG.md | 4 + .../src/strategy/across/across-quotes.test.ts | 423 ++++++++++++++++++ .../src/strategy/across/across-quotes.ts | 208 ++++++++- .../src/utils/gas-station.ts | 4 +- 7 files changed, 685 insertions(+), 31 deletions(-) diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index 2ae8ddb158..7daa012796 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `predictAcrossWithdraw` to the `TransactionType` enum ([#8759](https://github.com/MetaMask/core/pull/8759)) +### Fixed + +- Respect `excludeNativeTokenForFee` when publishing transactions with a selected gas fee token ([#8762](https://github.com/MetaMask/core/pull/8762)) + ## [65.3.0] ### Changed diff --git a/packages/transaction-controller/src/utils/gas-fee-tokens.test.ts b/packages/transaction-controller/src/utils/gas-fee-tokens.test.ts index 077b7e358a..8a6418375d 100644 --- a/packages/transaction-controller/src/utils/gas-fee-tokens.test.ts +++ b/packages/transaction-controller/src/utils/gas-fee-tokens.test.ts @@ -480,6 +480,36 @@ describe('Gas Fee Tokens Utils', () => { expect(request.transaction.txParams.nonce).toBeUndefined(); }); + it('sets external sign when native token is excluded for fees', async () => { + request.transaction.excludeNativeTokenForFee = true; + request.transaction.isGasFeeTokenIgnoredIfBalance = false; + request.transaction.selectedGasFeeToken = TOKEN_ADDRESS_1_MOCK; + request.transaction.gasFeeTokens = []; + request.transaction.isExternalSign = false; + request.transaction.txParams.nonce = '0x1'; + + jest.mocked(request.fetchGasFeeTokens).mockResolvedValueOnce([ + { + tokenAddress: TOKEN_ADDRESS_1_MOCK, + } as GasFeeToken, + ]); + + await checkGasFeeTokenBeforePublish(request); + + jest + .mocked(request.updateTransaction) + .mock.calls[0][1](request.transaction); + + expect(isNativeBalanceSufficientForGasMock).not.toHaveBeenCalled(); + expect(request.fetchGasFeeTokens).toHaveBeenCalledWith( + expect.objectContaining({ + isExternalSign: true, + }), + ); + expect(request.transaction.isExternalSign).toBe(true); + expect(request.transaction.txParams.nonce).toBeUndefined(); + }); + it('removes selected gas fee token if native balance sufficient', async () => { request.transaction.isGasFeeTokenIgnoredIfBalance = true; request.transaction.selectedGasFeeToken = TOKEN_ADDRESS_1_MOCK; @@ -503,7 +533,7 @@ describe('Gas Fee Tokens Utils', () => { expect(request.updateTransaction).not.toHaveBeenCalled(); }); - it('does nothing if not ignoring gas fee token when native balance sufficient', async () => { + it('does nothing if not ignoring gas fee token and native token is allowed for fees', async () => { request.transaction.selectedGasFeeToken = TOKEN_ADDRESS_1_MOCK; request.transaction.isGasFeeTokenIgnoredIfBalance = false; diff --git a/packages/transaction-controller/src/utils/gas-fee-tokens.ts b/packages/transaction-controller/src/utils/gas-fee-tokens.ts index 399d460a23..ce4205aa18 100644 --- a/packages/transaction-controller/src/utils/gas-fee-tokens.ts +++ b/packages/transaction-controller/src/utils/gas-fee-tokens.ts @@ -146,31 +146,40 @@ export async function checkGasFeeTokenBeforePublish({ fn: (tx: TransactionMeta) => void, ) => void; }): Promise { - const { isGasFeeTokenIgnoredIfBalance, selectedGasFeeToken } = transaction; + const { + excludeNativeTokenForFee, + isGasFeeTokenIgnoredIfBalance, + selectedGasFeeToken, + } = transaction; - if (!selectedGasFeeToken || !isGasFeeTokenIgnoredIfBalance) { + if ( + !selectedGasFeeToken || + (!isGasFeeTokenIgnoredIfBalance && !excludeNativeTokenForFee) + ) { return; } log('Checking gas fee token before publish', { selectedGasFeeToken }); - const hasNativeBalance = await isNativeBalanceSufficientForGas( - transaction, - messenger, - networkClientId, - ); - - if (hasNativeBalance) { - log( - 'Ignoring gas fee token before publish due to sufficient native balance', + if (!excludeNativeTokenForFee) { + const hasNativeBalance = await isNativeBalanceSufficientForGas( + transaction, + messenger, + networkClientId, ); - updateTransaction(transaction.id, (tx) => { - tx.isExternalSign = false; - tx.selectedGasFeeToken = undefined; - }); + if (hasNativeBalance) { + log( + 'Ignoring gas fee token before publish due to sufficient native balance', + ); - return; + updateTransaction(transaction.id, (tx) => { + tx.isExternalSign = false; + tx.selectedGasFeeToken = undefined; + }); + + return; + } } const gasFeeTokens = await fetchGasFeeTokens({ diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index aabc466657..cb450e3b68 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -13,6 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add Across quote support for post-quote Predict withdraw flows ([#8760](https://github.com/MetaMask/core/pull/8760)) - Add Across strategy plumbing to identify post-quote Predict withdraw requests ([#8759](https://github.com/MetaMask/core/pull/8759)) +### Fixed + +- Handle gas-station and prefunded gas-estimate edge cases for Across Predict withdraw quotes ([#8762](https://github.com/MetaMask/core/pull/8762)) + ## [22.2.0] ### Changed 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 50e56e589c..a21ae52c49 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 @@ -452,6 +452,103 @@ describe('Across Quotes', () => { expect(result[0].original.metamask.is7702).toBe(true); }); + it('caps excessive prefunded post-quote batch gas at Across fallback gas', async () => { + const refundTo = '0x5afe000000000000000000000000000000000001' as Hex; + + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: { + confirmations_pay: { + payStrategies: { + across: { + enabled: true, + apiBase: 'https://test.across.to/api', + fallbackGas: { + estimate: 900001, + max: 1500001, + }, + }, + }, + }, + }, + }); + + estimateGasBatchMock.mockResolvedValue({ + gasLimits: [42000000], + }); + getTokenBalanceMock.mockReturnValue('0'); + isEIP7702ChainMock.mockReturnValue(true); + getGasFeeTokensMock.mockResolvedValue([GAS_FEE_TOKEN_MOCK]); + + successfulFetchMock + .mockResolvedValueOnce({ + json: async () => ({ + ...QUOTE_MOCK, + approvalTxns: [ + { + chainId: 1, + data: '0xaaaa' as Hex, + to: '0xapprove1' as Hex, + }, + ], + inputAmount: '1000000000000000000', + }), + } as Response) + .mockResolvedValueOnce({ + json: async () => ({ + ...QUOTE_MOCK, + approvalTxns: [ + { + chainId: 1, + data: '0xaaaa' as Hex, + to: '0xapprove1' as Hex, + }, + ], + inputAmount: '999999999999999900', + }), + } as Response); + + const result = await getAcrossQuotes({ + accountSupports7702: true, + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + isPostQuote: true, + refundTo, + sourceBalanceRaw: '0', + targetAmountMinimum: '0', + }, + ], + transaction: { + ...PREDICT_WITHDRAW_TRANSACTION_MOCK, + txParams: { + ...PREDICT_WITHDRAW_TRANSACTION_MOCK.txParams, + gas: '0x5208', + to: '0x000000000000000000000000000000000000dEaD' as Hex, + }, + } as TransactionMeta, + }); + + expect(result[0].original.metamask.gasLimits).toStrictEqual([ + { + estimate: 921001, + max: 1521001, + }, + ]); + expect(calculateGasCostMock).toHaveBeenCalledWith( + expect.objectContaining({ + gas: 900001, + }), + ); + expect(calculateGasCostMock).toHaveBeenCalledWith( + expect.objectContaining({ + gas: 1500001, + isMax: true, + }), + ); + }); + it('preserves Across per-leg gas pricing when adding original post-quote gas fees', async () => { estimateGasBatchMock.mockResolvedValue({ gasLimits: [21000, 21000], @@ -691,6 +788,57 @@ describe('Across Quotes', () => { expect(successfulFetchMock).toHaveBeenCalledTimes(1); }); + it('prefers source gas fee token pricing for post-quote predict withdraws even when native balance is available', async () => { + const refundTo = '0x5afe000000000000000000000000000000000001' as Hex; + + isEIP7702ChainMock.mockReturnValue(true); + getGasFeeTokensMock.mockResolvedValue([GAS_FEE_TOKEN_MOCK]); + + successfulFetchMock + .mockResolvedValueOnce({ + json: async () => ({ + ...QUOTE_MOCK, + inputAmount: '1000', + }), + } as Response) + .mockResolvedValueOnce({ + json: async () => ({ + ...QUOTE_MOCK, + inputAmount: '900', + }), + } as Response); + + const result = await getAcrossQuotes({ + accountSupports7702: true, + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + isPostQuote: true, + refundTo, + sourceTokenAmount: '1000', + targetAmountMinimum: '0', + }, + ], + transaction: { + ...PREDICT_WITHDRAW_TRANSACTION_MOCK, + txParams: { + ...PREDICT_WITHDRAW_TRANSACTION_MOCK.txParams, + gas: '0x5208', + to: '0x000000000000000000000000000000000000dEaD' as Hex, + }, + } as TransactionMeta, + }); + + expect(getGasFeeTokensMock).toHaveBeenCalledWith( + expect.objectContaining({ + from: FROM_MOCK, + }), + ); + expect(result[0].fees.isSourceGasFeeToken).toBe(true); + expect(result[0].fees.sourceNetwork.max.raw).toBe('100'); + }); + it('rejects post-quote predict withdraws when phase 2 loses gas fee token eligibility', async () => { const refundTo = '0x5afe000000000000000000000000000000000001' as Hex; const sourceFiatRate = { @@ -810,6 +958,214 @@ describe('Across Quotes', () => { expect(successfulFetchMock).toHaveBeenCalledTimes(2); }); + it('keeps native gas for post-quote predict withdraws when the account cannot use 7702', async () => { + const refundTo = '0x5afe000000000000000000000000000000000001' as Hex; + + isEIP7702ChainMock.mockReturnValue(true); + getGasFeeTokensMock.mockResolvedValue([GAS_FEE_TOKEN_MOCK]); + + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as Response); + + const result = await getAcrossQuotes({ + accountSupports7702: false, + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + isPostQuote: true, + refundTo, + sourceTokenAmount: '100', + targetAmountMinimum: '0', + }, + ], + transaction: { + ...PREDICT_WITHDRAW_TRANSACTION_MOCK, + txParams: { + ...PREDICT_WITHDRAW_TRANSACTION_MOCK.txParams, + gas: '0x5208', + to: '0x000000000000000000000000000000000000dEaD' as Hex, + }, + } as TransactionMeta, + }); + + expect(getGasFeeTokensMock).not.toHaveBeenCalled(); + expect(result[0].fees.isSourceGasFeeToken).toBeUndefined(); + expect(result[0].fees.sourceNetwork.max.raw).toBe('3450000000000000000'); + }); + + it('uses fiat-derived source gas fee token pricing for post-quote predict withdraws when gas station cannot price the source token', async () => { + const refundTo = '0x5afe000000000000000000000000000000000001' as Hex; + + getTokenBalanceMock.mockReturnValue('0'); + isEIP7702ChainMock.mockReturnValue(true); + getGasFeeTokensMock.mockResolvedValue([ + { + ...GAS_FEE_TOKEN_MOCK, + tokenAddress: '0xdifferent' as Hex, + }, + ]); + + successfulFetchMock + .mockResolvedValueOnce({ + json: async () => ({ + ...QUOTE_MOCK, + inputAmount: '10000000000000000000', + }), + } as Response) + .mockResolvedValueOnce({ + json: async () => ({ + ...QUOTE_MOCK, + inputAmount: '6550000000000000000', + }), + } as Response); + + const result = await getAcrossQuotes({ + accountSupports7702: true, + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + isPostQuote: true, + refundTo, + sourceTokenAmount: '10000000000000000000', + targetAmountMinimum: '0', + }, + ], + transaction: { + ...PREDICT_WITHDRAW_TRANSACTION_MOCK, + txParams: { + ...PREDICT_WITHDRAW_TRANSACTION_MOCK.txParams, + gas: '0x5208', + to: '0x000000000000000000000000000000000000dEaD' as Hex, + }, + } as TransactionMeta, + }); + + expect(getGasFeeTokensMock).toHaveBeenCalledWith( + expect.objectContaining({ + from: FROM_MOCK, + }), + ); + expect(result[0].fees.isSourceGasFeeToken).toBe(true); + expect(result[0].fees.sourceNetwork.max).toStrictEqual({ + fiat: '13.8', + human: '3.45', + raw: '3450000000000000000', + usd: '6.9', + }); + }); + + it('keeps native gas for post-quote predict withdraws when fallback source fiat rate is unavailable', async () => { + const refundTo = '0x5afe000000000000000000000000000000000001' as Hex; + const sourceFiatRate = { + fiatRate: '4.0', + usdRate: '2.0', + }; + + getTokenBalanceMock.mockReturnValue('0'); + getTokenFiatRateMock + .mockReturnValueOnce(sourceFiatRate) + .mockReturnValueOnce(undefined) + .mockReturnValueOnce(undefined); + isEIP7702ChainMock.mockReturnValue(true); + getGasFeeTokensMock.mockResolvedValue([ + { + ...GAS_FEE_TOKEN_MOCK, + tokenAddress: '0xdifferent' as Hex, + }, + ]); + + successfulFetchMock.mockResolvedValue({ + json: async () => ({ + ...QUOTE_MOCK, + inputAmount: '10000000000000000000', + }), + } as Response); + + const result = await getAcrossQuotes({ + accountSupports7702: true, + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + isPostQuote: true, + refundTo, + sourceTokenAmount: '10000000000000000000', + targetAmountMinimum: '0', + }, + ], + transaction: { + ...PREDICT_WITHDRAW_TRANSACTION_MOCK, + txParams: { + ...PREDICT_WITHDRAW_TRANSACTION_MOCK.txParams, + gas: '0x5208', + to: '0x000000000000000000000000000000000000dEaD' as Hex, + }, + } as TransactionMeta, + }); + + expect(result[0].fees.isSourceGasFeeToken).toBeUndefined(); + expect(result[0].fees.sourceNetwork.max.raw).toBe('3450000000000000000'); + }); + + it('keeps native gas for post-quote predict withdraws when fallback gas token amount is invalid', async () => { + const refundTo = '0x5afe000000000000000000000000000000000001' as Hex; + const sourceFiatRate = { + fiatRate: '4.0', + usdRate: '2.0', + }; + + getTokenBalanceMock.mockReturnValue('0'); + getTokenFiatRateMock + .mockReturnValueOnce(sourceFiatRate) + .mockReturnValueOnce(undefined) + .mockReturnValueOnce({ + fiatRate: '4.0', + usdRate: '0', + }); + isEIP7702ChainMock.mockReturnValue(true); + getGasFeeTokensMock.mockResolvedValue([ + { + ...GAS_FEE_TOKEN_MOCK, + tokenAddress: '0xdifferent' as Hex, + }, + ]); + + successfulFetchMock.mockResolvedValue({ + json: async () => ({ + ...QUOTE_MOCK, + inputAmount: '10000000000000000000', + }), + } as Response); + + const result = await getAcrossQuotes({ + accountSupports7702: true, + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + isPostQuote: true, + refundTo, + sourceTokenAmount: '10000000000000000000', + targetAmountMinimum: '0', + }, + ], + transaction: { + ...PREDICT_WITHDRAW_TRANSACTION_MOCK, + txParams: { + ...PREDICT_WITHDRAW_TRANSACTION_MOCK.txParams, + gas: '0x5208', + to: '0x000000000000000000000000000000000000dEaD' as Hex, + }, + } as TransactionMeta, + }); + + expect(result[0].fees.isSourceGasFeeToken).toBeUndefined(); + expect(result[0].fees.sourceNetwork.max.raw).toBe('3450000000000000000'); + }); + it('estimates post-quote predict withdraw Across transactions from the EOA', async () => { const refundTo = '0x5afe000000000000000000000000000000000001' as Hex; @@ -840,6 +1196,73 @@ describe('Across Quotes', () => { ); }); + it('uses relaxed per-transaction gas estimates for prefunded post-quote predict withdraws', async () => { + const refundTo = '0x5afe000000000000000000000000000000000001' as Hex; + + estimateGasBatchMock.mockRejectedValueOnce( + new Error('Batch estimation failed'), + ); + estimateGasMock + .mockResolvedValueOnce({ + gas: '0x1000', + simulationFails: undefined, + }) + .mockResolvedValueOnce({ + gas: '0x2000', + simulationFails: true, + }); + + successfulFetchMock.mockResolvedValue({ + json: async () => ({ + ...QUOTE_MOCK, + approvalTxns: [ + { + chainId: 1, + data: '0x095ea7b3' as Hex, + to: '0xapprove1' as Hex, + }, + ], + }), + } as Response); + + const result = await getAcrossQuotes({ + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + isPostQuote: true, + refundTo, + sourceBalanceRaw: '100', + sourceTokenAmount: '1000', + targetAmountMinimum: '0', + }, + ], + transaction: PREDICT_WITHDRAW_TRANSACTION_MOCK, + }); + + expect(estimateGasBatchMock).toHaveBeenCalledTimes(1); + expect(estimateGasBatchMock).toHaveBeenCalledWith({ + chainId: QUOTE_REQUEST_MOCK.sourceChainId, + from: FROM_MOCK, + transactions: expect.any(Array), + }); + expect(estimateGasMock).toHaveBeenCalledTimes(2); + expect(result[0].original.metamask.is7702).toBe(false); + expect(result[0].original.metamask.gasLimits).toStrictEqual([ + { + estimate: 4096, + max: 4096, + }, + expect.objectContaining({ + estimate: expect.any(Number), + max: expect.any(Number), + }), + ]); + expect(result[0].original.metamask.gasLimits[1].estimate).toBeGreaterThan( + 8192, + ); + }); + it('falls back to phase 1 max amount quote when adjusted quote is not affordable', async () => { getTokenBalanceMock.mockReturnValue('0'); isEIP7702ChainMock.mockReturnValue(true); 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 8db721b870..bd5eec27cb 100644 --- a/packages/transaction-pay-controller/src/strategy/across/across-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/across/across-quotes.ts @@ -22,6 +22,7 @@ import { getGasStationEligibility, } from '../../utils/gas-station'; import { estimateQuoteGasLimits } from '../../utils/quote-gas'; +import type { QuoteGasTransaction } from '../../utils/quote-gas'; import { getNativeToken, getTokenBalance, @@ -365,6 +366,7 @@ async function normalizeQuote( messenger, request, fullRequest.transaction, + fullRequest.accountSupports7702, ); const targetNetwork = getFiatValueFromUsd(new BigNumber(0), usdToFiatRate); @@ -550,6 +552,7 @@ async function calculateSourceNetworkCost( messenger: TransactionPayControllerMessenger, request: QuoteRequest, transaction: TransactionMeta, + accountSupports7702: boolean, ): Promise<{ sourceNetwork: TransactionPayQuote['fees']['sourceNetwork']; gasLimits: AcrossGasLimits; @@ -563,17 +566,27 @@ async function calculateSourceNetworkCost( const orderedTransactions = getAcrossOrderedTransactions({ quote }); const { swapTx } = quote; const swapChainId = toHex(swapTx.chainId); - const gasEstimates = await estimateQuoteGasLimits({ - fallbackGas: acrossFallbackGas, - messenger, - transactions: orderedTransactions.map((orderedTransaction) => ({ + const isPredictWithdraw = + request.isPostQuote === true && isPredictWithdrawTransaction(transaction); + const relaxPrefundedSourceEstimate = + isPredictWithdraw && + new BigNumber(request.sourceTokenAmount).gt(request.sourceBalanceRaw); + const gasEstimateTransactions = orderedTransactions.map( + (orderedTransaction) => ({ chainId: toHex(orderedTransaction.chainId), data: orderedTransaction.data, from, gas: orderedTransaction.gas, to: orderedTransaction.to, value: orderedTransaction.value ?? '0x0', - })), + }), + ); + + const gasEstimates = await estimateAcrossQuoteGasLimits({ + fallbackGas: acrossFallbackGas, + fallbackOnSimulationFailure: relaxPrefundedSourceEstimate, + messenger, + transactions: gasEstimateTransactions, }); const { batchGasLimit, is7702, requiresAuthorizationList, totalGasEstimate } = @@ -679,7 +692,11 @@ async function calculateSourceNetworkCost( finalResult.sourceNetwork.max.raw, ); - if (hasNativeBalance) { + if (isPredictWithdraw && !accountSupports7702) { + return finalResult; + } + + if (hasNativeBalance && !isPredictWithdraw) { return finalResult; } @@ -721,26 +738,193 @@ async function calculateSourceNetworkCost( ), }); - if (!gasFeeTokenCost) { + let gasFeeTokenNetwork: + | TransactionPayQuote['fees']['sourceNetwork'] + | undefined; + + if (gasFeeTokenCost) { + gasFeeTokenNetwork = { + estimate: gasFeeTokenCost, + max: gasFeeTokenCost, + }; + } else if (isPredictWithdraw) { + gasFeeTokenNetwork = calculateSourceGasFeeTokenNetworkFallback({ + messenger, + nativeSourceNetwork: finalResult.sourceNetwork, + quote, + request, + }); + } + + if (!gasFeeTokenNetwork) { return finalResult; } log('Using gas fee token for Across source network', { - gasFeeTokenCost, + gasFeeTokenCost: gasFeeTokenNetwork.max, }); return { isGasFeeToken: true, - sourceNetwork: { - estimate: gasFeeTokenCost, - max: gasFeeTokenCost, - }, + sourceNetwork: gasFeeTokenNetwork, is7702: finalResult.is7702, ...(requiresAuthorizationList ? { requiresAuthorizationList } : {}), gasLimits: finalResult.gasLimits, }; } +function calculateSourceGasFeeTokenNetworkFallback({ + messenger, + nativeSourceNetwork, + quote, + request, +}: { + messenger: TransactionPayControllerMessenger; + nativeSourceNetwork: TransactionPayQuote['fees']['sourceNetwork']; + quote: AcrossSwapApprovalResponse; + request: QuoteRequest; +}): TransactionPayQuote['fees']['sourceNetwork'] | undefined { + const sourceFiatRate = getTokenFiatRate( + messenger, + request.sourceTokenAddress, + request.sourceChainId, + ); + + if (!sourceFiatRate) { + return undefined; + } + + const estimate = calculateSourceGasFeeTokenAmountFallback({ + decimals: quote.inputToken.decimals, + fiatRate: sourceFiatRate, + nativeGasCost: nativeSourceNetwork.estimate, + }); + const max = calculateSourceGasFeeTokenAmountFallback({ + decimals: quote.inputToken.decimals, + fiatRate: sourceFiatRate, + nativeGasCost: nativeSourceNetwork.max, + }); + + if (!estimate || !max) { + return undefined; + } + + return { estimate, max }; +} + +function calculateSourceGasFeeTokenAmountFallback({ + decimals, + fiatRate, + nativeGasCost, +}: { + decimals: number; + fiatRate: FiatRates; + nativeGasCost: Amount; +}): Amount | undefined { + const usdRate = new BigNumber(fiatRate.usdRate); + const nativeGasUsd = new BigNumber(nativeGasCost.usd); + + if ( + !usdRate.isFinite() || + !usdRate.isGreaterThan(0) || + !nativeGasUsd.isFinite() || + !nativeGasUsd.isGreaterThan(0) + ) { + return undefined; + } + + const amountRaw = nativeGasUsd + .dividedBy(usdRate) + .shiftedBy(decimals) + .integerValue(BigNumber.ROUND_CEIL) + .toFixed(0); + + return getAmountFromTokenAmount({ + amountRaw, + decimals, + fiatRate, + }); +} + +async function estimateAcrossQuoteGasLimits({ + fallbackGas, + fallbackOnSimulationFailure, + messenger, + transactions, +}: { + fallbackGas?: { + estimate: number; + max: number; + }; + fallbackOnSimulationFailure: boolean; + messenger: TransactionPayControllerMessenger; + transactions: QuoteGasTransaction[]; +}): Promise>> { + try { + const gasEstimates = await estimateQuoteGasLimits({ + fallbackGas, + fallbackOnSimulationFailure, + messenger, + transactions, + }); + + if ( + fallbackOnSimulationFailure && + fallbackGas !== undefined && + gasEstimates.is7702 && + gasEstimates.batchGasLimit !== undefined && + gasEstimates.batchGasLimit.max > fallbackGas.max + ) { + // Prefunded Predict withdraws can produce inflated 7702 batch estimates + // because the source account does not yet hold the funds. Keep the gas + // reservation bounded by the configured Across fallback for this path. + return { + ...gasEstimates, + batchGasLimit: fallbackGas, + gasLimits: [fallbackGas], + totalGasEstimate: fallbackGas.estimate, + totalGasLimit: fallbackGas.max, + }; + } + + return gasEstimates; + } catch (error) { + if (!fallbackOnSimulationFailure || transactions.length <= 1) { + throw error; + } + + const perTransactionGasEstimates = await Promise.all( + transactions.map((transaction) => + estimateQuoteGasLimits({ + fallbackGas, + fallbackOnSimulationFailure: true, + messenger, + transactions: [transaction], + }), + ), + ); + const gasLimits = perTransactionGasEstimates.map( + (estimate) => estimate.gasLimits[0], + ); + const totalGasEstimate = gasLimits.reduce( + (total, gasLimit) => total + gasLimit.estimate, + 0, + ); + const totalGasLimit = gasLimits.reduce( + (total, gasLimit) => total + gasLimit.max, + 0, + ); + + return { + gasLimits, + is7702: false, + totalGasEstimate, + totalGasLimit, + usedBatch: false, + }; + } +} + function combinePostQuoteGas( gasResult: { sourceNetwork: TransactionPayQuote['fees']['sourceNetwork']; diff --git a/packages/transaction-pay-controller/src/utils/gas-station.ts b/packages/transaction-pay-controller/src/utils/gas-station.ts index 4a52b30310..6398a1365f 100644 --- a/packages/transaction-pay-controller/src/utils/gas-station.ts +++ b/packages/transaction-pay-controller/src/utils/gas-station.ts @@ -59,7 +59,7 @@ export async function getGasStationCostInSourceTokenRaw({ const { data, to, value } = firstStepData; const { from, sourceChainId, sourceTokenAddress } = request; - let gasFeeTokens: GasFeeToken[]; + let gasFeeTokens: GasFeeToken[] | undefined; try { gasFeeTokens = await messenger.call( @@ -80,7 +80,7 @@ export async function getGasStationCostInSourceTokenRaw({ return undefined; } - const gasFeeToken = gasFeeTokens.find( + const gasFeeToken = gasFeeTokens?.find( (singleGasFeeToken) => singleGasFeeToken.tokenAddress.toLowerCase() === sourceTokenAddress.toLowerCase(),