From 03837b83685bbbe294396a76c25595b5ebe5cd74 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Wed, 23 Sep 2026 14:33:03 +0100 Subject: [PATCH 1/9] refactor(transaction-pay-controller): consolidate origin gas payment resolution Replace `utils/gas-station.ts` with `utils/gas-payment.ts`, which owns both quote-time gas-station cost estimation and submit-time payment-mode resolution. Each strategy previously re-derived whether the origin gas fee should be paid from the source token, duplicating native-balance checks, gas-station eligibility probes and sponsorship predicates across the Across, Relay and Server strategies. Those modules now delegate to `resolveGasStationCost`, `resolveGasPayment` and `shouldUseGasStation`. This also unifies the Relay single-transaction and batch submit paths, which had diverged: the batch builder set `gasFeeToken` without checking sponsorship, whereas the single-transaction path did. Internal only; none of this surface is exported from the package index. --- .../src/strategy/across/across-quotes.ts | 58 +- .../src/strategy/across/across-submit.ts | 44 +- .../src/strategy/relay/relay-max.ts | 85 +-- .../src/strategy/relay/relay-quotes.ts | 129 +---- .../src/strategy/relay/relay-submit.ts | 39 +- .../src/strategy/server/server-quotes.test.ts | 35 +- .../src/strategy/server/server-quotes.ts | 51 +- .../src/strategy/server/server-submit.ts | 18 +- .../src/utils/gas-payment.test.ts | 498 +++++++++++++++++ .../src/utils/gas-payment.ts | 517 ++++++++++++++++++ .../src/utils/gas-station.test.ts | 165 ------ .../src/utils/gas-station.ts | 153 ------ 12 files changed, 1162 insertions(+), 630 deletions(-) create mode 100644 packages/transaction-pay-controller/src/utils/gas-payment.test.ts create mode 100644 packages/transaction-pay-controller/src/utils/gas-payment.ts delete mode 100644 packages/transaction-pay-controller/src/utils/gas-station.test.ts delete mode 100644 packages/transaction-pay-controller/src/utils/gas-station.ts 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 3222d6f7feb..d49a4b737a7 100644 --- a/packages/transaction-pay-controller/src/strategy/across/across-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/across/across-quotes.ts @@ -23,18 +23,11 @@ import { getPayStrategiesConfig, getSlippage, } from '../../utils/feature-flags.js'; -import { - getGasStationCostInSourceTokenRaw, - getGasStationEligibility, -} from '../../utils/gas-station.js'; +import { resolveGasStationCost } from '../../utils/gas-payment.js'; import { calculateGasCost } from '../../utils/gas.js'; import { estimateQuoteGasLimits } from '../../utils/quote-gas.js'; import type { QuoteGasTransaction } from '../../utils/quote-gas.js'; -import { - getNativeToken, - getTokenBalance, - getTokenFiatRate, -} from '../../utils/token.js'; +import { getTokenFiatRate } from '../../utils/token.js'; import type { AcrossDestination } from './across-actions.js'; import { getAcrossDestination } from './across-actions.js'; import { hasUnsupportedTransactionAuthorizationList } from './authorization-list.js'; @@ -692,50 +685,23 @@ async function calculateSourceNetworkCost( ? combinePostQuoteGas(result, transaction, swapTx, messenger) : result; - const nativeBalance = getTokenBalance( - messenger, - from, - sourceChainId, - getNativeToken(sourceChainId), - ); - const hasNativeBalance = new BigNumber(nativeBalance).isGreaterThanOrEqualTo( - finalResult.sourceNetwork.max.raw, - ); - - if (isPredictWithdraw && !accountSupports7702) { - return finalResult; - } - - if (hasNativeBalance && !isPredictWithdraw) { - return finalResult; - } - - const gasStationEligibility = getGasStationEligibility( - messenger, - sourceChainId, - ); - - if (gasStationEligibility.isDisabledChain) { - log('Skipping Across gas station as disabled chain', { sourceChainId }); - return finalResult; - } - - if (!gasStationEligibility.chainSupportsGasStation) { - log('Skipping Across gas station as chain does not support EIP-7702', { - sourceChainId, - }); - return finalResult; - } - const firstTransaction = orderedTransactions[0]; - const gasFeeTokenCost = await getGasStationCostInSourceTokenRaw({ + // Predict withdraws always consult the gas station: the user's source token + // lives in the Safe, so a healthy native balance does not mean they can + // actually pay gas. They do require an EIP-7702 capable account, since the + // withdraw is submitted as a 7702 batch. + const { amount: gasFeeTokenCost, isAvailable } = await resolveGasStationCost({ + accountSupports7702: isPredictWithdraw ? accountSupports7702 : undefined, firstStepData: { data: firstTransaction.data, to: firstTransaction.to, value: firstTransaction.value, }, messenger, + nativeGasCostRaw: isPredictWithdraw + ? undefined + : finalResult.sourceNetwork.max.raw, request: { from, sourceChainId, @@ -757,7 +723,7 @@ async function calculateSourceNetworkCost( estimate: gasFeeTokenCost, max: gasFeeTokenCost, }; - } else if (isPredictWithdraw) { + } else if (isAvailable && isPredictWithdraw) { gasFeeTokenNetwork = calculateSourceGasFeeTokenNetworkFallback({ messenger, nativeSourceNetwork: finalResult.sourceNetwork, 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 31833ef2517..2aad0a63343 100644 --- a/packages/transaction-pay-controller/src/strategy/across/across-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/across/across-submit.ts @@ -24,6 +24,7 @@ import type { import { accountSupports7702 } from '../../utils/7702.js'; import { getPayStrategiesConfig } from '../../utils/feature-flags.js'; import { getGasBuffer } from '../../utils/feature-flags.js'; +import { GasPaymentMode, resolveGasPayment } from '../../utils/gas-payment.js'; import { getNetworkClientId } from '../../utils/provider.js'; import { collectTransactionIds, @@ -127,8 +128,18 @@ async function submitTransactions( ): Promise { const { swapTx } = quote.original.quote; const { gasLimits: quoteGasLimits, is7702 } = quote.original.metamask; - const { from } = quote.request; + const { from, sourceTokenAddress } = quote.request; const chainId = toHex(swapTx.chainId); + + // Across has no relayer-paid delegation flow and is never sponsored, so the + // only lift available is the gas station collecting gas in the source token. + const gasPayment = resolveGasPayment({ + excludeNativeTokenForFee: true, + isSourceGasFeeToken: quote.fees.isSourceGasFeeToken, + sourceTokenAddress, + }); + const isGasFeeTokenPayment = gasPayment.mode === GasPaymentMode.GasFeeToken; + const orderedTransactions = getAcrossOrderedTransactions({ quote: quote.original.quote, swapType: acrossDepositType, @@ -165,10 +176,11 @@ async function submitTransactions( parentTransaction.txParams.authorizationList?.length, ); - const shouldUseGasFeeToken7702Submit = shouldEstimate7702SubmitBatch( + const shouldUseGasFeeToken7702Submit = shouldEstimate7702SubmitBatch({ + isGasFeeTokenPayment, parentTransaction, quote, - ) + }) ? accountSupports7702(messenger, from) : false; const shouldUse7702Submit = [ @@ -259,10 +271,7 @@ async function submitTransactions( ); let result: { result: Promise } | undefined; - const gasFeeToken = quote.fees.isSourceGasFeeToken - ? quote.request.sourceTokenAddress - : undefined; - const excludeNativeTokenForFee = gasFeeToken ? true : undefined; + const { excludeNativeTokenForFee, gasFeeToken } = gasPayment; try { if (transactions.length === 1) { @@ -419,18 +428,25 @@ async function waitForAcrossCompletion( * gas with the source token, because the final submit batch can differ from the * batch shape that Across quoted. * - * @param parentTransaction - Original transaction metadata. - * @param quote - Across quote selected for execution. + * @param args - Check arguments. + * @param args.isGasFeeTokenPayment - Whether source-chain gas is paid with a gas fee token. + * @param args.parentTransaction - Original transaction metadata. + * @param args.quote - Across quote selected for execution. * @returns Whether submit should try to estimate the final 7702 batch gas. */ -function shouldEstimate7702SubmitBatch( - parentTransaction: TransactionMeta, - quote: TransactionPayQuote, -): boolean { +function shouldEstimate7702SubmitBatch({ + isGasFeeTokenPayment, + parentTransaction, + quote, +}: { + isGasFeeTokenPayment: boolean; + parentTransaction: TransactionMeta; + quote: TransactionPayQuote; +}): boolean { return ( hasTransactionType(parentTransaction, [TransactionType.predictWithdraw]) && quote.request.isPostQuote === true && - quote.fees.isSourceGasFeeToken === true + isGasFeeTokenPayment ); } diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-max.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-max.ts index 953d60a3359..0012ba2f5ce 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-max.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-max.ts @@ -13,14 +13,10 @@ import type { import { prefixError } from '../../utils/error-prefix.js'; import { isAtomicMaxEnabled } from '../../utils/feature-flags.js'; import { - getGasStationEligibility, - getGasStationCostInSourceTokenRaw, -} from '../../utils/gas-station.js'; -import { - getNativeToken, - getTokenBalance, - getTokenInfo, -} from '../../utils/token.js'; + shouldUseGasStation, + resolveGasStationCost, +} from '../../utils/gas-payment.js'; +import { getTokenInfo } from '../../utils/token.js'; import { QuoteError } from '../../utils/validation.js'; import { ATOMIC_PROMOTION_FAILURE_PREFIX } from './constants.js'; import { isSubsidizedRelayQuote } from './relay-submit-execute.js'; @@ -41,12 +37,6 @@ type GasCostEstimate = { source: GasCostEstimateSource; }; -type NativeBalanceCheckResult = { - hasEnoughNativeBalance: boolean; - nativeBalance?: string; - nativeGasCostRaw?: string; -}; - type GetSingleQuoteFn = ( request: QuoteRequest, fullRequest: PayStrategyGetQuotesRequest, @@ -168,7 +158,7 @@ export async function getRelayMaxGasStationQuote( getSingleQuote: GetSingleQuoteFn, ): Promise> { const { messenger } = fullRequest; - const { sourceChainId, sourceTokenAmount } = request; + const { sourceTokenAmount } = request; const context: MaxAmountQuoteContext = { fullRequest, getSingleQuote, @@ -182,32 +172,21 @@ export async function getRelayMaxGasStationQuote( return phase1Quote; } - const nativeBalanceCheck = checkEnoughNativeBalanceIfSourceGasFeeTokenNotUsed( - phase1Quote, + // A source-token gas fee means the native balance is irrelevant, so skip the + // balance check entirely rather than comparing against a cost the user will + // never pay in native token. + const useGasStation = shouldUseGasStation({ messenger, + nativeGasCostRaw: phase1Quote.fees.isSourceGasFeeToken + ? undefined + : phase1Quote.fees.sourceNetwork.max.raw, request, - ); - - if (nativeBalanceCheck.hasEnoughNativeBalance) { - return fallbackToPhase1( - phase1Quote, - 'Native balance is sufficient for gas', - { - nativeBalance: nativeBalanceCheck.nativeBalance, - nativeGasCost: nativeBalanceCheck.nativeGasCostRaw, - }, - ); - } - - const gasStationEligibility = getGasStationEligibility( - messenger, - sourceChainId, - ); + }); - if (!gasStationEligibility.isEligible) { + if (!useGasStation) { return fallbackToPhase1( phase1Quote, - 'Gas station is disabled or unsupported', + 'Native balance covers gas or gas station is unavailable', ); } @@ -312,32 +291,6 @@ export async function getRelayMaxGasStationQuote( return phase2Quote; } -function checkEnoughNativeBalanceIfSourceGasFeeTokenNotUsed( - quote: TransactionPayQuote, - messenger: TransactionPayControllerMessenger, - request: QuoteRequest, -): NativeBalanceCheckResult { - if (quote.fees.isSourceGasFeeToken) { - return { hasEnoughNativeBalance: false }; - } - - const nativeGasCostRaw = quote.fees.sourceNetwork.max.raw; - const nativeBalance = getTokenBalance( - messenger, - request.from, - request.sourceChainId, - getNativeToken(request.sourceChainId), - ); - - return { - hasEnoughNativeBalance: new BigNumber(nativeBalance).isGreaterThanOrEqualTo( - nativeGasCostRaw, - ), - nativeBalance, - nativeGasCostRaw, - }; -} - function getAdjustedSourceAmount( sourceAmount: BigNumber, estimatedGasCost: BigNumber, @@ -424,7 +377,9 @@ async function getGasCostFromQuoteOrGasStation( 0, ); - const gasStationCost = await getGasStationCostInSourceTokenRaw({ + // Native balance and chain eligibility were already checked before the probe + // flow started, so only the source-token pricing is needed here. + const gasStationCost = await resolveGasStationCost({ firstStepData, messenger, request: { @@ -436,12 +391,12 @@ async function getGasCostFromQuoteOrGasStation( totalItemCount, }); - if (!gasStationCost) { + if (!gasStationCost.amount) { return undefined; } return { - amount: new BigNumber(gasStationCost.raw), + amount: new BigNumber(gasStationCost.amount.raw), source: GasCostEstimateSource.GasStation, }; } 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 0a216e70eab..5396d04e8e3 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -43,15 +43,15 @@ import { isRelayExecuteEnabled, } from '../../utils/feature-flags.js'; import { - getGasStationCostInSourceTokenRaw, - getGasStationEligibility, -} from '../../utils/gas-station.js'; + GasPaymentMode, + resolveGasPayment, + resolveGasStationCost, +} from '../../utils/gas-payment.js'; import { calculateGasCost } from '../../utils/gas.js'; import { estimateQuoteGasLimits } from '../../utils/quote-gas.js'; import type { QuoteGasTransaction } from '../../utils/quote-gas.js'; import { getNativeToken, - getTokenBalance, getTokenFiatRate, normalizeTokenAddress, TokenAddressTarget, @@ -941,12 +941,16 @@ async function calculateSourceNetworkCost( }; } - if ( - accountSupports7702 && - transaction.isGasFeeSponsored && - request.sourceChainId === transaction.chainId && - request.targetChainId === transaction.chainId - ) { + const gasPayment = resolveGasPayment({ + sourceTokenAddress, + sponsorship: { + accountSupports7702, + request, + transaction, + }, + }); + + if (gasPayment.mode === GasPaymentMode.Sponsored) { log('Zeroing source network fees for sponsored same-chain Relay route'); // Gas limit is zero as sponsored transactions go through the EIP-7702 @@ -1033,53 +1037,8 @@ async function calculateSourceNetworkCost( isMax: true, }); - const nativeBalance = getTokenBalance( - messenger, - from, - sourceChainId, - getNativeToken(sourceChainId), - ); - const result = { estimate, max, gasLimits, is7702 }; - if (new BigNumber(nativeBalance).isGreaterThanOrEqualTo(max.raw)) { - return result; - } - - if (accountSupports7702 === false) { - log('Skipping gas station as account does not support EIP-7702', { - from, - }); - - return result; - } - - const gasStationEligibility = getGasStationEligibility( - messenger, - sourceChainId, - ); - - if (gasStationEligibility.isDisabledChain) { - log('Skipping gas station as disabled chain', { - sourceChainId, - }); - - return result; - } - - if (!gasStationEligibility.chainSupportsGasStation) { - log('Skipping gas station as chain does not support EIP-7702', { - sourceChainId, - }); - - return result; - } - - log('Checking gas fee tokens as insufficient native balance', { - nativeBalance, - max: max.raw, - }); - // Gas-fee-token lookup must use the Safe proxy for ALL Predict withdraws, // not only deposit-style routes. The user's source token (pUSD) lives in // the Safe; the EOA is empty until the Safe.execTransaction sub-call runs @@ -1087,74 +1046,44 @@ async function calculateSourceNetworkCost( // return nothing and force users to hold POL. // (`useFromOverride` only governs the gas-estimation `from` address, where // swap-style routes need EOA because DEX routers reject contract callers.) - if (isPredictWithdrawFlow && request.refundTo) { - log('Using proxy address for predict withdraw gas station simulation', { - proxyAddress: request.refundTo, - sourceTokenAddress, - totalGasEstimate, - }); - - const gasFeeTokenCost = await getGasStationCostInSourceTokenRaw({ - firstStepData: { - data, - to, - value, - }, - messenger, - request: { - from: request.refundTo, - sourceChainId, - sourceTokenAddress, - }, - totalGasEstimate, - totalItemCount: relayParams.length + 1, - }); - - if (gasFeeTokenCost) { - log('Using predict withdraw gas fee token for source network', { - gasFeeTokenCost, - }); - - return { - isGasFeeToken: true, - estimate: gasFeeTokenCost, - max: gasFeeTokenCost, - gasLimits, - is7702, - }; - } - - return result; - } + const proxyFeeTokenAccount = isPredictWithdrawFlow + ? request.refundTo + : undefined; - const gasFeeTokenCost = await getGasStationCostInSourceTokenRaw({ + const gasStationCost = await resolveGasStationCost({ + accountSupports7702, + feeTokenAccount: proxyFeeTokenAccount, firstStepData: { data, to, value, }, messenger, + nativeGasCostRaw: max.raw, request: { from, sourceChainId, sourceTokenAddress, }, totalGasEstimate, - totalItemCount: Math.max(relayParams.length, gasLimits.length), + totalItemCount: proxyFeeTokenAccount + ? relayParams.length + 1 + : Math.max(relayParams.length, gasLimits.length), }); - if (!gasFeeTokenCost) { + if (!gasStationCost.amount) { return result; } log('Using gas fee token for source network', { - gasFeeTokenCost, + gasFeeTokenCost: gasStationCost.amount, + proxyFeeTokenAccount, }); return { isGasFeeToken: true, - estimate: gasFeeTokenCost, - max: gasFeeTokenCost, + estimate: gasStationCost.amount, + max: gasStationCost.amount, gasLimits, is7702, }; 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 98a577c852f..4a07a2f9aba 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts @@ -25,6 +25,8 @@ import { getRelayPollingInterval, getRelayPollingTimeout, } from '../../utils/feature-flags.js'; +import type { GasPayment } from '../../utils/gas-payment.js'; +import { resolveGasPayment } from '../../utils/gas-payment.js'; import { submitMoneyAccountVaultDeposit } from '../../utils/ma-vault-deposit.js'; import { getNetworkClientId } from '../../utils/provider.js'; import { @@ -881,21 +883,23 @@ async function submitViaTransactionController( let result: { result: Promise } | undefined; - const isSourceGasFeeSponsored = - transaction.isGasFeeSponsored && - quote.request.sourceChainId === transaction.chainId && - quote.request.targetChainId === transaction.chainId && - accountSupports7702(messenger, from); + const gasPayment = resolveGasPayment({ + isSourceGasFeeToken: quote.fees.isSourceGasFeeToken, + sourceTokenAddress, + sponsorship: { + accountSupports7702: accountSupports7702(messenger, from), + request: quote.request, + transaction, + }, + }); - const gasFeeToken = - !isSourceGasFeeSponsored && quote.fees.isSourceGasFeeToken - ? sourceTokenAddress - : undefined; + const { gasFeeToken } = gasPayment; log('Submitting transactions', { isPostQuote, gasFeeToken, allParamsCount: allParams.length, + gasPaymentMode: gasPayment.mode, }); const isSameChain = @@ -944,7 +948,7 @@ async function submitViaTransactionController( networkClientId, origin: ORIGIN_METAMASK, isInternal: true, - isGasFeeSponsored: isSourceGasFeeSponsored, + isGasFeeSponsored: gasPayment.isGasFeeSponsored, requireApproval: false, type: getRelayDepositType(getEffectiveTransactionType(transaction)), }, @@ -955,7 +959,7 @@ async function submitViaTransactionController( buildRelayTransactionBatchRequest({ allParams, authorizationList: batchAuthorizationList, - isGasFeeSponsored: isSourceGasFeeSponsored, + gasPayment, messenger, normalizedParams, quote, @@ -1008,7 +1012,7 @@ function mapSignedQuoteAuthorizationList( function buildRelayTransactionBatchRequest({ allParams, authorizationList, - isGasFeeSponsored, + gasPayment, messenger, normalizedParams, quote, @@ -1016,18 +1020,15 @@ function buildRelayTransactionBatchRequest({ }: { allParams: TransactionParams[]; authorizationList?: AuthorizationList; - isGasFeeSponsored: boolean | undefined; + gasPayment: GasPayment; messenger: TransactionPayControllerMessenger; normalizedParams: TransactionParams[]; quote: TransactionPayQuote; transaction: TransactionMeta; }): TransactionBatchRequest { - const { from, sourceChainId, sourceTokenAddress } = quote.request; + const { from, sourceChainId } = quote.request; const { metamask } = quote.original; const networkClientId = getNetworkClientId(messenger, sourceChainId); - const gasFeeToken = quote.fees?.isSourceGasFeeToken - ? sourceTokenAddress - : undefined; const gasLimit7702 = getGasLimit7702(quote); return { @@ -1036,12 +1037,12 @@ function buildRelayTransactionBatchRequest({ disable7702: !gasLimit7702, disableHook: Boolean(gasLimit7702), disableSequential: Boolean(gasLimit7702), - gasFeeToken, + gasFeeToken: gasPayment.gasFeeToken, gasLimit7702, networkClientId, origin: ORIGIN_METAMASK, isInternal: true, - isGasFeeSponsored, + isGasFeeSponsored: gasPayment.isGasFeeSponsored, overwriteUpgrade: true, requireApproval: false, transactions: buildRelayBatchTransactions({ diff --git a/packages/transaction-pay-controller/src/strategy/server/server-quotes.test.ts b/packages/transaction-pay-controller/src/strategy/server/server-quotes.test.ts index 131a9b2e94e..6ef839b9452 100644 --- a/packages/transaction-pay-controller/src/strategy/server/server-quotes.test.ts +++ b/packages/transaction-pay-controller/src/strategy/server/server-quotes.test.ts @@ -16,10 +16,7 @@ import { getSlippage, isEIP7702Chain, } from '../../utils/feature-flags.js'; -import { - getGasStationCostInSourceTokenRaw, - getGasStationEligibility, -} from '../../utils/gas-station.js'; +import { resolveGasStationCost } from '../../utils/gas-payment.js'; import { calculateGasCost, getGasFee } from '../../utils/gas.js'; import { estimateQuoteGasLimits } from '../../utils/quote-gas.js'; import { @@ -39,7 +36,7 @@ jest.mock('../../utils/feature-flags', () => ({ isEIP7702Chain: jest.fn(), })); jest.mock('./server-api'); -jest.mock('../../utils/gas-station'); +jest.mock('../../utils/gas-payment'); jest.mock('../../utils/gas'); jest.mock('../../utils/quote-gas'); jest.mock('../../utils/token'); @@ -169,11 +166,7 @@ describe('server-quotes', () => { totalGasEstimate: '0x5208', totalGasLimit: '0x7530', } as never); - jest.mocked(getGasStationEligibility).mockReturnValue({ - chainSupportsGasStation: false, - isDisabledChain: false, - } as never); - jest.mocked(getGasStationCostInSourceTokenRaw).mockResolvedValue(undefined); + jest.mocked(resolveGasStationCost).mockResolvedValue({ isAvailable: false }); }); it('maps transactions without bundled calls to EXACT_INPUT quote requests', async () => { @@ -549,10 +542,9 @@ describe('server-quotes', () => { beforeEach(() => { jest.mocked(calculateGasCost).mockReturnValue(GAS_ESTIMATE_MOCK); jest.mocked(getTokenBalance).mockReturnValue('999999999999999999999'); - jest.mocked(getGasStationEligibility).mockReturnValue({ - chainSupportsGasStation: true, - isDisabledChain: false, - } as never); + jest + .mocked(resolveGasStationCost) + .mockResolvedValue({ isAvailable: true }); fetchServerQuoteMock.mockResolvedValue({ results: [NON_GASLESS_RESULT_MOCK], }); @@ -574,10 +566,9 @@ describe('server-quotes', () => { it('returns estimate and max when gas station is not supported', async () => { jest.mocked(getTokenBalance).mockReturnValue('0'); - jest.mocked(getGasStationEligibility).mockReturnValue({ - chainSupportsGasStation: false, - isDisabledChain: false, - } as never); + jest + .mocked(resolveGasStationCost) + .mockResolvedValue({ isAvailable: false }); const result = await getServerQuotes({ accountSupports7702: true, @@ -592,8 +583,8 @@ describe('server-quotes', () => { it('returns estimate and max when gas station cost is unavailable', async () => { jest.mocked(getTokenBalance).mockReturnValue('0'); jest - .mocked(getGasStationCostInSourceTokenRaw) - .mockResolvedValue(undefined); + .mocked(resolveGasStationCost) + .mockResolvedValue({ amount: undefined, isAvailable: true }); const result = await getServerQuotes({ accountSupports7702: true, @@ -616,8 +607,8 @@ describe('server-quotes', () => { jest.mocked(getTokenBalance).mockReturnValue('0'); jest - .mocked(getGasStationCostInSourceTokenRaw) - .mockResolvedValue(GAS_FEE_TOKEN_COST); + .mocked(resolveGasStationCost) + .mockResolvedValue({ amount: GAS_FEE_TOKEN_COST, isAvailable: true }); const result = await getServerQuotes({ accountSupports7702: true, diff --git a/packages/transaction-pay-controller/src/strategy/server/server-quotes.ts b/packages/transaction-pay-controller/src/strategy/server/server-quotes.ts index 52f4a659011..75f04d6d295 100644 --- a/packages/transaction-pay-controller/src/strategy/server/server-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/server/server-quotes.ts @@ -27,18 +27,11 @@ import { getSlippage, isEIP7702Chain, } from '../../utils/feature-flags.js'; -import { - getGasStationCostInSourceTokenRaw, - getGasStationEligibility, -} from '../../utils/gas-station.js'; +import { resolveGasStationCost } from '../../utils/gas-payment.js'; import { calculateGasCost, getGasFee } from '../../utils/gas.js'; import { estimateQuoteGasLimits } from '../../utils/quote-gas.js'; import type { QuoteGasTransaction } from '../../utils/quote-gas.js'; -import { - getNativeToken, - getTokenBalance, - getTokenFiatRate, -} from '../../utils/token.js'; +import { getTokenFiatRate } from '../../utils/token.js'; import { getQuotePricing, TradeType } from '../../utils/trade-type.js'; import { normalizeServerPerpsRequest } from './perps.js'; import { fetchServerQuote } from './server-api.js'; @@ -520,42 +513,16 @@ async function calculateSourceNetworkCost({ messenger, }); - const nativeBalance = getTokenBalance( - messenger, - from, - sourceChainId, - getNativeToken(sourceChainId), - ); - const fees = { maxFeePerGas, maxPriorityFeePerGas }; - if (new BigNumber(nativeBalance).isGreaterThanOrEqualTo(max.raw)) { - return { estimate, gasLimits, is7702, max, ...fees }; - } - - const eligibility = getGasStationEligibility(messenger, sourceChainId); - - if (eligibility.isDisabledChain || !eligibility.chainSupportsGasStation) { - log('Skipping gas station for source network', { - isDisabledChain: eligibility.isDisabledChain, - sourceChainId, - supportsGasStation: eligibility.chainSupportsGasStation, - }); - return { estimate, gasLimits, is7702, max, ...fees }; - } - - log('Checking gas fee tokens due to insufficient native balance', { - max: max.raw, - nativeBalance, - }); - - const gasFeeTokenCost = await getGasStationCostInSourceTokenRaw({ + const gasStationCost = await resolveGasStationCost({ firstStepData: { data: firstStep.data, to: firstStep.to, value: firstStep.value, }, messenger, + nativeGasCostRaw: max.raw, request: { from, sourceChainId, @@ -565,18 +532,20 @@ async function calculateSourceNetworkCost({ totalItemCount: steps.length, }); - if (!gasFeeTokenCost) { + if (!gasStationCost.amount) { return { estimate, gasLimits, is7702, max, ...fees }; } - log('Using gas fee token for source network', { gasFeeTokenCost }); + log('Using gas fee token for source network', { + gasFeeTokenCost: gasStationCost.amount, + }); return { - estimate: gasFeeTokenCost, + estimate: gasStationCost.amount, gasLimits, is7702, isSourceGasFeeToken: true, - max: gasFeeTokenCost, + max: gasStationCost.amount, ...fees, }; } diff --git a/packages/transaction-pay-controller/src/strategy/server/server-submit.ts b/packages/transaction-pay-controller/src/strategy/server/server-submit.ts index 00e4a705b05..54ca3b32454 100644 --- a/packages/transaction-pay-controller/src/strategy/server/server-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/server/server-submit.ts @@ -23,6 +23,8 @@ import { getServerPollingInterval, getServerPollingTimeout, } from '../../utils/feature-flags.js'; +import type { GasPayment } from '../../utils/gas-payment.js'; +import { GasPaymentMode, resolveGasPayment } from '../../utils/gas-payment.js'; import { getNetworkClientId } from '../../utils/provider.js'; import { getLiveTokenBalance, @@ -204,7 +206,13 @@ async function submitTransactionSteps( messenger, ); - if (quote.original.gasless) { + const gasPayment = resolveGasPayment({ + isDelegated: quote.original.gasless, + isSourceGasFeeToken: quote.fees.isSourceGasFeeToken, + sourceTokenAddress: quote.request.sourceTokenAddress, + }); + + if (gasPayment.mode === GasPaymentMode.Delegation) { await submitViaServerExecute( quote, stepTransactions, @@ -217,6 +225,7 @@ async function submitTransactionSteps( stepTransactions, messenger, transaction, + gasPayment, ); } } @@ -670,14 +679,13 @@ async function submitViaTransactionController( stepTransactions: StepTransaction[], messenger: TransactionPayControllerMessenger, transaction: TransactionMeta, + gasPayment: GasPayment, ): Promise { - const { from, sourceChainId, sourceTokenAddress } = quote.request; + const { from, sourceChainId } = quote.request; const { gasLimits, is7702 } = quote.original.client; + const { gasFeeToken } = gasPayment; const networkClientId = getNetworkClientId(messenger, sourceChainId); - const gasFeeToken = quote.fees.isSourceGasFeeToken - ? sourceTokenAddress - : undefined; log('Submitting via TransactionController', { from, diff --git a/packages/transaction-pay-controller/src/utils/gas-payment.test.ts b/packages/transaction-pay-controller/src/utils/gas-payment.test.ts new file mode 100644 index 00000000000..13d409be4de --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/gas-payment.test.ts @@ -0,0 +1,498 @@ +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.js'; +import { getMessengerMock } from '../tests/messenger-mock.js'; +import type { GasPayment } from './gas-payment.js'; +import { + shouldUseGasStation, + GasPaymentMode, + resolveGasPayment, + resolveGasStationCost, +} from './gas-payment.js'; +import { calculateGasFeeTokenCost } from './gas.js'; +import { getTokenBalance } from './token.js'; + +jest.mock('./gas'); +jest.mock('./token', () => ({ + ...jest.requireActual('./token'), + getTokenBalance: jest.fn(), +})); + +const REQUEST_MOCK = { + from: '0x1234567890123456789012345678901234567891' as Hex, + sourceChainId: '0x1' as Hex, + sourceTokenAddress: '0xabc' as Hex, +}; + +const FIRST_STEP_DATA_MOCK = { + data: '0x123' as Hex, + to: '0x2' as Hex, + value: '0x0', +}; + +const MATCHING_GAS_FEE_TOKEN: GasFeeToken = { + amount: '0x64', + balance: '0x0', + decimals: 6, + gas: '0x64', + maxFeePerGas: '0x1', + maxPriorityFeePerGas: '0x1', + rateWei: '0x1', + recipient: REQUEST_MOCK.from, + symbol: 'USDC', + tokenAddress: REQUEST_MOCK.sourceTokenAddress, +}; + +const TRANSACTION_MOCK = { + chainId: REQUEST_MOCK.sourceChainId, + isGasFeeSponsored: true, +}; + +const SPONSORED_ROUTE_REQUEST_MOCK = { + sourceChainId: REQUEST_MOCK.sourceChainId, + targetChainId: REQUEST_MOCK.sourceChainId, +}; + +describe('gas-payment', () => { + const calculateGasFeeTokenCostMock = jest.mocked(calculateGasFeeTokenCost); + const getTokenBalanceMock = jest.mocked(getTokenBalance); + + const { + messenger, + getGasFeeTokensMock, + getRemoteFeatureFlagControllerStateMock, + } = getMessengerMock(); + + beforeEach(() => { + jest.resetAllMocks(); + calculateGasFeeTokenCostMock.mockReturnValue({ + fiat: '0', + human: '0', + raw: '100', + usd: '0', + }); + + getTokenBalanceMock.mockReturnValue('0'); + + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: { + confirmations_eip_7702: { + supportedChains: [REQUEST_MOCK.sourceChainId], + }, + }, + }); + }); + + describe('shouldUseGasStation', () => { + const RESERVE_REQUEST_MOCK = { + messenger, + nativeGasCostRaw: undefined, + request: REQUEST_MOCK, + }; + + it('returns true for an enabled supported chain', () => { + expect(shouldUseGasStation(RESERVE_REQUEST_MOCK)).toBe(true); + }); + + it('returns false when native balance already covers the gas cost', () => { + getTokenBalanceMock.mockReturnValue('100'); + + expect( + shouldUseGasStation({ + ...RESERVE_REQUEST_MOCK, + nativeGasCostRaw: '100', + }), + ).toBe(false); + }); + + it('returns true when native balance is insufficient', () => { + getTokenBalanceMock.mockReturnValue('99'); + + expect( + shouldUseGasStation({ + ...RESERVE_REQUEST_MOCK, + nativeGasCostRaw: '100', + }), + ).toBe(true); + }); + + it('returns false when the chain is explicitly disabled', () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: { + confirmations_eip_7702: { + supportedChains: [REQUEST_MOCK.sourceChainId], + }, + confirmations_pay: { + relayDisabledGasStationChains: [REQUEST_MOCK.sourceChainId], + }, + }, + }); + + expect(shouldUseGasStation(RESERVE_REQUEST_MOCK)).toBe(false); + }); + + it('returns false when the chain does not support EIP-7702', () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: {}, + }); + + expect(shouldUseGasStation(RESERVE_REQUEST_MOCK)).toBe(false); + }); + }); + + describe('resolveGasPayment', () => { + const PAYMENT_REQUEST_MOCK = { + sourceTokenAddress: REQUEST_MOCK.sourceTokenAddress, + }; + + const SPONSORED_MOCK = { + accountSupports7702: true, + request: SPONSORED_ROUTE_REQUEST_MOCK, + transaction: TRANSACTION_MOCK, + }; + + const UNSPONSORED_MOCK = { + ...SPONSORED_MOCK, + accountSupports7702: false, + }; + + it('prefers delegation over every other funding source', () => { + expect( + resolveGasPayment({ + ...PAYMENT_REQUEST_MOCK, + excludeNativeTokenForFee: true, + isDelegated: true, + isSourceGasFeeToken: true, + sponsorship: SPONSORED_MOCK, + }), + ).toStrictEqual({ mode: GasPaymentMode.Delegation }); + }); + + it('prefers sponsorship over a gas fee token', () => { + expect( + resolveGasPayment({ + ...PAYMENT_REQUEST_MOCK, + isSourceGasFeeToken: true, + sponsorship: SPONSORED_MOCK, + }), + ).toStrictEqual({ + isGasFeeSponsored: true, + mode: GasPaymentMode.Sponsored, + }); + }); + + it('charges the source token when the quote priced gas in it', () => { + expect( + resolveGasPayment({ + ...PAYMENT_REQUEST_MOCK, + isSourceGasFeeToken: true, + }), + ).toStrictEqual({ + gasFeeToken: REQUEST_MOCK.sourceTokenAddress, + mode: GasPaymentMode.GasFeeToken, + }); + }); + + it('excludes native token for fee only when the strategy opts in', () => { + expect( + resolveGasPayment({ + ...PAYMENT_REQUEST_MOCK, + excludeNativeTokenForFee: true, + isSourceGasFeeToken: true, + }), + ).toStrictEqual({ + excludeNativeTokenForFee: true, + gasFeeToken: REQUEST_MOCK.sourceTokenAddress, + mode: GasPaymentMode.GasFeeToken, + }); + }); + + it('falls back to native gas', () => { + expect(resolveGasPayment(PAYMENT_REQUEST_MOCK)).toStrictEqual({ + mode: GasPaymentMode.Native, + }); + }); + + it('keeps an explicit unsponsored flag distinct from no signal', () => { + expect( + resolveGasPayment({ + ...PAYMENT_REQUEST_MOCK, + sponsorship: UNSPONSORED_MOCK, + }), + ).toStrictEqual({ + isGasFeeSponsored: false, + mode: GasPaymentMode.Native, + }); + }); + }); + + describe('sponsorship', () => { + const SPONSORSHIP_MOCK = { + accountSupports7702: true, + request: SPONSORED_ROUTE_REQUEST_MOCK, + transaction: TRANSACTION_MOCK, + }; + + /** + * Resolve the mode for a sponsorship input. + * + * @param sponsorship - Sponsorship overrides. + * @returns The resolved gas payment. + */ + function resolve(sponsorship: Record): GasPayment { + return resolveGasPayment({ + sourceTokenAddress: REQUEST_MOCK.sourceTokenAddress, + sponsorship: { ...SPONSORSHIP_MOCK, ...sponsorship }, + }); + } + + it('sponsors a supported account on a same-chain route', () => { + expect(resolve({})).toStrictEqual({ + isGasFeeSponsored: true, + mode: GasPaymentMode.Sponsored, + }); + }); + + it('does not sponsor when the account cannot sign EIP-7702 authorizations', () => { + expect(resolve({ accountSupports7702: false })).toStrictEqual({ + isGasFeeSponsored: false, + mode: GasPaymentMode.Native, + }); + }); + + it.each(['sourceChainId', 'targetChainId'] as const)( + 'does not sponsor when %s differs from the transaction chain', + (chainIdKey) => { + expect( + resolve({ + request: { ...SPONSORED_ROUTE_REQUEST_MOCK, [chainIdKey]: '0x2' }, + }), + ).toStrictEqual({ + isGasFeeSponsored: false, + mode: GasPaymentMode.Native, + }); + }, + ); + + it('leaves the flag unset when the transaction carries no sponsorship signal', () => { + expect( + resolve({ transaction: { chainId: REQUEST_MOCK.sourceChainId } }), + ).toStrictEqual({ mode: GasPaymentMode.Native }); + }); + + it('leaves the flag unset when the strategy never sponsors', () => { + expect( + resolveGasPayment({ + sourceTokenAddress: REQUEST_MOCK.sourceTokenAddress, + }), + ).toStrictEqual({ mode: GasPaymentMode.Native }); + }); + }); + + describe('resolveGasStationCost', () => { + const RESOLVE_REQUEST_MOCK = { + firstStepData: FIRST_STEP_DATA_MOCK, + messenger, + request: REQUEST_MOCK, + totalGasEstimate: 100, + totalItemCount: 1, + }; + + it('returns the gas station cost when every gate passes', async () => { + getGasFeeTokensMock.mockResolvedValue([MATCHING_GAS_FEE_TOKEN]); + + expect(await resolveGasStationCost(RESOLVE_REQUEST_MOCK)).toStrictEqual({ + amount: { fiat: '0', human: '0', raw: '100', usd: '0' }, + isAvailable: true, + }); + }); + + it('reports availability when no fee token matches the source token', async () => { + getGasFeeTokensMock.mockResolvedValue([]); + + expect(await resolveGasStationCost(RESOLVE_REQUEST_MOCK)).toStrictEqual({ + amount: undefined, + isAvailable: true, + }); + }); + + it('ignores fee tokens that are not the source token', async () => { + getGasFeeTokensMock.mockResolvedValue([ + { ...MATCHING_GAS_FEE_TOKEN, tokenAddress: '0xdef' as Hex }, + ]); + + expect(await resolveGasStationCost(RESOLVE_REQUEST_MOCK)).toStrictEqual({ + amount: undefined, + isAvailable: true, + }); + }); + + it('scales the fee token amount across a multi-item estimate', async () => { + getGasFeeTokensMock.mockResolvedValue([MATCHING_GAS_FEE_TOKEN]); + + await resolveGasStationCost({ + ...RESOLVE_REQUEST_MOCK, + totalGasEstimate: 300, + totalItemCount: 2, + }); + + expect(calculateGasFeeTokenCostMock).toHaveBeenCalledWith( + expect.objectContaining({ + gasFeeToken: expect.objectContaining({ amount: '0x12c' }), + }), + ); + }); + + it('supports decimal simulation values in gas fee token fields', async () => { + getGasFeeTokensMock.mockResolvedValue([ + { ...MATCHING_GAS_FEE_TOKEN, amount: '300' as Hex, gas: '100' as Hex }, + ]); + + await resolveGasStationCost({ + ...RESOLVE_REQUEST_MOCK, + totalItemCount: 2, + }); + + expect(calculateGasFeeTokenCostMock).toHaveBeenCalledWith( + expect.objectContaining({ + gasFeeToken: expect.objectContaining({ amount: '0x12c' }), + }), + ); + }); + + it('defaults a missing step value to zero', async () => { + getGasFeeTokensMock.mockResolvedValue([MATCHING_GAS_FEE_TOKEN]); + + await resolveGasStationCost({ + ...RESOLVE_REQUEST_MOCK, + firstStepData: { data: '0x123' as Hex, to: '0x2' as Hex }, + }); + + expect(getGasFeeTokensMock).toHaveBeenCalledWith( + expect.objectContaining({ value: '0x0' }), + ); + }); + + it('keeps the raw fee token amount when the gas estimate is zero', async () => { + getGasFeeTokensMock.mockResolvedValue([MATCHING_GAS_FEE_TOKEN]); + + await resolveGasStationCost({ + ...RESOLVE_REQUEST_MOCK, + totalGasEstimate: 0, + totalItemCount: 2, + }); + + expect(calculateGasFeeTokenCostMock).toHaveBeenCalledWith( + expect.objectContaining({ + gasFeeToken: expect.objectContaining({ amount: '0x64' }), + }), + ); + }); + + it('keeps the raw fee token amount when the fee token reports no gas', async () => { + getGasFeeTokensMock.mockResolvedValue([ + { ...MATCHING_GAS_FEE_TOKEN, gas: '0x0' as Hex }, + ]); + + await resolveGasStationCost({ + ...RESOLVE_REQUEST_MOCK, + totalItemCount: 2, + }); + + expect(calculateGasFeeTokenCostMock).toHaveBeenCalledWith( + expect.objectContaining({ + gasFeeToken: expect.objectContaining({ amount: '0x64' }), + }), + ); + }); + + it('returns no amount when the gas fee token estimate fails', async () => { + getGasFeeTokensMock.mockRejectedValue(new Error('estimate failed')); + + expect(await resolveGasStationCost(RESOLVE_REQUEST_MOCK)).toStrictEqual({ + amount: undefined, + isAvailable: true, + }); + }); + + it('returns no amount when fiat rates cannot price the fee token', async () => { + getGasFeeTokensMock.mockResolvedValue([MATCHING_GAS_FEE_TOKEN]); + calculateGasFeeTokenCostMock.mockReturnValue(undefined); + + expect(await resolveGasStationCost(RESOLVE_REQUEST_MOCK)).toStrictEqual({ + amount: undefined, + isAvailable: true, + }); + }); + + it('simulates from the fee token account when overridden', async () => { + const PROXY_ADDRESS_MOCK = '0xdeadbeef' as Hex; + getGasFeeTokensMock.mockResolvedValue([MATCHING_GAS_FEE_TOKEN]); + + await resolveGasStationCost({ + ...RESOLVE_REQUEST_MOCK, + feeTokenAccount: PROXY_ADDRESS_MOCK, + }); + + expect(getGasFeeTokensMock).toHaveBeenCalledWith( + expect.objectContaining({ from: PROXY_ADDRESS_MOCK }), + ); + }); + + it('skips the gas station when the account does not support EIP-7702', async () => { + expect( + await resolveGasStationCost({ + ...RESOLVE_REQUEST_MOCK, + accountSupports7702: false, + }), + ).toStrictEqual({ isAvailable: false }); + + expect(getGasFeeTokensMock).not.toHaveBeenCalled(); + }); + + it('skips the gas station when native balance covers the gas cost', async () => { + getTokenBalanceMock.mockReturnValue('100'); + + expect( + await resolveGasStationCost({ + ...RESOLVE_REQUEST_MOCK, + nativeGasCostRaw: '100', + }), + ).toStrictEqual({ isAvailable: false }); + + expect(getGasFeeTokensMock).not.toHaveBeenCalled(); + }); + + it('consults the gas station when native balance is insufficient', async () => { + getTokenBalanceMock.mockReturnValue('99'); + getGasFeeTokensMock.mockResolvedValue([MATCHING_GAS_FEE_TOKEN]); + + expect( + await resolveGasStationCost({ + ...RESOLVE_REQUEST_MOCK, + nativeGasCostRaw: '100', + }), + ).toStrictEqual({ + amount: { fiat: '0', human: '0', raw: '100', usd: '0' }, + isAvailable: true, + }); + }); + + it('skips the gas station when the chain is ineligible', async () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: {}, + }); + + expect(await resolveGasStationCost(RESOLVE_REQUEST_MOCK)).toStrictEqual({ + isAvailable: false, + }); + + expect(getGasFeeTokensMock).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/transaction-pay-controller/src/utils/gas-payment.ts b/packages/transaction-pay-controller/src/utils/gas-payment.ts new file mode 100644 index 00000000000..f8626b9c414 --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/gas-payment.ts @@ -0,0 +1,517 @@ +import { toHex } from '@metamask/controller-utils'; +import type { + GasFeeToken, + TransactionMeta, +} from '@metamask/transaction-controller'; +import type { Hex } from '@metamask/utils'; +import { createModuleLogger } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; + +import { projectLogger } from '../logger.js'; +import type { + Amount, + QuoteRequest, + TransactionPayControllerMessenger, +} from '../types.js'; +import { getFeatureFlags, isEIP7702Chain } from './feature-flags.js'; +import { calculateGasFeeTokenCost } from './gas.js'; +import { getNativeToken, getTokenBalance } from './token.js'; + +const log = createModuleLogger(projectLogger, 'gas-payment'); + +export enum GasPaymentMode { + /** + * A relayer redeems a signed delegation and pays origin gas itself, so + * nothing is submitted through the `TransactionController`. + */ + Delegation = 'delegation', + + /** MetaMask sponsors origin gas through the EIP-7702 gas station hook. */ + Sponsored = 'sponsored', + + /** + * The user pays origin gas in the source token, collected by the EIP-7702 + * gas station as a gas fee token. + */ + GasFeeToken = 'gas-fee-token', + + /** The user pays origin gas in the chain's native token. */ + Native = 'native', +} + +/** + * Who pays origin gas, plus the `TransactionController` options that implement + * it. Spread the options straight into `addTransaction` or + * `addTransactionBatch`. + */ +export type GasPayment = { + /** + * Stop the `TransactionController` silently falling back to native gas when + * a gas fee token is set. Only set in {@link GasPaymentMode.GasFeeToken}. + */ + excludeNativeTokenForFee?: boolean; + + /** + * Token to collect origin gas in. Only set in + * {@link GasPaymentMode.GasFeeToken}. + */ + gasFeeToken?: Hex; + + /** + * Whether MetaMask sponsors origin gas. `true` in + * {@link GasPaymentMode.Sponsored}. Otherwise `false` when sponsorship was + * evaluated and rejected, or absent when the strategy never sponsors — the + * `TransactionController` omits the flag entirely rather than recording + * `false`, so the distinction is carried through. + */ + isGasFeeSponsored?: boolean; + + /** The resolved mode. */ + mode: GasPaymentMode; +}; + +export type GasPaymentRequest = { + /** + * Whether to set `excludeNativeTokenForFee` when a gas fee token is used. + * Opt-in per strategy since it changes how the `TransactionController` + * treats an account that also holds native balance. + */ + excludeNativeTokenForFee?: boolean; + + /** + * Whether a relayer will redeem a signed delegation and pay origin gas + * (Relay `/execute`, server `gasless`, HyperLiquid withdrawals). + */ + isDelegated?: boolean; + + /** + * Whether the quote priced origin gas in the source token. Leave `undefined` + * at quote time, before the gas station has been consulted. + */ + isSourceGasFeeToken?: boolean; + + /** Source token charged when paying with a gas fee token. */ + sourceTokenAddress: Hex; + + /** + * Inputs for the MetaMask sponsorship check. Omit for strategies that never + * sponsor, so `isGasFeeSponsored` is left unset rather than explicitly + * disabled. + */ + sponsorship?: SponsorshipRequest; +}; + +/** Origin gas priced in source-token units by the gas station. */ +export type GasStationCost = { + /** The cost, when the gas station will collect gas in the source token. */ + amount?: Amount; + + /** + * Whether the gas station was actually consulted, meaning every eligibility + * gate passed. `false` means it was skipped, so an absent `amount` says + * nothing about whether a fee token exists. + */ + isAvailable: boolean; +}; + +export type GasStationCostRequest = { + /** + * Whether the paying account can sign EIP-7702 authorizations. Omit when + * unknown; only an explicit `false` blocks the gas station. + */ + accountSupports7702?: boolean; + + /** + * Account the gas station simulates the fee token transfer from. Defaults to + * `request.from`. Predict withdrawals hold the source token in a Safe proxy, + * so the probe must target the proxy rather than the still-empty EOA. + */ + feeTokenAccount?: Hex; + + /** First origin-chain call, simulated to discover available fee tokens. */ + firstStepData: GasStationStepData; + + messenger: TransactionPayControllerMessenger; + + /** + * Native gas cost the user would otherwise pay, in raw units. The gas + * station is skipped when `request.from` already holds enough native token + * to cover it. Omit to consult the gas station regardless of balance. + */ + nativeGasCostRaw?: string; + + request: Pick; + + /** Combined gas estimate across every origin-chain call. */ + totalGasEstimate: number; + + /** Number of origin-chain calls the estimate covers. */ + totalItemCount: number; +}; + +export type GasStationStepData = { + data: Hex; + to: Hex; + value?: string; +}; + +export type SponsorshipRequest = { + /** Whether the paying account can sign EIP-7702 authorizations. */ + accountSupports7702: boolean | undefined; + + request: Pick; + + transaction: Pick; +}; + +/** + * Whether origin gas should be paid through the gas station. + * + * True only when the user cannot cover gas in native token *and* the gas + * station can actually collect it on this chain. A `false` result therefore + * means either "native balance covers it" or "the gas station is unavailable + * here" — it does not mean the gas station was unnecessary. Cheap enough to + * call before any network work, so it gates the expensive probe flow. + * + * @param request - Gas station check. + * @param request.messenger - Controller messenger. + * @param request.nativeGasCostRaw - Native gas cost the user would otherwise pay, in raw units. + * @param request.request - Quote request fields identifying the payment. + * @returns Whether the gas station should be used for origin gas. + */ +export function shouldUseGasStation({ + messenger, + nativeGasCostRaw, + request, +}: { + messenger: TransactionPayControllerMessenger; + nativeGasCostRaw: string | undefined; + request: Pick; +}): boolean { + if ( + nativeGasCostRaw !== undefined && + hasNativeBalanceFor({ messenger, nativeGasCostRaw, request }) + ) { + log('Native balance is sufficient for gas', { nativeGasCostRaw }); + return false; + } + + return canUseGasStation(messenger, request.sourceChainId); +} + +/** + * Whether the gas station can collect origin gas on a chain at all. + * + * Cheap enough to call before doing any work — it only reads feature flags. + * Use it to skip an expensive code path that would otherwise end in + * {@link resolveGasStationCost} returning nothing. + * + * @param messenger - Controller messenger. + * @param sourceChainId - Origin chain of the payment. + * @returns Whether the chain supports EIP-7702 and is not disabled. + */ +function canUseGasStation( + messenger: TransactionPayControllerMessenger, + sourceChainId: QuoteRequest['sourceChainId'], +): boolean { + const { relayDisabledGasStationChains } = getFeatureFlags(messenger); + + if (relayDisabledGasStationChains.includes(sourceChainId)) { + log('Gas station is disabled for chain', { sourceChainId }); + return false; + } + + if (!isEIP7702Chain(messenger, sourceChainId)) { + log('Gas station is unsupported on chain', { sourceChainId }); + return false; + } + + return true; +} + +/** + * Resolve who pays origin gas. + * + * Applies the {@link GasPaymentMode} priority order, so a strategy only + * declares which options are available rather than encoding the precedence + * itself. + * + * Both stages use this. At quote time, call it without `isSourceGasFeeToken` + * to find out whether anyone else covers gas before spending effort + * estimating it. At submit time, call it with everything to get the + * `TransactionController` options. + * + * @param request - Payment request. + * @param request.excludeNativeTokenForFee - Whether to block native gas fallback alongside a gas fee token. + * @param request.isDelegated - Whether a relayer redeems a delegation and pays origin gas. + * @param request.isSourceGasFeeToken - Whether the quote priced origin gas in the source token. + * @param request.sourceTokenAddress - Source token charged when paying with a gas fee token. + * @param request.sponsorship - Inputs for the MetaMask sponsorship check. + * @returns The resolved mode and its `TransactionController` options. + */ +export function resolveGasPayment({ + excludeNativeTokenForFee, + isDelegated, + isSourceGasFeeToken, + sourceTokenAddress, + sponsorship, +}: GasPaymentRequest): GasPayment { + if (isDelegated) { + return { mode: GasPaymentMode.Delegation }; + } + + const isSponsored = isSponsoredRoute(sponsorship); + + if (isSponsored) { + return { isGasFeeSponsored: true, mode: GasPaymentMode.Sponsored }; + } + + // An absent flag and an explicit `false` are not equivalent to the + // `TransactionController`, so only set it when sponsorship was evaluated. + const unsponsored = + isSponsored === undefined ? {} : { isGasFeeSponsored: isSponsored }; + + if (isSourceGasFeeToken) { + return { + ...unsponsored, + ...(excludeNativeTokenForFee ? { excludeNativeTokenForFee: true } : {}), + gasFeeToken: sourceTokenAddress, + mode: GasPaymentMode.GasFeeToken, + }; + } + + return { ...unsponsored, mode: GasPaymentMode.Native }; +} + +/** + * Price origin gas in source-token units, applying every gas station + * eligibility gate first. + * + * The gas station is skipped when the account cannot sign EIP-7702 + * authorizations, when the user already holds enough native token, or when the + * chain is unsupported or disabled. Callers distinguish "gas station skipped" + * from "gas station found no fee token" via {@link GasStationCost.isAvailable}. + * + * @param request - Cost request. + * @param request.accountSupports7702 - Whether the account can sign EIP-7702 authorizations. + * @param request.feeTokenAccount - Account the fee token transfer is simulated from. + * @param request.firstStepData - First origin-chain call to simulate. + * @param request.messenger - Controller messenger. + * @param request.nativeGasCostRaw - Native gas cost the user would otherwise pay, in raw units. + * @param request.request - Quote request fields identifying the payment. + * @param request.totalGasEstimate - Combined gas estimate across every origin-chain call. + * @param request.totalItemCount - Number of origin-chain calls the estimate covers. + * @returns The resolved gas station cost. + */ +export async function resolveGasStationCost({ + accountSupports7702, + feeTokenAccount, + firstStepData, + messenger, + nativeGasCostRaw, + request, + totalGasEstimate, + totalItemCount, +}: GasStationCostRequest): Promise { + const { from, sourceChainId, sourceTokenAddress } = request; + + if (accountSupports7702 === false) { + log('Skipping gas station as account does not support EIP-7702', { from }); + return { isAvailable: false }; + } + + if ( + nativeGasCostRaw !== undefined && + hasNativeBalanceFor({ messenger, nativeGasCostRaw, request }) + ) { + log('Skipping gas station as native balance covers gas', { + nativeGasCostRaw, + sourceChainId, + }); + return { isAvailable: false }; + } + + if (!canUseGasStation(messenger, sourceChainId)) { + return { isAvailable: false }; + } + + const amount = await getGasStationCostInSourceToken({ + feeTokenAccount: feeTokenAccount ?? from, + firstStepData, + messenger, + sourceChainId, + sourceTokenAddress, + totalGasEstimate, + totalItemCount, + }); + + return { amount, isAvailable: true }; +} + +async function getGasStationCostInSourceToken({ + feeTokenAccount, + firstStepData, + messenger, + sourceChainId, + sourceTokenAddress, + totalGasEstimate, + totalItemCount, +}: { + feeTokenAccount: Hex; + firstStepData: GasStationStepData; + messenger: TransactionPayControllerMessenger; + sourceChainId: Hex; + sourceTokenAddress: Hex; + totalGasEstimate: number; + totalItemCount: number; +}): Promise { + const { data, to, value } = firstStepData; + + let gasFeeTokens: GasFeeToken[] | undefined; + + try { + gasFeeTokens = await messenger.call( + 'TransactionController:getGasFeeTokens', + { + chainId: sourceChainId, + data, + from: feeTokenAccount, + to, + value: toHex(value ?? '0'), + }, + ); + } catch (error) { + log('Failed to estimate gas fee tokens', { + error, + sourceChainId, + }); + return undefined; + } + + const gasFeeToken = gasFeeTokens?.find( + (singleGasFeeToken) => + singleGasFeeToken.tokenAddress.toLowerCase() === + sourceTokenAddress.toLowerCase(), + ); + + if (!gasFeeToken) { + log('No matching source token in gas fee token estimate', { + sourceTokenAddress, + sourceChainId, + }); + return undefined; + } + + const gasFeeTokenWithNormalizedAmount = { + ...gasFeeToken, + amount: toHex( + getNormalizedGasFeeTokenAmount({ + gasFeeToken, + totalGasEstimate, + totalItemCount, + }), + ), + }; + + const gasFeeTokenCost = calculateGasFeeTokenCost({ + chainId: sourceChainId, + gasFeeToken: gasFeeTokenWithNormalizedAmount, + messenger, + }); + + if (!gasFeeTokenCost) { + log('Unable to calculate gas fee token cost using fiat rates', { + sourceTokenAddress, + sourceChainId, + }); + return undefined; + } + + log('Estimated gas station cost for source token', { + amount: gasFeeTokenCost.raw, + sourceTokenAddress, + sourceChainId, + }); + + return gasFeeTokenCost; +} + +function getNormalizedGasFeeTokenAmount({ + gasFeeToken, + totalGasEstimate, + totalItemCount, +}: { + gasFeeToken: GasFeeToken; + totalGasEstimate: number; + totalItemCount: number; +}): string { + let amount = new BigNumber(gasFeeToken.amount); + + if (totalItemCount > 1) { + const gas = new BigNumber(gasFeeToken.gas); + const gasFeeAmount = new BigNumber(gasFeeToken.amount); + + if (totalGasEstimate > 0 && gas.isGreaterThan(0)) { + const gasRate = gasFeeAmount.dividedBy(gas); + amount = gasRate.multipliedBy(totalGasEstimate); + } + } + + return amount.integerValue(BigNumber.ROUND_CEIL).toFixed(0); +} + +function hasNativeBalanceFor({ + messenger, + nativeGasCostRaw, + request, +}: { + messenger: TransactionPayControllerMessenger; + nativeGasCostRaw: string; + request: Pick; +}): boolean { + const { from, sourceChainId } = request; + + const nativeBalance = getTokenBalance( + messenger, + from, + sourceChainId, + getNativeToken(sourceChainId), + ); + + return new BigNumber(nativeBalance).isGreaterThanOrEqualTo(nativeGasCostRaw); +} + +/** + * Whether MetaMask sponsors origin gas for a route. + * + * Sponsorship runs through the EIP-7702 gas station hook, so it requires an + * account that can sign authorizations and a payment that never leaves the + * transaction's own chain. + * + * Returns `undefined` when there is nothing to evaluate, which callers keep + * distinct from an explicit `false`. + * + * @param sponsorship - Sponsorship inputs, or `undefined` when the strategy never sponsors. + * @returns Whether the route is sponsored. + */ +function isSponsoredRoute( + sponsorship: SponsorshipRequest | undefined, +): boolean | undefined { + if (!sponsorship) { + return undefined; + } + + const { accountSupports7702, request, transaction } = sponsorship; + const { isGasFeeSponsored } = transaction; + + if (isGasFeeSponsored === undefined) { + return undefined; + } + + return ( + isGasFeeSponsored && + request.sourceChainId === transaction.chainId && + request.targetChainId === transaction.chainId && + Boolean(accountSupports7702) + ); +} diff --git a/packages/transaction-pay-controller/src/utils/gas-station.test.ts b/packages/transaction-pay-controller/src/utils/gas-station.test.ts deleted file mode 100644 index ffea301fac8..00000000000 --- a/packages/transaction-pay-controller/src/utils/gas-station.test.ts +++ /dev/null @@ -1,165 +0,0 @@ -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.js'; -import { getMessengerMock } from '../tests/messenger-mock.js'; -import { - getGasStationEligibility, - getGasStationCostInSourceTokenRaw, -} from './gas-station.js'; -import { calculateGasFeeTokenCost } from './gas.js'; - -jest.mock('./gas'); - -const REQUEST_MOCK = { - from: '0x1234567890123456789012345678901234567891' as Hex, - sourceChainId: '0x1' as Hex, - sourceTokenAddress: '0xabc' as Hex, -}; - -const FIRST_STEP_DATA_MOCK = { - data: '0x123' as Hex, - to: '0x2' as Hex, - value: '0x0', -}; - -const MATCHING_GAS_FEE_TOKEN: GasFeeToken = { - amount: '0x64', - balance: '0x0', - decimals: 6, - gas: '0x64', - maxFeePerGas: '0x1', - maxPriorityFeePerGas: '0x1', - rateWei: '0x1', - recipient: REQUEST_MOCK.from, - symbol: 'USDC', - tokenAddress: REQUEST_MOCK.sourceTokenAddress, -}; - -describe('gas-station', () => { - const calculateGasFeeTokenCostMock = jest.mocked(calculateGasFeeTokenCost); - - const { - messenger, - getGasFeeTokensMock, - getRemoteFeatureFlagControllerStateMock, - } = getMessengerMock(); - - beforeEach(() => { - jest.resetAllMocks(); - calculateGasFeeTokenCostMock.mockReturnValue({ - fiat: '0', - human: '0', - raw: '100', - usd: '0', - }); - - getRemoteFeatureFlagControllerStateMock.mockReturnValue({ - ...getDefaultRemoteFeatureFlagControllerState(), - remoteFeatureFlags: { - confirmations_eip_7702: { - supportedChains: [REQUEST_MOCK.sourceChainId], - }, - }, - }); - }); - - it('returns eligible gas-station status for enabled supported chain', () => { - const result = getGasStationEligibility( - messenger, - REQUEST_MOCK.sourceChainId, - ); - - expect(result).toStrictEqual({ - chainSupportsGasStation: true, - isDisabledChain: false, - isEligible: true, - }); - }); - - it('returns ineligible status when chain is disabled', () => { - getRemoteFeatureFlagControllerStateMock.mockReturnValue({ - ...getDefaultRemoteFeatureFlagControllerState(), - remoteFeatureFlags: { - confirmations_eip_7702: { - supportedChains: [REQUEST_MOCK.sourceChainId], - }, - confirmations_pay: { - relayDisabledGasStationChains: [REQUEST_MOCK.sourceChainId], - }, - }, - }); - - const result = getGasStationEligibility( - messenger, - REQUEST_MOCK.sourceChainId, - ); - - expect(result).toStrictEqual({ - chainSupportsGasStation: true, - isDisabledChain: true, - isEligible: false, - }); - }); - - it('returns undefined when no matching gas fee token exists', async () => { - getGasFeeTokensMock.mockResolvedValue([ - { - ...MATCHING_GAS_FEE_TOKEN, - tokenAddress: '0xdef' as Hex, - }, - ]); - - const result = await getGasStationCostInSourceTokenRaw({ - firstStepData: FIRST_STEP_DATA_MOCK, - messenger, - request: REQUEST_MOCK, - totalGasEstimate: 100, - totalItemCount: 1, - }); - - expect(result).toBeUndefined(); - }); - - it('normalizes amount for multi-item gas estimation', async () => { - getGasFeeTokensMock.mockResolvedValue([MATCHING_GAS_FEE_TOKEN]); - - await getGasStationCostInSourceTokenRaw({ - firstStepData: FIRST_STEP_DATA_MOCK, - messenger, - request: REQUEST_MOCK, - totalGasEstimate: 300, - totalItemCount: 2, - }); - - expect(calculateGasFeeTokenCostMock).toHaveBeenCalledWith( - expect.objectContaining({ - gasFeeToken: expect.objectContaining({ amount: '0x12c' }), - }), - ); - }); - - it('supports decimal simulation values in gas fee token fields', async () => { - getGasFeeTokensMock.mockResolvedValue([ - { - ...MATCHING_GAS_FEE_TOKEN, - amount: '300' as Hex, - gas: '100' as Hex, - }, - ]); - - await getGasStationCostInSourceTokenRaw({ - firstStepData: FIRST_STEP_DATA_MOCK, - messenger, - request: REQUEST_MOCK, - totalGasEstimate: 100, - totalItemCount: 2, - }); - - expect(calculateGasFeeTokenCostMock).toHaveBeenCalledWith( - expect.objectContaining({ - gasFeeToken: expect.objectContaining({ amount: '0x12c' }), - }), - ); - }); -}); diff --git a/packages/transaction-pay-controller/src/utils/gas-station.ts b/packages/transaction-pay-controller/src/utils/gas-station.ts deleted file mode 100644 index e495c7762a1..00000000000 --- a/packages/transaction-pay-controller/src/utils/gas-station.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { toHex } from '@metamask/controller-utils'; -import type { GasFeeToken } from '@metamask/transaction-controller'; -import type { Hex } from '@metamask/utils'; -import { createModuleLogger } from '@metamask/utils'; -import { BigNumber } from 'bignumber.js'; - -import { projectLogger } from '../logger.js'; -import type { - Amount, - QuoteRequest, - TransactionPayControllerMessenger, -} from '../types.js'; -import { getFeatureFlags, isEIP7702Chain } from './feature-flags.js'; -import { calculateGasFeeTokenCost } from './gas.js'; - -const log = createModuleLogger(projectLogger, 'gas-station'); - -type GasStationCostParams = { - firstStepData: { - data: Hex; - to: Hex; - value?: string; - }; - messenger: TransactionPayControllerMessenger; - request: Pick; - totalGasEstimate: number; - totalItemCount: number; -}; - -export type GasStationEligibility = { - chainSupportsGasStation: boolean; - isDisabledChain: boolean; - isEligible: boolean; -}; - -export function getGasStationEligibility( - messenger: TransactionPayControllerMessenger, - sourceChainId: QuoteRequest['sourceChainId'], -): GasStationEligibility { - const { relayDisabledGasStationChains } = getFeatureFlags(messenger); - const chainSupportsGasStation = isEIP7702Chain(messenger, sourceChainId); - - const isDisabledChain = relayDisabledGasStationChains.includes(sourceChainId); - - return { - chainSupportsGasStation, - isDisabledChain, - isEligible: !isDisabledChain && chainSupportsGasStation, - }; -} - -export async function getGasStationCostInSourceTokenRaw({ - firstStepData, - messenger, - request, - totalGasEstimate, - totalItemCount, -}: GasStationCostParams): Promise { - const { data, to, value } = firstStepData; - const { from, sourceChainId, sourceTokenAddress } = request; - - let gasFeeTokens: GasFeeToken[] | undefined; - - try { - gasFeeTokens = await messenger.call( - 'TransactionController:getGasFeeTokens', - { - chainId: sourceChainId, - data, - from, - to, - value: toHex(value ?? '0'), - }, - ); - } catch (error) { - log('Failed to estimate gas fee tokens', { - error, - sourceChainId, - }); - return undefined; - } - - const gasFeeToken = gasFeeTokens?.find( - (singleGasFeeToken) => - singleGasFeeToken.tokenAddress.toLowerCase() === - sourceTokenAddress.toLowerCase(), - ); - - if (!gasFeeToken) { - log('No matching source token in gas fee token estimate', { - sourceTokenAddress, - sourceChainId, - }); - return undefined; - } - - const gasFeeTokenWithNormalizedAmount = { - ...gasFeeToken, - amount: toHex( - getNormalizedGasFeeTokenAmount({ - gasFeeToken, - totalGasEstimate, - totalItemCount, - }), - ), - }; - - const gasFeeTokenCost = calculateGasFeeTokenCost({ - chainId: sourceChainId, - gasFeeToken: gasFeeTokenWithNormalizedAmount, - messenger, - }); - - if (!gasFeeTokenCost) { - log('Unable to calculate gas fee token cost using fiat rates', { - sourceTokenAddress, - sourceChainId, - }); - return undefined; - } - - log('Estimated gas station cost for source token', { - amount: gasFeeTokenCost.raw, - sourceTokenAddress, - sourceChainId, - }); - - return gasFeeTokenCost; -} - -function getNormalizedGasFeeTokenAmount({ - gasFeeToken, - totalGasEstimate, - totalItemCount, -}: { - gasFeeToken: GasFeeToken; - totalGasEstimate: number; - totalItemCount: number; -}): string { - let amount = new BigNumber(gasFeeToken.amount); - - if (totalItemCount > 1) { - const gas = new BigNumber(gasFeeToken.gas); - const gasFeeAmount = new BigNumber(gasFeeToken.amount); - - if (totalGasEstimate > 0 && gas.isGreaterThan(0)) { - const gasRate = gasFeeAmount.dividedBy(gas); - amount = gasRate.multipliedBy(totalGasEstimate); - } - } - - return amount.integerValue(BigNumber.ROUND_CEIL).toFixed(0); -} From 184e17018b93e384dd662db61e61bcd8ef76956a Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Wed, 23 Sep 2026 15:46:14 +0100 Subject: [PATCH 2/9] refactor(transaction-pay-controller): narrow gas payment surface and route Relay delegation through the resolver Fold the Relay execute and HyperLiquid withdrawal branches into `resolveGasPayment` via `isDelegated`, so "a relayer pays origin gas" is decided in one place rather than being hand-rolled per flow. The module already documented HyperLiquid withdrawals as a delegation case, so leaving that branch outside the resolver contradicted its stated contract. Both branches previously returned identical values and the resolver short-circuits on `isDelegated` before evaluating sponsorship, so the precedence and results are unchanged; the two log messages collapse into one carrying the flow as structured fields. Also stop exporting `GasPaymentRequest`, `GasStationCost`, `GasStationCostRequest`, `GasStationStepData` and `SponsorshipRequest`, none of which are referenced outside the module. --- .../src/strategy/relay/relay-quotes.ts | 46 +++++++++---------- .../src/utils/gas-payment.ts | 10 ++-- 2 files changed, 27 insertions(+), 29 deletions(-) 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 5396d04e8e3..109a875e516 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -892,8 +892,8 @@ function getFiatRates( * transaction's params so that gas estimation and gas-fee-token logic handle * both transactions together. * - * When the execute flow is active (indicated by `quote.metamask.isExecute`), - * network fees are zeroed because the relayer covers them. + * Network fees are zeroed whenever the user does not pay origin gas, either + * because a relayer covers it or because MetaMask sponsors it. * * @param quote - Relay quote. * @param messenger - Controller messenger. @@ -917,21 +917,28 @@ async function calculateSourceNetworkCost( > { const { from, sourceChainId, sourceTokenAddress } = request; - if (quote.metamask?.isExecute) { - log('Zeroing network fees for execute flow'); + // Neither flow bills origin gas to the user: the execute flow has a relayer + // redeem a signed delegation, and a HyperLiquid withdrawal's "deposit" step + // is an off-chain HL sendAsset signature rather than an on-chain + // transaction. + const isExecuteFlow = Boolean(quote.metamask?.isExecute); + const isHyperliquidWithdrawal = Boolean(request.isHyperliquidSource); - return { - estimate: ZERO_AMOUNT, - max: ZERO_AMOUNT, - gasLimits: [], - is7702: false, - }; - } + const gasPayment = resolveGasPayment({ + isDelegated: isExecuteFlow || isHyperliquidWithdrawal, + sourceTokenAddress, + sponsorship: { + accountSupports7702, + request, + transaction, + }, + }); - // HyperLiquid withdrawals are gasless -- the "deposit" step is an HL - // sendAsset (off-chain signature), not an on-chain transaction. - if (request.isHyperliquidSource) { - log('Zeroing network fees for HyperLiquid withdrawal (gasless)'); + if (gasPayment.mode === GasPaymentMode.Delegation) { + log('Zeroing network fees as the user does not pay origin gas', { + isExecuteFlow, + isHyperliquidWithdrawal, + }); return { estimate: ZERO_AMOUNT, @@ -941,15 +948,6 @@ async function calculateSourceNetworkCost( }; } - const gasPayment = resolveGasPayment({ - sourceTokenAddress, - sponsorship: { - accountSupports7702, - request, - transaction, - }, - }); - if (gasPayment.mode === GasPaymentMode.Sponsored) { log('Zeroing source network fees for sponsored same-chain Relay route'); diff --git a/packages/transaction-pay-controller/src/utils/gas-payment.ts b/packages/transaction-pay-controller/src/utils/gas-payment.ts index f8626b9c414..0f59a5971ff 100644 --- a/packages/transaction-pay-controller/src/utils/gas-payment.ts +++ b/packages/transaction-pay-controller/src/utils/gas-payment.ts @@ -70,7 +70,7 @@ export type GasPayment = { mode: GasPaymentMode; }; -export type GasPaymentRequest = { +type GasPaymentRequest = { /** * Whether to set `excludeNativeTokenForFee` when a gas fee token is used. * Opt-in per strategy since it changes how the `TransactionController` @@ -102,7 +102,7 @@ export type GasPaymentRequest = { }; /** Origin gas priced in source-token units by the gas station. */ -export type GasStationCost = { +type GasStationCost = { /** The cost, when the gas station will collect gas in the source token. */ amount?: Amount; @@ -114,7 +114,7 @@ export type GasStationCost = { isAvailable: boolean; }; -export type GasStationCostRequest = { +type GasStationCostRequest = { /** * Whether the paying account can sign EIP-7702 authorizations. Omit when * unknown; only an explicit `false` blocks the gas station. @@ -149,13 +149,13 @@ export type GasStationCostRequest = { totalItemCount: number; }; -export type GasStationStepData = { +type GasStationStepData = { data: Hex; to: Hex; value?: string; }; -export type SponsorshipRequest = { +type SponsorshipRequest = { /** Whether the paying account can sign EIP-7702 authorizations. */ accountSupports7702: boolean | undefined; From 80cda6d2b5712ee04d3beccbdab093ab9eb02da2 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Mon, 28 Sep 2026 10:18:52 +0100 Subject: [PATCH 3/9] fix(transaction-pay-controller): always exclude native token for fee when origin gas uses the source token Only the across strategy set `excludeNativeTokenForFee`, so for the relay and server strategies the `TransactionController` re-checked the native balance at publish time and silently dropped the gas fee token, charging the user in native token instead of the quoted source token. Resolve the flag centrally in `resolveGasPayment` and propagate it from every strategy that submits via the `TransactionController`, so the fee shown in the quote is the fee the user is charged. Also route the server strategy's source network cost through the shared resolver so sponsored same-chain routes zero their fees via the gas station hook, and add `logGasPaymentOutcome` to surface cases where the gas fee token is still dropped before publish. --- .../transaction-pay-controller/CHANGELOG.md | 2 + .../src/strategy/across/across-submit.ts | 12 +- .../src/strategy/relay/relay-submit.test.ts | 2 + .../src/strategy/relay/relay-submit.ts | 14 +- .../src/strategy/server/server-quotes.test.ts | 134 ++++++++++++- .../src/strategy/server/server-quotes.ts | 39 +++- .../src/strategy/server/server-submit.test.ts | 135 ++++++++++++++ .../src/strategy/server/server-submit.ts | 23 ++- .../src/utils/gas-payment.test.ts | 84 ++++++++- .../src/utils/gas-payment.ts | 176 +++++++++++++++--- 10 files changed, 570 insertions(+), 51 deletions(-) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index f01661585a8..a88b95cd364 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -69,6 +69,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Always set `excludeNativeTokenForFee` when origin gas is priced in the source token, so the fee shown in the quote is the fee the user is charged ([#10404](https://github.com/MetaMask/core/pull/10404)) + - Previously only the across strategy opted in, so for the relay and server strategies the `TransactionController` re-checked the native balance at publish time and silently dropped the gas fee token, billing the user in native token instead. - Populate `TransactionPayQuote.targetAmount` on server pay strategy quotes, which previously always reported zero ([#10342](https://github.com/MetaMask/core/pull/10342)) - The fiat and USD values are now derived from the quote's output amount and the target token fiat rate, matching the relay strategy. They remain zero only when no fiat rate is available for the target token. - Fix single-step server pay strategy quotes failing to submit with an invalid transaction envelope type error ([#10342](https://github.com/MetaMask/core/pull/10342)) 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 2aad0a63343..e0b301ae099 100644 --- a/packages/transaction-pay-controller/src/strategy/across/across-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/across/across-submit.ts @@ -24,7 +24,11 @@ import type { import { accountSupports7702 } from '../../utils/7702.js'; import { getPayStrategiesConfig } from '../../utils/feature-flags.js'; import { getGasBuffer } from '../../utils/feature-flags.js'; -import { GasPaymentMode, resolveGasPayment } from '../../utils/gas-payment.js'; +import { + GasPaymentMode, + logGasPaymentOutcome, + resolveGasPayment, +} from '../../utils/gas-payment.js'; import { getNetworkClientId } from '../../utils/provider.js'; import { collectTransactionIds, @@ -134,7 +138,6 @@ async function submitTransactions( // Across has no relayer-paid delegation flow and is never sponsored, so the // only lift available is the gas station collecting gas in the source token. const gasPayment = resolveGasPayment({ - excludeNativeTokenForFee: true, isSourceGasFeeToken: quote.fees.isSourceGasFeeToken, sourceTokenAddress, }); @@ -322,6 +325,11 @@ async function submitTransactions( transactionIds.map((txId) => waitForTransactionConfirmed(txId, messenger)), ); + logGasPaymentOutcome( + gasPayment, + transactionIds.map((txId) => getTransaction(txId, messenger)), + ); + const hash = transactionIds.length ? getTransaction(transactionIds.slice(-1)[0], messenger)?.hash : undefined; 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 9e4100d4648..2a56c504398 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 @@ -468,6 +468,7 @@ describe('Relay Submit Utils', () => { expect(addTransactionMock).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ + excludeNativeTokenForFee: true, gasFeeToken: TOKEN_ADDRESS_MOCK, }), ); @@ -765,6 +766,7 @@ describe('Relay Submit Utils', () => { expect(addTransactionBatchMock).toHaveBeenCalledTimes(1); expect(addTransactionBatchMock).toHaveBeenCalledWith( expect.objectContaining({ + excludeNativeTokenForFee: true, gasFeeToken: TOKEN_ADDRESS_MOCK, }), ); 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 4a07a2f9aba..f87d49b50dc 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts @@ -26,7 +26,10 @@ import { getRelayPollingTimeout, } from '../../utils/feature-flags.js'; import type { GasPayment } from '../../utils/gas-payment.js'; -import { resolveGasPayment } from '../../utils/gas-payment.js'; +import { + logGasPaymentOutcome, + resolveGasPayment, +} from '../../utils/gas-payment.js'; import { submitMoneyAccountVaultDeposit } from '../../utils/ma-vault-deposit.js'; import { getNetworkClientId } from '../../utils/provider.js'; import { @@ -893,7 +896,7 @@ async function submitViaTransactionController( }, }); - const { gasFeeToken } = gasPayment; + const { excludeNativeTokenForFee, gasFeeToken } = gasPayment; log('Submitting transactions', { isPostQuote, @@ -944,6 +947,7 @@ async function submitViaTransactionController( 'TransactionController:addTransaction', transactionParams, { + excludeNativeTokenForFee, gasFeeToken, networkClientId, origin: ORIGIN_METAMASK, @@ -987,6 +991,11 @@ async function submitViaTransactionController( log('All transactions confirmed', transactionIds); + logGasPaymentOutcome( + gasPayment, + transactionIds.map((txId) => getTransaction(txId, messenger)), + ); + const hash = getTransaction(transactionIds.slice(-1)[0], messenger)?.hash; if (!hash) { @@ -1037,6 +1046,7 @@ function buildRelayTransactionBatchRequest({ disable7702: !gasLimit7702, disableHook: Boolean(gasLimit7702), disableSequential: Boolean(gasLimit7702), + excludeNativeTokenForFee: gasPayment.excludeNativeTokenForFee, gasFeeToken: gasPayment.gasFeeToken, gasLimit7702, networkClientId, diff --git a/packages/transaction-pay-controller/src/strategy/server/server-quotes.test.ts b/packages/transaction-pay-controller/src/strategy/server/server-quotes.test.ts index 6ef839b9452..f467d8555b1 100644 --- a/packages/transaction-pay-controller/src/strategy/server/server-quotes.test.ts +++ b/packages/transaction-pay-controller/src/strategy/server/server-quotes.test.ts @@ -36,7 +36,13 @@ jest.mock('../../utils/feature-flags', () => ({ isEIP7702Chain: jest.fn(), })); jest.mock('./server-api'); -jest.mock('../../utils/gas-payment'); +// `resolveGasPayment` is a pure resolver, so keep the real implementation and +// only stub the gas station lookup that performs network work. This lets the +// sponsorship and gas-fee-token modes be exercised for real. +jest.mock('../../utils/gas-payment', () => ({ + ...jest.requireActual('../../utils/gas-payment'), + resolveGasStationCost: jest.fn(), +})); jest.mock('../../utils/gas'); jest.mock('../../utils/quote-gas'); jest.mock('../../utils/token'); @@ -107,6 +113,14 @@ const REJECTED_RESULT_MOCK = { provider: ServerProviderName.Relay, }; +const CONTRACT_CALL_TRANSACTION_MOCK = { + nestedTransactions: [ + { data: '0x1234', to: '0xcontract' }, + { data: '0x5678', to: '0xcontract' }, + ], + txParams: {}, +} as TransactionMeta; + const DELEGATION_RESULT_MOCK = { authorizationList: [ { @@ -166,7 +180,9 @@ describe('server-quotes', () => { totalGasEstimate: '0x5208', totalGasLimit: '0x7530', } as never); - jest.mocked(resolveGasStationCost).mockResolvedValue({ isAvailable: false }); + jest + .mocked(resolveGasStationCost) + .mockResolvedValue({ isAvailable: false }); }); it('maps transactions without bundled calls to EXACT_INPUT quote requests', async () => { @@ -286,6 +302,26 @@ describe('server-quotes', () => { ); }); + it('omits authorizationList when the delegation does not carry one', async () => { + getDelegationTransactionMock.mockResolvedValue({ + ...DELEGATION_RESULT_MOCK, + authorizationList: [], + }); + + await getServerQuotes({ + accountSupports7702: true, + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: COMPLEX_TRANSACTION_META_MOCK, + }); + + expect(fetchServerQuoteMock).toHaveBeenCalledWith( + messenger, + expect.not.objectContaining({ authorizationList: expect.anything() }), + undefined, + ); + }); + it('does not build delegation calls for Hypercore transactions', async () => { await getServerQuotes({ accountSupports7702: true, @@ -550,6 +586,100 @@ describe('server-quotes', () => { }); }); + describe('sponsored same-chain route', () => { + const SPONSORED_TRANSACTION_MOCK = { + chainId: '0x1', + isGasFeeSponsored: true, + txParams: {}, + } as TransactionMeta; + + // Sponsorship only applies when the quote never leaves the parent + // transaction's chain. + const SAME_CHAIN_REQUEST_MOCK: QuoteRequest = { + ...QUOTE_REQUEST_MOCK, + sourceChainId: '0x1', + targetChainId: '0x1', + }; + + it('zeroes source network fees', async () => { + const result = await getServerQuotes({ + accountSupports7702: true, + messenger, + requests: [SAME_CHAIN_REQUEST_MOCK], + transaction: SPONSORED_TRANSACTION_MOCK, + }); + + expect(result[0].fees.sourceNetwork.estimate).toStrictEqual( + expect.objectContaining({ raw: '0' }), + ); + expect(result[0].fees.sourceNetwork.max).toStrictEqual( + expect.objectContaining({ raw: '0' }), + ); + }); + + it('reports a zero gas limit in 7702 mode', async () => { + const result = await getServerQuotes({ + accountSupports7702: true, + messenger, + requests: [SAME_CHAIN_REQUEST_MOCK], + transaction: SPONSORED_TRANSACTION_MOCK, + }); + + // Sponsored transactions go through the EIP-7702 gas station hook, so + // no user-paid gas is required. + expect(result[0].original.client.gasLimits).toStrictEqual([0]); + expect(result[0].original.client.is7702).toBe(true); + }); + + it('skips gas estimation entirely', async () => { + await getServerQuotes({ + accountSupports7702: true, + messenger, + requests: [SAME_CHAIN_REQUEST_MOCK], + transaction: SPONSORED_TRANSACTION_MOCK, + }); + + expect(jest.mocked(estimateQuoteGasLimits)).not.toHaveBeenCalled(); + expect(jest.mocked(resolveGasStationCost)).not.toHaveBeenCalled(); + }); + + it('does not zero fees when the route is cross-chain', async () => { + const result = await getServerQuotes({ + accountSupports7702: true, + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: SPONSORED_TRANSACTION_MOCK, + }); + + expect(result[0].fees.sourceNetwork.estimate).toBe(GAS_ESTIMATE_MOCK); + }); + + it('does not zero fees when the account cannot sign 7702 authorizations', async () => { + const result = await getServerQuotes({ + accountSupports7702: false, + messenger, + requests: [SAME_CHAIN_REQUEST_MOCK], + transaction: SPONSORED_TRANSACTION_MOCK, + }); + + expect(result[0].fees.sourceNetwork.estimate).toBe(GAS_ESTIMATE_MOCK); + }); + + it('does not zero fees when the parent transaction is not sponsored', async () => { + const result = await getServerQuotes({ + accountSupports7702: true, + messenger, + requests: [SAME_CHAIN_REQUEST_MOCK], + transaction: { + ...SPONSORED_TRANSACTION_MOCK, + isGasFeeSponsored: false, + } as TransactionMeta, + }); + + expect(result[0].fees.sourceNetwork.estimate).toBe(GAS_ESTIMATE_MOCK); + }); + }); + it('returns gas cost estimate and max when native balance is sufficient', async () => { jest.mocked(getTokenBalance).mockReturnValue('9999999999999999999999'); diff --git a/packages/transaction-pay-controller/src/strategy/server/server-quotes.ts b/packages/transaction-pay-controller/src/strategy/server/server-quotes.ts index 75f04d6d295..84d27e67ae4 100644 --- a/packages/transaction-pay-controller/src/strategy/server/server-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/server/server-quotes.ts @@ -27,7 +27,11 @@ import { getSlippage, isEIP7702Chain, } from '../../utils/feature-flags.js'; -import { resolveGasStationCost } from '../../utils/gas-payment.js'; +import { + GasPaymentMode, + resolveGasPayment, + resolveGasStationCost, +} from '../../utils/gas-payment.js'; import { calculateGasCost, getGasFee } from '../../utils/gas.js'; import { estimateQuoteGasLimits } from '../../utils/quote-gas.js'; import type { QuoteGasTransaction } from '../../utils/quote-gas.js'; @@ -135,6 +139,8 @@ async function getQuotesForRequest( quoteRequest, messenger, body.tradeType === TradeType.ExactInput, + transaction, + accountSupports7702, ), ), ); @@ -341,16 +347,20 @@ async function normalizeQuote( quoteRequest: QuoteRequest, messenger: TransactionPayControllerMessenger, isInputBased: boolean, + transaction: TransactionMeta, + accountSupports7702: boolean, ): Promise> { const { quote } = result; const { gasless } = quote; const transactionSteps = quote.steps.filter(isTransactionStep); const isSignatureOnly = transactionSteps.length === 0; const sourceNetwork = await calculateSourceNetworkCost({ + accountSupports7702, gasless: gasless || isSignatureOnly, messenger, quoteRequest, steps: transactionSteps, + transaction, }); const sourceFiatRate = getTokenFiatRate( @@ -443,15 +453,19 @@ async function normalizeQuote( } async function calculateSourceNetworkCost({ + accountSupports7702, gasless, messenger, quoteRequest, steps, + transaction, }: { + accountSupports7702: boolean | undefined; gasless: boolean; messenger: TransactionPayControllerMessenger; quoteRequest: QuoteRequest; steps: ServerTransactionStep[]; + transaction: TransactionMeta; }): Promise { const noFees = { estimate: ZERO_AMOUNT, @@ -462,12 +476,31 @@ async function calculateSourceNetworkCost({ maxPriorityFeePerGas: undefined, }; - if (gasless) { + const { from, sourceChainId, sourceTokenAddress } = quoteRequest; + + const gasPayment = resolveGasPayment({ + isDelegated: gasless, + sourceTokenAddress, + sponsorship: { + accountSupports7702, + request: quoteRequest, + transaction, + }, + }); + + if (gasPayment.mode === GasPaymentMode.Delegation) { log('Zeroing source network fees for gasless quote'); return noFees; } - const { from, sourceChainId, sourceTokenAddress } = quoteRequest; + if (gasPayment.mode === GasPaymentMode.Sponsored) { + log('Zeroing source network fees for sponsored same-chain server route'); + + // Gas limit is zero as sponsored transactions go through the EIP-7702 + // gas station hook and do not require user-paid gas. + return { ...noFees, gasLimits: [0], is7702: true }; + } + const firstStep = steps[0]; const chainIdHex = toHex(firstStep.chainId); diff --git a/packages/transaction-pay-controller/src/strategy/server/server-submit.test.ts b/packages/transaction-pay-controller/src/strategy/server/server-submit.test.ts index ec4e327501b..7c3a4bbfdf7 100644 --- a/packages/transaction-pay-controller/src/strategy/server/server-submit.test.ts +++ b/packages/transaction-pay-controller/src/strategy/server/server-submit.test.ts @@ -161,6 +161,7 @@ describe('submitServerQuotes', () => { findNetworkClientIdByChainIdMock, getControllerStateMock, getDelegationTransactionMock, + getKeyringControllerStateMock, getPaymentOverrideDataMock, getTransactionControllerStateMock, messenger, @@ -182,6 +183,17 @@ describe('submitServerQuotes', () => { transaction: cloneDeep(TRANSACTION_META_MOCK), }; + getKeyringControllerStateMock.mockReturnValue({ + isUnlocked: true, + keyrings: [ + { + type: 'HD Key Tree', + accounts: [ORIGINAL_FROM_MOCK, QUOTE_FROM_MOCK], + metadata: { id: 'hd-keyring', name: 'HD Key Tree' }, + }, + ], + }); + findNetworkClientIdByChainIdMock.mockReturnValue(NETWORK_CLIENT_ID_MOCK); getDelegationTransactionMock.mockResolvedValue(DELEGATION_MOCK); getLiveTokenBalanceMock.mockResolvedValue('999999999999999999'); @@ -535,11 +547,134 @@ describe('submitServerQuotes', () => { expect(addTxMock).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ + excludeNativeTokenForFee: true, + gasFeeToken: reqWithGasFeeToken.quotes[0].request.sourceTokenAddress, + }), + ); + }); + + it('passes gasFeeToken to the batch when fees.isSourceGasFeeToken is true', async () => { + const reqWithGasFeeToken = buildNonGaslessRequest({ + steps: [ + ORIGINAL_QUOTE_MOCK.steps[0], + { + type: 'transaction' as const, + chainId: 137, + data: '0xseconddata' as Hex, + to: '0x9999999999999999999999999999999999999999' as Hex, + value: '0x20', + }, + ], + }); + + reqWithGasFeeToken.quotes[0].fees.isSourceGasFeeToken = true; + + await submitServerQuotes(reqWithGasFeeToken); + + expect(addTxBatchMock).toHaveBeenCalledTimes(1); + expect(addTxBatchMock).toHaveBeenCalledWith( + expect.objectContaining({ + excludeNativeTokenForFee: true, gasFeeToken: reqWithGasFeeToken.quotes[0].request.sourceTokenAddress, }), ); }); + const buildSponsoredRequest = ( + overrides: Partial = {}, + ): PayStrategyExecuteRequest => { + const sponsoredRequest = buildNonGaslessRequest(overrides); + const { chainId } = sponsoredRequest.transaction; + + // Sponsorship only applies when the quote never leaves the parent + // transaction's chain. + sponsoredRequest.quotes[0].request.sourceChainId = chainId; + sponsoredRequest.quotes[0].request.targetChainId = chainId; + sponsoredRequest.transaction.isGasFeeSponsored = true; + + return sponsoredRequest; + }; + + it('passes isGasFeeSponsored when the parent is sponsored and the route is same-chain', async () => { + await submitServerQuotes(buildSponsoredRequest()); + + expect(addTxMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ isGasFeeSponsored: true }), + ); + }); + + it('suppresses gasFeeToken when sponsored', async () => { + const sponsoredRequest = buildSponsoredRequest(); + + // Sponsorship outranks the gas fee token, so the user must not also be + // billed in the source token for gas MetaMask is covering. + sponsoredRequest.quotes[0].fees.isSourceGasFeeToken = true; + + await submitServerQuotes(sponsoredRequest); + + const [, options] = addTxMock.mock.calls[0]; + + expect(options).toMatchObject({ isGasFeeSponsored: true }); + expect(options?.gasFeeToken).toBeUndefined(); + expect(options?.excludeNativeTokenForFee).toBeUndefined(); + }); + + it('passes isGasFeeSponsored to the batch when sponsored', async () => { + await submitServerQuotes( + buildSponsoredRequest({ + steps: [ + ORIGINAL_QUOTE_MOCK.steps[0], + { + type: 'transaction' as const, + chainId: 137, + data: '0xseconddata' as Hex, + to: '0x9999999999999999999999999999999999999999' as Hex, + value: '0x20', + }, + ], + }), + ); + + expect(addTxBatchMock).toHaveBeenCalledWith( + expect.objectContaining({ isGasFeeSponsored: true }), + ); + }); + + it('passes isGasFeeSponsored as false when the parent is explicitly unsponsored', async () => { + const unsponsoredRequest = buildSponsoredRequest(); + unsponsoredRequest.transaction.isGasFeeSponsored = false; + + await submitServerQuotes(unsponsoredRequest); + + // An explicit `false` and an absent flag are not equivalent to the + // `TransactionController`, so the evaluated result must be forwarded. + expect(addTxMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ isGasFeeSponsored: false }), + ); + }); + + it('omits isGasFeeSponsored when the parent sponsorship is unknown', async () => { + await submitServerQuotes(buildNonGaslessRequest()); + + const [, options] = addTxMock.mock.calls[0]; + + expect(options?.isGasFeeSponsored).toBeUndefined(); + }); + + it('does not sponsor a cross-chain route even when the parent is sponsored', async () => { + const crossChainRequest = buildSponsoredRequest(); + crossChainRequest.quotes[0].request.sourceChainId = '0x89'; + + await submitServerQuotes(crossChainRequest); + + expect(addTxMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ isGasFeeSponsored: false }), + ); + }); + it('records submitted transaction ids onto requiredTransactionIds', async () => { await submitServerQuotes(buildNonGaslessRequest()); diff --git a/packages/transaction-pay-controller/src/strategy/server/server-submit.ts b/packages/transaction-pay-controller/src/strategy/server/server-submit.ts index 54ca3b32454..3a59685dc9a 100644 --- a/packages/transaction-pay-controller/src/strategy/server/server-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/server/server-submit.ts @@ -19,12 +19,17 @@ import type { TransactionPayControllerMessenger, TransactionPayQuote, } from '../../types.js'; +import { accountSupports7702 } from '../../utils/7702.js'; import { getServerPollingInterval, getServerPollingTimeout, } from '../../utils/feature-flags.js'; import type { GasPayment } from '../../utils/gas-payment.js'; -import { GasPaymentMode, resolveGasPayment } from '../../utils/gas-payment.js'; +import { + GasPaymentMode, + logGasPaymentOutcome, + resolveGasPayment, +} from '../../utils/gas-payment.js'; import { getNetworkClientId } from '../../utils/provider.js'; import { getLiveTokenBalance, @@ -210,6 +215,11 @@ async function submitTransactionSteps( isDelegated: quote.original.gasless, isSourceGasFeeToken: quote.fees.isSourceGasFeeToken, sourceTokenAddress: quote.request.sourceTokenAddress, + sponsorship: { + accountSupports7702: accountSupports7702(messenger, quote.request.from), + request: quote.request, + transaction, + }, }); if (gasPayment.mode === GasPaymentMode.Delegation) { @@ -683,7 +693,7 @@ async function submitViaTransactionController( ): Promise { const { from, sourceChainId } = quote.request; const { gasLimits, is7702 } = quote.original.client; - const { gasFeeToken } = gasPayment; + const { excludeNativeTokenForFee, gasFeeToken } = gasPayment; const networkClientId = getNetworkClientId(messenger, sourceChainId); @@ -722,7 +732,9 @@ async function submitViaTransactionController( const { params, type } = stepTransactions[0]; const addTransactionOptions = { + excludeNativeTokenForFee, gasFeeToken, + isGasFeeSponsored: gasPayment.isGasFeeSponsored, isInternal: true, networkClientId, origin: ORIGIN_METAMASK, @@ -773,7 +785,9 @@ async function submitViaTransactionController( disableSequential: true, gasLimit7702, }), + excludeNativeTokenForFee, gasFeeToken, + isGasFeeSponsored: gasPayment.isGasFeeSponsored, isInternal: true, networkClientId, origin: ORIGIN_METAMASK, @@ -801,6 +815,11 @@ async function submitViaTransactionController( log('Server transactions confirmed', transactionIds); + logGasPaymentOutcome( + gasPayment, + transactionIds.map((txId) => getTransaction(txId, messenger)), + ); + const lastId = transactionIds.at(-1); const sourceHash = lastId ? getTransaction(lastId, messenger)?.hash diff --git a/packages/transaction-pay-controller/src/utils/gas-payment.test.ts b/packages/transaction-pay-controller/src/utils/gas-payment.test.ts index 13d409be4de..3cb07554783 100644 --- a/packages/transaction-pay-controller/src/utils/gas-payment.test.ts +++ b/packages/transaction-pay-controller/src/utils/gas-payment.test.ts @@ -7,6 +7,7 @@ import type { GasPayment } from './gas-payment.js'; import { shouldUseGasStation, GasPaymentMode, + logGasPaymentOutcome, resolveGasPayment, resolveGasStationCost, } from './gas-payment.js'; @@ -19,6 +20,21 @@ jest.mock('./token', () => ({ getTokenBalance: jest.fn(), })); +// `logGasPaymentOutcome` only emits diagnostics, so the log call is the +// observable behaviour. Capturing it is the only way to assert the outcome it +// reports rather than merely that it did not throw. +const logMock = jest.fn(); + +jest.mock('@metamask/utils', () => ({ + ...jest.requireActual('@metamask/utils'), + // Indirected through a closure because `jest.mock` is hoisted above the + // `logMock` declaration, so it cannot be read while the module initialises. + createModuleLogger: + () => + (...args: unknown[]) => + logMock(...args), +})); + const REQUEST_MOCK = { from: '0x1234567890123456789012345678901234567891' as Hex, sourceChainId: '0x1' as Hex, @@ -164,7 +180,6 @@ describe('gas-payment', () => { expect( resolveGasPayment({ ...PAYMENT_REQUEST_MOCK, - excludeNativeTokenForFee: true, isDelegated: true, isSourceGasFeeToken: true, sponsorship: SPONSORED_MOCK, @@ -192,23 +207,22 @@ describe('gas-payment', () => { isSourceGasFeeToken: true, }), ).toStrictEqual({ + excludeNativeTokenForFee: true, gasFeeToken: REQUEST_MOCK.sourceTokenAddress, mode: GasPaymentMode.GasFeeToken, }); }); - it('excludes native token for fee only when the strategy opts in', () => { + it('always excludes native token for fee so the quoted fee is honoured', () => { + // The user was shown the source token fee at quote time, so the + // `TransactionController` must not silently bill them in native token + // when its own balance check disagrees with the quote's. expect( resolveGasPayment({ ...PAYMENT_REQUEST_MOCK, - excludeNativeTokenForFee: true, isSourceGasFeeToken: true, - }), - ).toStrictEqual({ - excludeNativeTokenForFee: true, - gasFeeToken: REQUEST_MOCK.sourceTokenAddress, - mode: GasPaymentMode.GasFeeToken, - }); + }).excludeNativeTokenForFee, + ).toBe(true); }); it('falls back to native gas', () => { @@ -230,6 +244,58 @@ describe('gas-payment', () => { }); }); + describe('logGasPaymentOutcome', () => { + const GAS_FEE_TOKEN_PAYMENT_MOCK: GasPayment = { + excludeNativeTokenForFee: true, + gasFeeToken: REQUEST_MOCK.sourceTokenAddress, + mode: GasPaymentMode.GasFeeToken, + }; + + beforeEach(() => { + logMock.mockClear(); + }); + + it('reports the gas fee token was honoured when it survived to publish', () => { + logGasPaymentOutcome(GAS_FEE_TOKEN_PAYMENT_MOCK, [ + { id: '1', selectedGasFeeToken: REQUEST_MOCK.sourceTokenAddress }, + ]); + + expect(logMock).toHaveBeenCalledWith('Gas was paid with source token', { + gasFeeToken: REQUEST_MOCK.sourceTokenAddress, + }); + }); + + it('reports the offending transactions when the gas fee token was dropped', () => { + logGasPaymentOutcome(GAS_FEE_TOKEN_PAYMENT_MOCK, [ + { id: '1', selectedGasFeeToken: undefined }, + ]); + + expect(logMock).toHaveBeenCalledWith( + 'Gas fee token was dropped, gas paid with native token instead', + { + gasFeeToken: REQUEST_MOCK.sourceTokenAddress, + transactionIds: ['1'], + }, + ); + }); + + it('treats a missing transaction as honoured rather than dropped', () => { + logGasPaymentOutcome(GAS_FEE_TOKEN_PAYMENT_MOCK, [undefined]); + + expect(logMock).toHaveBeenCalledWith('Gas was paid with source token', { + gasFeeToken: REQUEST_MOCK.sourceTokenAddress, + }); + }); + + it('stays silent for modes that never set a gas fee token', () => { + logGasPaymentOutcome({ mode: GasPaymentMode.Native }, [ + { id: '1', selectedGasFeeToken: undefined }, + ]); + + expect(logMock).not.toHaveBeenCalled(); + }); + }); + describe('sponsorship', () => { const SPONSORSHIP_MOCK = { accountSupports7702: true, diff --git a/packages/transaction-pay-controller/src/utils/gas-payment.ts b/packages/transaction-pay-controller/src/utils/gas-payment.ts index 0f59a5971ff..73224e3b351 100644 --- a/packages/transaction-pay-controller/src/utils/gas-payment.ts +++ b/packages/transaction-pay-controller/src/utils/gas-payment.ts @@ -39,6 +39,21 @@ export enum GasPaymentMode { Native = 'native', } +/** + * Plain-language summary logged for each resolved {@link GasPaymentMode}. + * + * Phrased in the future tense because this is the payment MetaMask is about to + * request, not one that has happened. For the modes that go through the + * `TransactionController`, the outcome is only known after publish — see + * {@link logGasPaymentOutcome}. + */ +const GAS_PAYMENT_MODE_LOG: Record = { + [GasPaymentMode.Delegation]: 'Gas will be paid by relayer', + [GasPaymentMode.GasFeeToken]: 'Gas will be paid with source token', + [GasPaymentMode.Native]: 'Gas will be paid with native token', + [GasPaymentMode.Sponsored]: 'Gas will be sponsored', +}; + /** * Who pays origin gas, plus the `TransactionController` options that implement * it. Spread the options straight into `addTransaction` or @@ -47,7 +62,9 @@ export enum GasPaymentMode { export type GasPayment = { /** * Stop the `TransactionController` silently falling back to native gas when - * a gas fee token is set. Only set in {@link GasPaymentMode.GasFeeToken}. + * a gas fee token is set. Always set in {@link GasPaymentMode.GasFeeToken}, + * because reaching that mode means the quote was priced with the gas fee + * token and the user was shown that fee. */ excludeNativeTokenForFee?: boolean; @@ -71,13 +88,6 @@ export type GasPayment = { }; type GasPaymentRequest = { - /** - * Whether to set `excludeNativeTokenForFee` when a gas fee token is used. - * Opt-in per strategy since it changes how the `TransactionController` - * treats an account that also holds native balance. - */ - excludeNativeTokenForFee?: boolean; - /** * Whether a relayer will redeem a signed delegation and pay origin gas * (Relay `/execute`, server `gasless`, HyperLiquid withdrawals). @@ -188,15 +198,31 @@ export function shouldUseGasStation({ nativeGasCostRaw: string | undefined; request: Pick; }): boolean { - if ( - nativeGasCostRaw !== undefined && - hasNativeBalanceFor({ messenger, nativeGasCostRaw, request }) - ) { - log('Native balance is sufficient for gas', { nativeGasCostRaw }); + const { isSufficient, nativeBalanceRaw } = checkNativeBalance({ + messenger, + nativeGasCostRaw, + request, + }); + + if (isSufficient) { + log('Native balance is sufficient for gas', { + nativeBalanceRaw, + nativeGasCostRaw, + }); + return false; + } + + if (!canUseGasStation(messenger, request.sourceChainId)) { return false; } - return canUseGasStation(messenger, request.sourceChainId); + log('Requesting gas fee token as insufficient native token', { + nativeBalanceRaw, + nativeGasCostRaw, + sourceChainId: request.sourceChainId, + }); + + return true; } /** @@ -242,15 +268,61 @@ function canUseGasStation( * `TransactionController` options. * * @param request - Payment request. - * @param request.excludeNativeTokenForFee - Whether to block native gas fallback alongside a gas fee token. - * @param request.isDelegated - Whether a relayer redeems a delegation and pays origin gas. - * @param request.isSourceGasFeeToken - Whether the quote priced origin gas in the source token. - * @param request.sourceTokenAddress - Source token charged when paying with a gas fee token. - * @param request.sponsorship - Inputs for the MetaMask sponsorship check. * @returns The resolved mode and its `TransactionController` options. */ -export function resolveGasPayment({ - excludeNativeTokenForFee, +export function resolveGasPayment(request: GasPaymentRequest): GasPayment { + const gasPayment = getGasPayment(request); + + log(GAS_PAYMENT_MODE_LOG[gasPayment.mode], gasPayment); + + return gasPayment; +} + +/** + * Log whether the `TransactionController` actually charged gas the way + * {@link resolveGasPayment} asked it to. + * + * `resolveGasPayment` only logs the request. The `TransactionController` makes + * its own decision at publish time and clears `selectedGasFeeToken` when it + * declines, so without this the logs would claim the source token paid for gas + * even when the user was billed in native token. + * + * Only meaningful for {@link GasPaymentMode.GasFeeToken}; every other mode + * either bypasses the `TransactionController` or has nothing to contradict. + * + * @param gasPayment - The gas payment that was requested. + * @param transactions - Final metadata of the submitted transactions. + */ +export function logGasPaymentOutcome( + gasPayment: GasPayment, + transactions: ( + | Pick + | undefined + )[], +): void { + if (gasPayment.mode !== GasPaymentMode.GasFeeToken) { + return; + } + + const ignored = transactions.filter( + (transaction) => transaction && !transaction.selectedGasFeeToken, + ); + + if (!ignored.length) { + log('Gas was paid with source token', { + gasFeeToken: gasPayment.gasFeeToken, + }); + + return; + } + + log('Gas fee token was dropped, gas paid with native token instead', { + gasFeeToken: gasPayment.gasFeeToken, + transactionIds: ignored.map((transaction) => transaction?.id), + }); +} + +function getGasPayment({ isDelegated, isSourceGasFeeToken, sourceTokenAddress, @@ -272,9 +344,15 @@ export function resolveGasPayment({ isSponsored === undefined ? {} : { isGasFeeSponsored: isSponsored }; if (isSourceGasFeeToken) { + // Reaching this mode means the quote priced origin gas in the source token + // and the user was shown that fee, so the `TransactionController` must not + // quietly bill them in native token instead. Without this, it drops the + // gas fee token whenever its own balance check says native covers the gas, + // and that check uses a live balance and a per-transaction cost while the + // quote used cached balances and a buffered cost across every origin call. return { ...unsponsored, - ...(excludeNativeTokenForFee ? { excludeNativeTokenForFee: true } : {}), + excludeNativeTokenForFee: true, gasFeeToken: sourceTokenAddress, mode: GasPaymentMode.GasFeeToken, }; @@ -320,11 +398,15 @@ export async function resolveGasStationCost({ return { isAvailable: false }; } - if ( - nativeGasCostRaw !== undefined && - hasNativeBalanceFor({ messenger, nativeGasCostRaw, request }) - ) { + const { isSufficient, nativeBalanceRaw } = checkNativeBalance({ + messenger, + nativeGasCostRaw, + request, + }); + + if (isSufficient) { log('Skipping gas station as native balance covers gas', { + nativeBalanceRaw, nativeGasCostRaw, sourceChainId, }); @@ -335,6 +417,13 @@ export async function resolveGasStationCost({ return { isAvailable: false }; } + log('Requesting gas fee token as insufficient native token', { + nativeBalanceRaw, + nativeGasCostRaw, + sourceChainId, + sourceTokenAddress, + }); + const amount = await getGasStationCostInSourceToken({ feeTokenAccount: feeTokenAccount ?? from, firstStepData, @@ -402,6 +491,13 @@ async function getGasStationCostInSourceToken({ return undefined; } + log('Found gas fee token', { + amount: gasFeeToken.amount, + gas: gasFeeToken.gas, + sourceChainId, + sourceTokenAddress, + }); + const gasFeeTokenWithNormalizedAmount = { ...gasFeeToken, amount: toHex( @@ -460,25 +556,43 @@ function getNormalizedGasFeeTokenAmount({ return amount.integerValue(BigNumber.ROUND_CEIL).toFixed(0); } -function hasNativeBalanceFor({ +/** + * Read the account's native balance and compare it to a gas cost. + * + * The balance is returned alongside the verdict so callers can log what the + * decision was actually based on. An undefined cost is never sufficient, since + * an unknown gas cost cannot be shown to be covered. + * + * @param request - Balance check. + * @param request.messenger - Controller messenger. + * @param request.nativeGasCostRaw - Native gas cost to cover, in raw units. + * @param request.request - Quote request fields identifying the payment. + * @returns The raw native balance and whether it covers the cost. + */ +function checkNativeBalance({ messenger, nativeGasCostRaw, request, }: { messenger: TransactionPayControllerMessenger; - nativeGasCostRaw: string; + nativeGasCostRaw: string | undefined; request: Pick; -}): boolean { +}): { isSufficient: boolean; nativeBalanceRaw: string } { const { from, sourceChainId } = request; - const nativeBalance = getTokenBalance( + const nativeBalanceRaw = getTokenBalance( messenger, from, sourceChainId, getNativeToken(sourceChainId), ); - return new BigNumber(nativeBalance).isGreaterThanOrEqualTo(nativeGasCostRaw); + return { + isSufficient: + nativeGasCostRaw !== undefined && + new BigNumber(nativeBalanceRaw).isGreaterThanOrEqualTo(nativeGasCostRaw), + nativeBalanceRaw, + }; } /** From 9ecc736735c5c43a5bca08459024cf39bb2ea2e2 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Mon, 28 Sep 2026 10:24:41 +0100 Subject: [PATCH 4/9] chore(transaction-pay-controller): move changelog entry to Unreleased The entry was added under the already-released 29.2.0 heading. --- packages/transaction-pay-controller/CHANGELOG.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index a88b95cd364..56bc601493a 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -46,6 +46,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/ramps-controller` from `^25.1.0` to `^25.1.1` ([#10459](https://github.com/MetaMask/core/pull/10459)) - Bump `@metamask/transaction-controller` from `^72.0.0` to `^72.0.1` ([#10462](https://github.com/MetaMask/core/pull/10462)) +### Fixed + +- Always set `excludeNativeTokenForFee` when origin gas is priced in the source token, so the fee shown in the quote is the fee the user is charged ([#10404](https://github.com/MetaMask/core/pull/10404)) + - Previously only the across strategy opted in, so for the relay and server strategies the `TransactionController` re-checked the native balance at publish time and silently dropped the gas fee token, billing the user in native token instead. + ## [29.2.1] ### Fixed @@ -69,8 +74,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Always set `excludeNativeTokenForFee` when origin gas is priced in the source token, so the fee shown in the quote is the fee the user is charged ([#10404](https://github.com/MetaMask/core/pull/10404)) - - Previously only the across strategy opted in, so for the relay and server strategies the `TransactionController` re-checked the native balance at publish time and silently dropped the gas fee token, billing the user in native token instead. - Populate `TransactionPayQuote.targetAmount` on server pay strategy quotes, which previously always reported zero ([#10342](https://github.com/MetaMask/core/pull/10342)) - The fiat and USD values are now derived from the quote's output amount and the target token fiat rate, matching the relay strategy. They remain zero only when no fiat rate is available for the target token. - Fix single-step server pay strategy quotes failing to submit with an invalid transaction envelope type error ([#10342](https://github.com/MetaMask/core/pull/10342)) From 33d8d5a24e623267c491c580e52ada02a1674a26 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Wed, 30 Sep 2026 12:16:16 +0100 Subject: [PATCH 5/9] fix(transaction-pay-controller): only log the gas payment mode once it is decided `resolveGasPayment` runs twice: at quote time to estimate the fee, and at submit time to build the `TransactionController` options. At quote time the gas station has not been consulted, so an absent `isSourceGasFeeToken` collapses the mode to `Native` by default rather than by decision. Logging it announced that the user pays gas in native token while the source token was still in play. Log only when `isSourceGasFeeToken` is present, i.e. at submit time. --- .../src/utils/gas-payment.test.ts | 21 +++++++++++++++++++ .../src/utils/gas-payment.ts | 13 +++++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/packages/transaction-pay-controller/src/utils/gas-payment.test.ts b/packages/transaction-pay-controller/src/utils/gas-payment.test.ts index 3cb07554783..5c01f95bd55 100644 --- a/packages/transaction-pay-controller/src/utils/gas-payment.test.ts +++ b/packages/transaction-pay-controller/src/utils/gas-payment.test.ts @@ -242,6 +242,27 @@ describe('gas-payment', () => { mode: GasPaymentMode.Native, }); }); + + it('does not log the resolved mode at quote time', () => { + // Without `isSourceGasFeeToken` the gas station has not been consulted, + // so `Native` is a default rather than a decision. Logging it claims the + // user pays gas in native token while the source token is still in play. + resolveGasPayment(PAYMENT_REQUEST_MOCK); + + expect(logMock).not.toHaveBeenCalled(); + }); + + it('logs the resolved mode at submit time', () => { + resolveGasPayment({ + ...PAYMENT_REQUEST_MOCK, + isSourceGasFeeToken: false, + }); + + expect(logMock).toHaveBeenCalledWith( + 'Gas will be paid with native token', + { mode: GasPaymentMode.Native }, + ); + }); }); describe('logGasPaymentOutcome', () => { diff --git a/packages/transaction-pay-controller/src/utils/gas-payment.ts b/packages/transaction-pay-controller/src/utils/gas-payment.ts index 73224e3b351..4a825b6616b 100644 --- a/packages/transaction-pay-controller/src/utils/gas-payment.ts +++ b/packages/transaction-pay-controller/src/utils/gas-payment.ts @@ -46,6 +46,9 @@ export enum GasPaymentMode { * request, not one that has happened. For the modes that go through the * `TransactionController`, the outcome is only known after publish — see * {@link logGasPaymentOutcome}. + * + * Only emitted at submit time. See {@link resolveGasPayment} for why the + * quote-time resolution is too provisional to report. */ const GAS_PAYMENT_MODE_LOG: Record = { [GasPaymentMode.Delegation]: 'Gas will be paid by relayer', @@ -267,13 +270,21 @@ function canUseGasStation( * estimating it. At submit time, call it with everything to get the * `TransactionController` options. * + * Only the submit-time resolution is logged. At quote time the gas station has + * not been consulted yet, so an absent `isSourceGasFeeToken` collapses to + * {@link GasPaymentMode.Native} by default rather than by decision — logging + * that would announce the user is paying gas in native token before anything + * has ruled out the source token. + * * @param request - Payment request. * @returns The resolved mode and its `TransactionController` options. */ export function resolveGasPayment(request: GasPaymentRequest): GasPayment { const gasPayment = getGasPayment(request); - log(GAS_PAYMENT_MODE_LOG[gasPayment.mode], gasPayment); + if (request.isSourceGasFeeToken !== undefined) { + log(GAS_PAYMENT_MODE_LOG[gasPayment.mode], gasPayment); + } return gasPayment; } From 0a3c0f1e879f617611763eeb5ffd7cdcc4dcdc45 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Wed, 30 Sep 2026 12:16:16 +0100 Subject: [PATCH 6/9] chore(transaction-pay-controller): move changelog entry back to Unreleased The rebase onto the 30.0.0 release re-filed the #10404 entry under the published 29.2.2 heading, describing a change that is not in that release. --- packages/transaction-pay-controller/CHANGELOG.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 56bc601493a..8bdb587efb2 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -18,6 +18,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/ramps-controller` from `^26.0.1` to `^26.1.0` ([#10569](https://github.com/MetaMask/core/pull/10569)) - Bump `@metamask/assets-controller` from `^17.0.0` to `^18.0.0` ([#10648](https://github.com/MetaMask/core/pull/10648)) +### Fixed + +- Always set `excludeNativeTokenForFee` when origin gas is priced in the source token, so the fee shown in the quote is the fee the user is charged ([#10404](https://github.com/MetaMask/core/pull/10404)) + - Previously only the across strategy opted in, so for the relay and server strategies the `TransactionController` re-checked the native balance at publish time and silently dropped the gas fee token, billing the user in native token instead. + ## [30.0.0] ### Changed @@ -46,11 +51,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/ramps-controller` from `^25.1.0` to `^25.1.1` ([#10459](https://github.com/MetaMask/core/pull/10459)) - Bump `@metamask/transaction-controller` from `^72.0.0` to `^72.0.1` ([#10462](https://github.com/MetaMask/core/pull/10462)) -### Fixed - -- Always set `excludeNativeTokenForFee` when origin gas is priced in the source token, so the fee shown in the quote is the fee the user is charged ([#10404](https://github.com/MetaMask/core/pull/10404)) - - Previously only the across strategy opted in, so for the relay and server strategies the `TransactionController` re-checked the native balance at publish time and silently dropped the gas fee token, billing the user in native token instead. - ## [29.2.1] ### Fixed From bf9fda05a497ec4c2d19312bd68f44aa23480d5d Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Wed, 30 Sep 2026 16:53:34 +0100 Subject: [PATCH 7/9] chore(transaction-pay-controller): realign lint suppressions after the gas payment split Both suppression files key on file path, so renaming `gas-station.test.ts` to `gas-payment.test.ts` orphaned its oxlint entry and surfaced the carried-over mock violations as new. Move the entry to the new path and drop the stale one. Also raise the `server-quotes.test.ts` tsc counts to cover the request literals added by the new tests. These omit the required `from`, matching the 32 call sites already suppressed in that file; supplying a value would change what the tests feed the code under test. --- oxlint-suppressions.json | 39 +++++++++++++++++++++++++++------------ tsc-suppressions.json | 5 ++++- 2 files changed, 31 insertions(+), 13 deletions(-) diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 50a72b60d16..b3eedca04fd 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -6746,17 +6746,23 @@ } }, "packages/transaction-pay-controller/src/strategy/server/server-quotes.test.ts": { + "no-unused-vars": { + "count": 1 + }, + "typescript/no-unnecessary-type-assertion": { + "count": 1 + }, "typescript/no-unsafe-assignment": { - "count": 39 + "count": 47 }, "typescript/no-unsafe-call": { - "count": 8 + "count": 9 }, "typescript/no-unsafe-member-access": { - "count": 8 + "count": 9 }, "typescript/no-unsafe-return": { - "count": 1 + "count": 2 } }, "packages/transaction-pay-controller/src/strategy/server/server-quotes.ts": { @@ -6768,14 +6774,17 @@ } }, "packages/transaction-pay-controller/src/strategy/server/server-submit.test.ts": { + "typescript/no-unnecessary-type-assertion": { + "count": 4 + }, "typescript/no-unsafe-assignment": { - "count": 42 + "count": 44 }, "typescript/no-unsafe-call": { - "count": 20 + "count": 21 }, "typescript/no-unsafe-member-access": { - "count": 23 + "count": 29 }, "typescript/no-unsafe-return": { "count": 3 @@ -6795,18 +6804,24 @@ "count": 108 } }, - "packages/transaction-pay-controller/src/utils/gas-station.test.ts": { - "typescript/no-unsafe-argument": { + "packages/transaction-pay-controller/src/utils/gas-payment.test.ts": { + "typescript/explicit-function-return-type": { + "count": 1 + }, + "typescript/no-unnecessary-type-assertion": { "count": 2 }, "typescript/no-unsafe-assignment": { - "count": 6 + "count": 7 }, "typescript/no-unsafe-call": { - "count": 5 + "count": 16 }, "typescript/no-unsafe-member-access": { - "count": 5 + "count": 16 + }, + "typescript/no-unsafe-return": { + "count": 3 } }, "packages/transaction-pay-controller/src/utils/gas.test.ts": { diff --git a/tsc-suppressions.json b/tsc-suppressions.json index 2b5f58943bd..93d875c054f 100644 --- a/tsc-suppressions.json +++ b/tsc-suppressions.json @@ -1226,8 +1226,11 @@ } }, "packages/transaction-pay-controller/src/strategy/server/server-quotes.test.ts": { + "TS2352": { + "count": 2 + }, "TS2741": { - "count": 32 + "count": 39 } }, "packages/transaction-pay-controller/src/strategy/server/server-submit.test.ts": { From fd5e2b2d6eb79d5a09fa4efff01eba936ddb5ad9 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Thu, 1 Oct 2026 12:46:59 +0100 Subject: [PATCH 8/9] fix(transaction-pay-controller): log native gas resolution at submit time --- .../src/utils/gas-payment.test.ts | 12 ++++++++++++ .../src/utils/gas-payment.ts | 12 ++++++------ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/packages/transaction-pay-controller/src/utils/gas-payment.test.ts b/packages/transaction-pay-controller/src/utils/gas-payment.test.ts index 5c01f95bd55..20f89f0239c 100644 --- a/packages/transaction-pay-controller/src/utils/gas-payment.test.ts +++ b/packages/transaction-pay-controller/src/utils/gas-payment.test.ts @@ -263,6 +263,18 @@ describe('gas-payment', () => { { mode: GasPaymentMode.Native }, ); }); + + it('logs the resolved mode when submit has no gas fee token result', () => { + resolveGasPayment({ + ...PAYMENT_REQUEST_MOCK, + isSourceGasFeeToken: undefined, + }); + + expect(logMock).toHaveBeenCalledWith( + 'Gas will be paid with native token', + { mode: GasPaymentMode.Native }, + ); + }); }); describe('logGasPaymentOutcome', () => { diff --git a/packages/transaction-pay-controller/src/utils/gas-payment.ts b/packages/transaction-pay-controller/src/utils/gas-payment.ts index 4a825b6616b..769d16f387e 100644 --- a/packages/transaction-pay-controller/src/utils/gas-payment.ts +++ b/packages/transaction-pay-controller/src/utils/gas-payment.ts @@ -270,11 +270,11 @@ function canUseGasStation( * estimating it. At submit time, call it with everything to get the * `TransactionController` options. * - * Only the submit-time resolution is logged. At quote time the gas station has - * not been consulted yet, so an absent `isSourceGasFeeToken` collapses to - * {@link GasPaymentMode.Native} by default rather than by decision — logging - * that would announce the user is paying gas in native token before anything - * has ruled out the source token. + * Only the submit-time resolution is logged. Quote-time callers omit + * `isSourceGasFeeToken`, while submit-time callers include it even when the + * value is `undefined`. At quote time the gas station has not been consulted + * yet, so logging would announce the user is paying gas in native token before + * anything has ruled out the source token. * * @param request - Payment request. * @returns The resolved mode and its `TransactionController` options. @@ -282,7 +282,7 @@ function canUseGasStation( export function resolveGasPayment(request: GasPaymentRequest): GasPayment { const gasPayment = getGasPayment(request); - if (request.isSourceGasFeeToken !== undefined) { + if ('isSourceGasFeeToken' in request) { log(GAS_PAYMENT_MODE_LOG[gasPayment.mode], gasPayment); } From f58e3a8498dddd147dca13815c36c9ad5d658b17 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Thu, 1 Oct 2026 13:31:28 +0100 Subject: [PATCH 9/9] chore(transaction-pay-controller): keep gas payment fix unreleased --- packages/transaction-pay-controller/CHANGELOG.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 8bdb587efb2..9e12fa39759 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -11,6 +11,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/transaction-controller` from `^72.0.1` to `^72.1.0` ([#10652](https://github.com/MetaMask/core/pull/10652)) +### Fixed + +- Always set `excludeNativeTokenForFee` when origin gas is priced in the source token, so the fee shown in the quote is the fee the user is charged ([#10404](https://github.com/MetaMask/core/pull/10404)) + - Previously only the across strategy opted in, so for the relay and server strategies the `TransactionController` re-checked the native balance at publish time and silently dropped the gas fee token, billing the user in native token instead. + ## [30.0.1] ### Changed @@ -18,11 +23,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/ramps-controller` from `^26.0.1` to `^26.1.0` ([#10569](https://github.com/MetaMask/core/pull/10569)) - Bump `@metamask/assets-controller` from `^17.0.0` to `^18.0.0` ([#10648](https://github.com/MetaMask/core/pull/10648)) -### Fixed - -- Always set `excludeNativeTokenForFee` when origin gas is priced in the source token, so the fee shown in the quote is the fee the user is charged ([#10404](https://github.com/MetaMask/core/pull/10404)) - - Previously only the across strategy opted in, so for the relay and server strategies the `TransactionController` re-checked the native balance at publish time and silently dropped the gas fee token, billing the user in native token instead. - ## [30.0.0] ### Changed