diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index d99846839e..ad2778123f 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add Gas Station support for Across source transactions when native balance is insufficient ([#8588](https://github.com/MetaMask/core/pull/8588)) + ## [20.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 748c1c3c10..2a872d6611 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 @@ -1,7 +1,10 @@ import { Interface } from '@ethersproject/abi'; import { successfulFetch } from '@metamask/controller-utils'; import { TransactionType } from '@metamask/transaction-controller'; -import type { TransactionMeta } from '@metamask/transaction-controller'; +import type { + GasFeeToken, + TransactionMeta, +} from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; import { getDefaultRemoteFeatureFlagControllerState } from '../../../../remote-feature-flag-controller/src/remote-feature-flag-controller'; @@ -13,10 +16,14 @@ import { } from '../../constants'; import { getMessengerMock } from '../../tests/messenger-mock'; import type { QuoteRequest } from '../../types'; -import { getGasBuffer, getSlippage } from '../../utils/feature-flags'; -import { calculateGasCost } from '../../utils/gas'; +import { + getGasBuffer, + getSlippage, + isEIP7702Chain, +} from '../../utils/feature-flags'; +import { calculateGasCost, calculateGasFeeTokenCost } from '../../utils/gas'; import * as quoteGasUtils from '../../utils/quote-gas'; -import { getTokenFiatRate } from '../../utils/token'; +import { getTokenBalance, getTokenFiatRate } from '../../utils/token'; import { getAcrossQuotes } from './across-quotes'; import { ACROSS_HYPERCORE_USDC_PERPS_ADDRESS } from './perps'; import * as acrossTransactions from './transactions'; @@ -26,6 +33,7 @@ jest.mock('../../utils/token'); jest.mock('../../utils/gas', () => ({ ...jest.requireActual('../../utils/gas'), calculateGasCost: jest.fn(), + calculateGasFeeTokenCost: jest.fn(), })); jest.mock('../../utils/feature-flags', () => ({ ...jest.requireActual('../../utils/feature-flags'), @@ -161,16 +169,33 @@ describe('Across Quotes', () => { const getTokenFiatRateMock = jest.mocked(getTokenFiatRate); const getGasBufferMock = jest.mocked(getGasBuffer); const getSlippageMock = jest.mocked(getSlippage); + const isEIP7702ChainMock = jest.mocked(isEIP7702Chain); const calculateGasCostMock = jest.mocked(calculateGasCost); + const calculateGasFeeTokenCostMock = jest.mocked(calculateGasFeeTokenCost); + const getTokenBalanceMock = jest.mocked(getTokenBalance); const { messenger, estimateGasMock, estimateGasBatchMock, findNetworkClientIdByChainIdMock, + getGasFeeTokensMock, getRemoteFeatureFlagControllerStateMock, } = getMessengerMock(); + const GAS_FEE_TOKEN_MOCK: GasFeeToken = { + amount: '0x64', + balance: '0x1000', + decimals: 18, + gas: '0x5208', + maxFeePerGas: '0x1', + maxPriorityFeePerGas: '0x1', + rateWei: '0x1', + recipient: FROM_MOCK, + symbol: 'ETH', + tokenAddress: QUOTE_REQUEST_MOCK.sourceTokenAddress, + }; + beforeEach(() => { jest.resetAllMocks(); @@ -200,8 +225,17 @@ describe('Across Quotes', () => { usd: '3.45', }); + calculateGasFeeTokenCostMock.mockReturnValue({ + fiat: '0.0004', + human: '0.0001', + raw: '100', + usd: '0.0002', + }); + + getTokenBalanceMock.mockReturnValue('1725000000000000000'); getGasBufferMock.mockReturnValue(1.0); getSlippageMock.mockReturnValue(0.005); + isEIP7702ChainMock.mockReturnValue(false); findNetworkClientIdByChainIdMock.mockReturnValue('mainnet'); estimateGasMock.mockResolvedValue({ @@ -295,6 +329,187 @@ describe('Across Quotes', () => { expect(params.get('amount')).toBe(QUOTE_REQUEST_MOCK.sourceTokenAmount); }); + it('re-quotes max amount quotes after reserving source token for gas fee token', async () => { + const adjustedSourceAmount = '999999999999999900'; + + getTokenBalanceMock.mockReturnValue('0'); + isEIP7702ChainMock.mockReturnValue(true); + getGasFeeTokensMock.mockResolvedValue([ + { + amount: '0x64', + balance: '0x1000', + decimals: 18, + gas: '0x5208', + maxFeePerGas: '0x1', + maxPriorityFeePerGas: '0x1', + rateWei: '0x1', + recipient: FROM_MOCK, + symbol: 'ETH', + tokenAddress: QUOTE_REQUEST_MOCK.sourceTokenAddress, + }, + ]); + + successfulFetchMock + .mockResolvedValueOnce({ + json: async () => QUOTE_MOCK, + } as Response) + .mockResolvedValueOnce({ + json: async () => ({ + ...QUOTE_MOCK, + inputAmount: adjustedSourceAmount, + }), + } as Response); + + const result = await getAcrossQuotes({ + messenger, + requests: [{ ...QUOTE_REQUEST_MOCK, isMaxAmount: true }], + transaction: TRANSACTION_META_MOCK, + }); + + expect(successfulFetchMock).toHaveBeenCalledTimes(2); + + const [phase1Url] = successfulFetchMock.mock.calls[0]; + const [phase2Url] = successfulFetchMock.mock.calls[1]; + + expect(new URL(phase1Url as string).searchParams.get('amount')).toBe( + QUOTE_REQUEST_MOCK.sourceTokenAmount, + ); + expect(new URL(phase2Url as string).searchParams.get('amount')).toBe( + adjustedSourceAmount, + ); + expect(result[0].sourceAmount.raw).toBe(adjustedSourceAmount); + expect(result[0].fees.isSourceGasFeeToken).toBe(true); + }); + + it('falls back to phase 1 max amount quote when adjusted quote is not affordable', async () => { + getTokenBalanceMock.mockReturnValue('0'); + isEIP7702ChainMock.mockReturnValue(true); + calculateGasFeeTokenCostMock + .mockReturnValueOnce({ + fiat: '0.0004', + human: '0.0001', + raw: '100', + usd: '0.0002', + }) + .mockReturnValueOnce({ + fiat: '0.0008', + human: '0.0002', + raw: '200', + usd: '0.0004', + }); + getGasFeeTokensMock.mockResolvedValue([ + { + amount: '0x64', + balance: '0x1000', + decimals: 18, + gas: '0x5208', + maxFeePerGas: '0x1', + maxPriorityFeePerGas: '0x1', + rateWei: '0x1', + recipient: FROM_MOCK, + symbol: 'ETH', + tokenAddress: QUOTE_REQUEST_MOCK.sourceTokenAddress, + }, + ]); + + successfulFetchMock + .mockResolvedValueOnce({ + json: async () => QUOTE_MOCK, + } as Response) + .mockResolvedValueOnce({ + json: async () => ({ + ...QUOTE_MOCK, + inputAmount: '999999999999999900', + }), + } as Response); + + const result = await getAcrossQuotes({ + messenger, + requests: [{ ...QUOTE_REQUEST_MOCK, isMaxAmount: true }], + transaction: TRANSACTION_META_MOCK, + }); + + expect(successfulFetchMock).toHaveBeenCalledTimes(2); + expect(result[0].sourceAmount.raw).toBe(QUOTE_MOCK.inputAmount); + }); + + it('falls back to phase 1 max amount quote when gas subtraction consumes the source amount', async () => { + getTokenBalanceMock.mockReturnValue('0'); + isEIP7702ChainMock.mockReturnValue(true); + getGasFeeTokensMock.mockResolvedValue([GAS_FEE_TOKEN_MOCK]); + + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as Response); + + const result = await getAcrossQuotes({ + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + isMaxAmount: true, + sourceTokenAmount: '100', + }, + ], + transaction: TRANSACTION_META_MOCK, + }); + + expect(successfulFetchMock).toHaveBeenCalledTimes(1); + expect(result[0].sourceAmount.raw).toBe(QUOTE_MOCK.inputAmount); + expect(result[0].fees.isSourceGasFeeToken).toBe(true); + }); + + it('falls back to phase 1 max amount quote when phase 2 loses gas fee token eligibility', async () => { + getTokenBalanceMock.mockReturnValue('0'); + isEIP7702ChainMock.mockReturnValue(true); + getGasFeeTokensMock + .mockResolvedValueOnce([GAS_FEE_TOKEN_MOCK]) + .mockResolvedValueOnce([]); + + successfulFetchMock + .mockResolvedValueOnce({ + json: async () => QUOTE_MOCK, + } as Response) + .mockResolvedValueOnce({ + json: async () => ({ + ...QUOTE_MOCK, + inputAmount: '999999999999999900', + }), + } as Response); + + const result = await getAcrossQuotes({ + messenger, + requests: [{ ...QUOTE_REQUEST_MOCK, isMaxAmount: true }], + transaction: TRANSACTION_META_MOCK, + }); + + expect(successfulFetchMock).toHaveBeenCalledTimes(2); + expect(result[0].sourceAmount.raw).toBe(QUOTE_MOCK.inputAmount); + expect(result[0].fees.isSourceGasFeeToken).toBe(true); + }); + + it('falls back to phase 1 max amount quote when phase 2 fetching fails', async () => { + getTokenBalanceMock.mockReturnValue('0'); + isEIP7702ChainMock.mockReturnValue(true); + getGasFeeTokensMock.mockResolvedValue([GAS_FEE_TOKEN_MOCK]); + + successfulFetchMock + .mockResolvedValueOnce({ + json: async () => QUOTE_MOCK, + } as Response) + .mockRejectedValueOnce(new Error('Phase 2 quote failed')); + + const result = await getAcrossQuotes({ + messenger, + requests: [{ ...QUOTE_REQUEST_MOCK, isMaxAmount: true }], + transaction: TRANSACTION_META_MOCK, + }); + + expect(successfulFetchMock).toHaveBeenCalledTimes(2); + expect(result[0].sourceAmount.raw).toBe(QUOTE_MOCK.inputAmount); + expect(result[0].fees.isSourceGasFeeToken).toBe(true); + }); + it('forwards the abort signal to the underlying fetch', async () => { successfulFetchMock.mockResolvedValue({ json: async () => QUOTE_MOCK, @@ -1172,6 +1387,125 @@ describe('Across Quotes', () => { ); }); + it('uses source gas fee token pricing when native balance is insufficient', async () => { + getTokenBalanceMock.mockReturnValue('0'); + isEIP7702ChainMock.mockReturnValue(true); + getGasFeeTokensMock.mockResolvedValue([GAS_FEE_TOKEN_MOCK]); + + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as Response); + + const result = await getAcrossQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + expect(getGasFeeTokensMock).toHaveBeenCalledWith({ + chainId: QUOTE_REQUEST_MOCK.sourceChainId, + data: QUOTE_MOCK.swapTx.data, + from: FROM_MOCK, + to: QUOTE_MOCK.swapTx.to, + value: '0x0', + }); + expect(result[0].fees.isSourceGasFeeToken).toBe(true); + expect(result[0].fees.sourceNetwork.max.raw).toBe('100'); + expect(result[0].fees.sourceNetwork.estimate.raw).toBe('100'); + }); + + it('preserves authorization-list metadata when source gas fee token pricing is used', async () => { + estimateGasBatchMock.mockResolvedValue({ + totalGasLimit: 51000, + gasLimits: [51000], + requiresAuthorizationList: true, + }); + getTokenBalanceMock.mockReturnValue('0'); + isEIP7702ChainMock.mockReturnValue(true); + getGasFeeTokensMock.mockResolvedValue([GAS_FEE_TOKEN_MOCK]); + + successfulFetchMock.mockResolvedValue({ + json: async () => ({ + ...QUOTE_MOCK, + approvalTxns: [ + { + chainId: 1, + data: '0xaaaa' as Hex, + to: '0xapprove1' as Hex, + value: '0x1' as Hex, + }, + ], + }), + } as Response); + + const result = await getAcrossQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + expect(result[0].fees.isSourceGasFeeToken).toBe(true); + expect(result[0].original.metamask.requiresAuthorizationList).toBe(true); + }); + + it('does not use source gas fee token pricing when gas station is disabled for the source chain', async () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: { + confirmations_pay: { + payStrategies: { + across: { + enabled: true, + apiBase: 'https://test.across.to/api', + }, + }, + relayDisabledGasStationChains: [QUOTE_REQUEST_MOCK.sourceChainId], + }, + }, + }); + getTokenBalanceMock.mockReturnValue('0'); + isEIP7702ChainMock.mockReturnValue(true); + + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as Response); + + const result = await getAcrossQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + expect(getGasFeeTokensMock).not.toHaveBeenCalled(); + expect(result[0].fees.isSourceGasFeeToken).toBeUndefined(); + expect(result[0].fees.sourceNetwork.max.raw).toBe('1725000000000000000'); + }); + + it('does not use source gas fee token pricing when gas station cannot price the source token', async () => { + getTokenBalanceMock.mockReturnValue('0'); + isEIP7702ChainMock.mockReturnValue(true); + getGasFeeTokensMock.mockResolvedValue([ + { + ...GAS_FEE_TOKEN_MOCK, + tokenAddress: '0xdifferent' as Hex, + }, + ]); + + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as Response); + + const result = await getAcrossQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + expect(getGasFeeTokensMock).toHaveBeenCalledTimes(1); + expect(result[0].fees.isSourceGasFeeToken).toBeUndefined(); + expect(result[0].fees.sourceNetwork.max.raw).toBe('1725000000000000000'); + }); + it('includes approval gas costs and gas limits when approval transactions exist', async () => { estimateGasBatchMock.mockResolvedValue({ totalGasLimit: 951000, 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 110b1fcf1c..8e3ddd5c22 100644 --- a/packages/transaction-pay-controller/src/strategy/across/across-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/across/across-quotes.ts @@ -16,8 +16,16 @@ import type { import { getFiatValueFromUsd, sumAmounts } from '../../utils/amounts'; import { getPayStrategiesConfig, getSlippage } from '../../utils/feature-flags'; import { calculateGasCost } from '../../utils/gas'; +import { + getGasStationCostInSourceTokenRaw, + getGasStationEligibility, +} from '../../utils/gas-station'; import { estimateQuoteGasLimits } from '../../utils/quote-gas'; -import { getTokenFiatRate } from '../../utils/token'; +import { + getNativeToken, + getTokenBalance, + getTokenFiatRate, +} from '../../utils/token'; import { getAcrossDestination } from './across-actions'; import { normalizeAcrossRequest } from './perps'; import { isAcrossQuoteRequest } from './requests'; @@ -63,7 +71,7 @@ export async function getAcrossQuotes( return await Promise.all( normalizedRequests.map((singleRequest) => - getSingleQuote(singleRequest, request), + getQuoteWithGasStationHandling(singleRequest, request), ), ); } catch (error) { @@ -125,6 +133,70 @@ async function getSingleQuote( return await normalizeQuote(originalQuote, normalizedRequest, fullRequest); } +async function getQuoteWithGasStationHandling( + request: QuoteRequest, + fullRequest: PayStrategyGetQuotesRequest, +): Promise> { + const phase1Quote = await getSingleQuote(request, fullRequest); + + if (!request.isMaxAmount || !phase1Quote.fees.isSourceGasFeeToken) { + return phase1Quote; + } + + const adjustedSourceAmount = new BigNumber(request.sourceTokenAmount) + .minus(phase1Quote.fees.sourceNetwork.max.raw) + .integerValue(BigNumber.ROUND_DOWN); + + if (!adjustedSourceAmount.isGreaterThan(0)) { + log('Insufficient balance after gas subtraction for Across max quote'); + return phase1Quote; + } + + log('Subtracting gas from source for Across max quote', { + adjustedSourceAmount: adjustedSourceAmount.toString(10), + gasCostRaw: phase1Quote.fees.sourceNetwork.max.raw, + originalSourceAmount: request.sourceTokenAmount, + }); + + try { + const phase2Quote = await getSingleQuote( + { + ...request, + sourceTokenAmount: adjustedSourceAmount.toFixed( + 0, + BigNumber.ROUND_DOWN, + ), + }, + fullRequest, + ); + + if (!phase2Quote.fees.isSourceGasFeeToken) { + log('Across max phase 2 lost gas fee token eligibility'); + return phase1Quote; + } + + const phase2GasCost = new BigNumber(phase2Quote.fees.sourceNetwork.max.raw); + + if ( + adjustedSourceAmount + .plus(phase2GasCost) + .isGreaterThan(request.sourceTokenAmount) + ) { + log('Across max phase 2 quote exceeds original source amount', { + adjustedSourceAmount: adjustedSourceAmount.toString(10), + gasCostRaw: phase2GasCost.toString(10), + originalSourceAmount: request.sourceTokenAmount, + }); + return phase1Quote; + } + + return phase2Quote; + } catch (error) { + log('Across max phase 2 quote failed, falling back to phase 1', { error }); + return phase1Quote; + } +} + type AcrossApprovalRequest = { actions: AcrossAction[]; amount: string; @@ -204,8 +276,13 @@ async function normalizeQuote( const dustUsd = calculateDustUsd(quote, request, targetFiatRate); const dust = getFiatValueFromUsd(dustUsd, usdToFiatRate); - const { gasLimits, is7702, requiresAuthorizationList, sourceNetwork } = - await calculateSourceNetworkCost(quote, messenger, request); + const { + gasLimits, + is7702, + isGasFeeToken: isSourceGasFeeToken, + requiresAuthorizationList, + sourceNetwork, + } = await calculateSourceNetworkCost(quote, messenger, request); const targetNetwork = getFiatValueFromUsd(new BigNumber(0), usdToFiatRate); @@ -252,6 +329,7 @@ async function normalizeQuote( dust, estimatedDuration: quote.expectedFillTime ?? 0, fees: { + isSourceGasFeeToken, metaMask: metaMaskFee, provider, sourceNetwork, @@ -391,12 +469,13 @@ async function calculateSourceNetworkCost( ): Promise<{ sourceNetwork: TransactionPayQuote['fees']['sourceNetwork']; gasLimits: AcrossGasLimits; + isGasFeeToken?: boolean; is7702: boolean; requiresAuthorizationList?: true; }> { const acrossFallbackGas = getPayStrategiesConfig(messenger).across.fallbackGas; - const { from } = request; + const { from, sourceChainId, sourceTokenAddress } = request; const orderedTransactions = getAcrossOrderedTransactions({ quote }); const { swapTx } = quote; const swapChainId = toHex(swapTx.chainId); @@ -412,7 +491,11 @@ async function calculateSourceNetworkCost( value: transaction.value ?? '0x0', })), }); - const { batchGasLimit, is7702, requiresAuthorizationList } = gasEstimates; + const { batchGasLimit, is7702, requiresAuthorizationList, totalGasEstimate } = + gasEstimates; + + let sourceNetwork: TransactionPayQuote['fees']['sourceNetwork']; + let gasLimits: AcrossGasLimits; if (is7702) { if (!batchGasLimit) { @@ -435,63 +518,130 @@ async function calculateSourceNetworkCost( messenger, }); - return { - sourceNetwork: { - estimate, - max, + sourceNetwork = { + estimate, + max, + }; + gasLimits = [ + { + estimate: batchGasLimit.estimate, + max: batchGasLimit.max, }, - is7702: true, - ...(requiresAuthorizationList ? { requiresAuthorizationList } : {}), - gasLimits: [ - { - estimate: batchGasLimit.estimate, - max: batchGasLimit.max, - }, - ], + ]; + } else { + const transactionGasLimits = orderedTransactions.map( + (transaction, index) => ({ + gasEstimate: gasEstimates.gasLimits[index], + transaction, + }), + ); + + const estimate = sumAmounts( + transactionGasLimits.map(({ gasEstimate, transaction }) => + calculateGasCost({ + chainId: toHex(transaction.chainId), + gas: gasEstimate.estimate, + maxFeePerGas: transaction.maxFeePerGas, + maxPriorityFeePerGas: transaction.maxPriorityFeePerGas, + messenger, + }), + ), + ); + + const max = sumAmounts( + transactionGasLimits.map(({ gasEstimate, transaction }) => + calculateGasCost({ + chainId: toHex(transaction.chainId), + gas: gasEstimate.max, + isMax: true, + maxFeePerGas: transaction.maxFeePerGas, + maxPriorityFeePerGas: transaction.maxPriorityFeePerGas, + messenger, + }), + ), + ); + + sourceNetwork = { + estimate, + max, }; + gasLimits = transactionGasLimits.map(({ gasEstimate }) => ({ + estimate: gasEstimate.estimate, + max: gasEstimate.max, + })); } - const transactionGasLimits = orderedTransactions.map( - (transaction, index) => ({ - gasEstimate: gasEstimates.gasLimits[index], - transaction, - }), - ); + const result = { + sourceNetwork, + is7702, + ...(requiresAuthorizationList ? { requiresAuthorizationList } : {}), + gasLimits, + }; - const estimate = sumAmounts( - transactionGasLimits.map(({ gasEstimate, transaction }) => - calculateGasCost({ - chainId: toHex(transaction.chainId), - gas: gasEstimate.estimate, - maxFeePerGas: transaction.maxFeePerGas, - maxPriorityFeePerGas: transaction.maxPriorityFeePerGas, - messenger, - }), - ), + const nativeBalance = getTokenBalance( + messenger, + from, + sourceChainId, + getNativeToken(sourceChainId), ); - const max = sumAmounts( - transactionGasLimits.map(({ gasEstimate, transaction }) => - calculateGasCost({ - chainId: toHex(transaction.chainId), - gas: gasEstimate.max, - isMax: true, - maxFeePerGas: transaction.maxFeePerGas, - maxPriorityFeePerGas: transaction.maxPriorityFeePerGas, - messenger, - }), - ), + if ( + new BigNumber(nativeBalance).isGreaterThanOrEqualTo(sourceNetwork.max.raw) + ) { + return result; + } + + const gasStationEligibility = getGasStationEligibility( + messenger, + sourceChainId, ); + if (gasStationEligibility.isDisabledChain) { + log('Skipping Across gas station as disabled chain', { sourceChainId }); + return result; + } + + if (!gasStationEligibility.chainSupportsGasStation) { + log('Skipping Across gas station as chain does not support EIP-7702', { + sourceChainId, + }); + return result; + } + + const firstTransaction = orderedTransactions[0]; + + const gasFeeTokenCost = await getGasStationCostInSourceTokenRaw({ + firstStepData: { + data: firstTransaction.data, + to: firstTransaction.to, + value: firstTransaction.value, + }, + messenger, + request: { + from, + sourceChainId, + sourceTokenAddress, + }, + totalGasEstimate, + totalItemCount: Math.max(orderedTransactions.length, gasLimits.length), + }); + + if (!gasFeeTokenCost) { + return result; + } + + log('Using gas fee token for Across source network', { + gasFeeTokenCost, + }); + return { + isGasFeeToken: true, sourceNetwork: { - estimate, - max, + estimate: gasFeeTokenCost, + max: gasFeeTokenCost, }, - is7702: false, - gasLimits: transactionGasLimits.map(({ gasEstimate }) => ({ - estimate: gasEstimate.estimate, - max: gasEstimate.max, - })), + is7702, + ...(requiresAuthorizationList ? { requiresAuthorizationList } : {}), + gasLimits, }; } 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 b6247e0996..074ee59760 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 @@ -213,6 +213,29 @@ describe('Across Submit', () => { ); }); + it('passes gas fee token to batch submission when source gas fee token is used', async () => { + await submitAcrossQuotes({ + messenger, + quotes: [ + { + ...QUOTE_MOCK, + fees: { + ...QUOTE_MOCK.fees, + isSourceGasFeeToken: true, + }, + }, + ], + transaction: TRANSACTION_META_MOCK, + isSmartTransaction: jest.fn(), + }); + + expect(addTransactionBatchMock).toHaveBeenCalledWith( + expect.objectContaining({ + gasFeeToken: QUOTE_MOCK.request.sourceTokenAddress, + }), + ); + }); + it('submits a 7702 batch when the quote contains a combined batch gas limit', async () => { const batchGasQuote = { ...QUOTE_MOCK, @@ -291,6 +314,37 @@ describe('Across Submit', () => { ); }); + it('passes gas fee token to single transaction submission when source gas fee token is used', async () => { + const noApprovalQuote = { + ...QUOTE_MOCK, + fees: { + ...QUOTE_MOCK.fees, + isSourceGasFeeToken: true, + }, + original: { + ...QUOTE_MOCK.original, + quote: { + ...QUOTE_MOCK.original.quote, + approvalTxns: [], + }, + }, + } as TransactionPayQuote; + + await submitAcrossQuotes({ + messenger, + quotes: [noApprovalQuote], + transaction: TRANSACTION_META_MOCK, + isSmartTransaction: jest.fn(), + }); + + expect(addTransactionMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + gasFeeToken: QUOTE_MOCK.request.sourceTokenAddress, + }), + ); + }); + it('throws when the combined 7702 batch gas limit is missing', async () => { const missingBatchGasQuote = { ...QUOTE_MOCK, diff --git a/packages/transaction-pay-controller/src/strategy/across/across-submit.ts b/packages/transaction-pay-controller/src/strategy/across/across-submit.ts index 78d3cd17a5..cb09bac1cb 100644 --- a/packages/transaction-pay-controller/src/strategy/across/across-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/across/across-submit.ts @@ -194,6 +194,9 @@ async function submitTransactions( ); let result: { result: Promise } | undefined; + const gasFeeToken = quote.fees.isSourceGasFeeToken + ? quote.request.sourceTokenAddress + : undefined; try { if (transactions.length === 1) { @@ -201,6 +204,7 @@ async function submitTransactions( 'TransactionController:addTransaction', transactions[0].params, { + gasFeeToken, networkClientId, origin: ORIGIN_METAMASK, requireApproval: false, @@ -218,6 +222,7 @@ async function submitTransactions( disableHook: Boolean(gasLimit7702), disableSequential: Boolean(gasLimit7702), from, + gasFeeToken, gasLimit7702, networkClientId, origin: ORIGIN_METAMASK, diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-max-gas-station.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-max-gas-station.ts index bb2f9c5581..b58473f1a0 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-max-gas-station.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-max-gas-station.ts @@ -8,15 +8,15 @@ import type { TransactionPayControllerMessenger, TransactionPayQuote, } from '../../types'; +import { + getGasStationEligibility, + getGasStationCostInSourceTokenRaw, +} from '../../utils/gas-station'; import { getNativeToken, getTokenBalance, getTokenInfo, } from '../../utils/token'; -import { - getGasStationEligibility, - getGasStationCostInSourceTokenRaw, -} from './gas-station'; import type { RelayQuote, RelayTransactionStep } from './types'; const log = createModuleLogger(projectLogger, 'relay-max-gas-station'); 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 19c0b4d332..f2fbaf517c 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -37,6 +37,10 @@ import { isRelayExecuteEnabled, } from '../../utils/feature-flags'; import { calculateGasCost } from '../../utils/gas'; +import { + getGasStationCostInSourceTokenRaw, + getGasStationEligibility, +} from '../../utils/gas-station'; import { estimateQuoteGasLimits } from '../../utils/quote-gas'; import type { QuoteGasTransaction } from '../../utils/quote-gas'; import { @@ -48,10 +52,6 @@ import { } from '../../utils/token'; import { isPredictWithdrawTransaction } from '../../utils/transaction'; import { TOKEN_TRANSFER_FOUR_BYTE } from './constants'; -import { - getGasStationCostInSourceTokenRaw, - getGasStationEligibility, -} from './gas-station'; import { fetchRelayQuote } from './relay-api'; import { getRelayMaxGasStationQuote } from './relay-max-gas-station'; import type { diff --git a/packages/transaction-pay-controller/src/strategy/relay/gas-station.test.ts b/packages/transaction-pay-controller/src/utils/gas-station.test.ts similarity index 94% rename from packages/transaction-pay-controller/src/strategy/relay/gas-station.test.ts rename to packages/transaction-pay-controller/src/utils/gas-station.test.ts index d72e4b51ac..135a1447ea 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/gas-station.test.ts +++ b/packages/transaction-pay-controller/src/utils/gas-station.test.ts @@ -1,15 +1,15 @@ import type { GasFeeToken } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; -import { getDefaultRemoteFeatureFlagControllerState } from '../../../../remote-feature-flag-controller/src/remote-feature-flag-controller'; -import { getMessengerMock } from '../../tests/messenger-mock'; -import { calculateGasFeeTokenCost } from '../../utils/gas'; +import { getDefaultRemoteFeatureFlagControllerState } from '../../../remote-feature-flag-controller/src/remote-feature-flag-controller'; +import { getMessengerMock } from '../tests/messenger-mock'; +import { calculateGasFeeTokenCost } from './gas'; import { getGasStationEligibility, getGasStationCostInSourceTokenRaw, } from './gas-station'; -jest.mock('../../utils/gas'); +jest.mock('./gas'); const REQUEST_MOCK = { from: '0x1234567890123456789012345678901234567891' as Hex, @@ -18,8 +18,8 @@ const REQUEST_MOCK = { }; const FIRST_STEP_DATA_MOCK = { - data: '0x123', - to: '0x2', + data: '0x123' as Hex, + to: '0x2' as Hex, value: '0x0', }; diff --git a/packages/transaction-pay-controller/src/strategy/relay/gas-station.ts b/packages/transaction-pay-controller/src/utils/gas-station.ts similarity index 93% rename from packages/transaction-pay-controller/src/strategy/relay/gas-station.ts rename to packages/transaction-pay-controller/src/utils/gas-station.ts index 0aca93332a..4a52b30310 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/gas-station.ts +++ b/packages/transaction-pay-controller/src/utils/gas-station.ts @@ -4,16 +4,16 @@ import type { Hex } from '@metamask/utils'; import { createModuleLogger } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; -import { projectLogger } from '../../logger'; +import { projectLogger } from '../logger'; import type { Amount, QuoteRequest, TransactionPayControllerMessenger, -} from '../../types'; -import { getFeatureFlags, isEIP7702Chain } from '../../utils/feature-flags'; -import { calculateGasFeeTokenCost } from '../../utils/gas'; +} from '../types'; +import { getFeatureFlags, isEIP7702Chain } from './feature-flags'; +import { calculateGasFeeTokenCost } from './gas'; -const log = createModuleLogger(projectLogger, 'relay-gas-station'); +const log = createModuleLogger(projectLogger, 'gas-station'); type GasStationCostParams = { firstStepData: {