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/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index f01661585a8..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 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..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,6 +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, + logGasPaymentOutcome, + resolveGasPayment, +} from '../../utils/gas-payment.js'; import { getNetworkClientId } from '../../utils/provider.js'; import { collectTransactionIds, @@ -127,8 +132,17 @@ 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({ + isSourceGasFeeToken: quote.fees.isSourceGasFeeToken, + sourceTokenAddress, + }); + const isGasFeeTokenPayment = gasPayment.mode === GasPaymentMode.GasFeeToken; + const orderedTransactions = getAcrossOrderedTransactions({ quote: quote.original.quote, swapType: acrossDepositType, @@ -165,10 +179,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 +274,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) { @@ -313,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; @@ -419,18 +436,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..109a875e516 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, @@ -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,12 +948,7 @@ async function calculateSourceNetworkCost( }; } - if ( - accountSupports7702 && - transaction.isGasFeeSponsored && - request.sourceChainId === transaction.chainId && - request.targetChainId === transaction.chainId - ) { + 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 +1035,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 +1044,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.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 98a577c852f..f87d49b50dc 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,11 @@ import { getRelayPollingInterval, getRelayPollingTimeout, } from '../../utils/feature-flags.js'; +import type { GasPayment } 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 { @@ -881,21 +886,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 { excludeNativeTokenForFee, gasFeeToken } = gasPayment; log('Submitting transactions', { isPostQuote, gasFeeToken, allParamsCount: allParams.length, + gasPaymentMode: gasPayment.mode, }); const isSameChain = @@ -940,11 +947,12 @@ async function submitViaTransactionController( 'TransactionController:addTransaction', transactionParams, { + excludeNativeTokenForFee, gasFeeToken, networkClientId, origin: ORIGIN_METAMASK, isInternal: true, - isGasFeeSponsored: isSourceGasFeeSponsored, + isGasFeeSponsored: gasPayment.isGasFeeSponsored, requireApproval: false, type: getRelayDepositType(getEffectiveTransactionType(transaction)), }, @@ -955,7 +963,7 @@ async function submitViaTransactionController( buildRelayTransactionBatchRequest({ allParams, authorizationList: batchAuthorizationList, - isGasFeeSponsored: isSourceGasFeeSponsored, + gasPayment, messenger, normalizedParams, quote, @@ -983,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) { @@ -1008,7 +1021,7 @@ function mapSignedQuoteAuthorizationList( function buildRelayTransactionBatchRequest({ allParams, authorizationList, - isGasFeeSponsored, + gasPayment, messenger, normalizedParams, quote, @@ -1016,18 +1029,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 +1046,13 @@ function buildRelayTransactionBatchRequest({ disable7702: !gasLimit7702, disableHook: Boolean(gasLimit7702), disableSequential: Boolean(gasLimit7702), - gasFeeToken, + excludeNativeTokenForFee: gasPayment.excludeNativeTokenForFee, + 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..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 @@ -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,13 @@ jest.mock('../../utils/feature-flags', () => ({ isEIP7702Chain: jest.fn(), })); jest.mock('./server-api'); -jest.mock('../../utils/gas-station'); +// `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'); @@ -110,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: [ { @@ -169,11 +180,9 @@ 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 () => { @@ -293,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, @@ -549,15 +578,108 @@ 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], }); }); + 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'); @@ -574,10 +696,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 +713,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 +737,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..84d27e67ae4 100644 --- a/packages/transaction-pay-controller/src/strategy/server/server-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/server/server-quotes.ts @@ -28,17 +28,14 @@ import { isEIP7702Chain, } from '../../utils/feature-flags.js'; import { - getGasStationCostInSourceTokenRaw, - getGasStationEligibility, -} from '../../utils/gas-station.js'; + 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'; -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'; @@ -142,6 +139,8 @@ async function getQuotesForRequest( quoteRequest, messenger, body.tradeType === TradeType.ExactInput, + transaction, + accountSupports7702, ), ), ); @@ -348,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( @@ -450,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, @@ -469,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); @@ -520,42 +546,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 +565,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.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 00e4a705b05..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,10 +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, + logGasPaymentOutcome, + resolveGasPayment, +} from '../../utils/gas-payment.js'; import { getNetworkClientId } from '../../utils/provider.js'; import { getLiveTokenBalance, @@ -204,7 +211,18 @@ async function submitTransactionSteps( messenger, ); - if (quote.original.gasless) { + const gasPayment = resolveGasPayment({ + 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) { await submitViaServerExecute( quote, stepTransactions, @@ -217,6 +235,7 @@ async function submitTransactionSteps( stepTransactions, messenger, transaction, + gasPayment, ); } } @@ -670,14 +689,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 { excludeNativeTokenForFee, gasFeeToken } = gasPayment; const networkClientId = getNetworkClientId(messenger, sourceChainId); - const gasFeeToken = quote.fees.isSourceGasFeeToken - ? sourceTokenAddress - : undefined; log('Submitting via TransactionController', { from, @@ -714,7 +732,9 @@ async function submitViaTransactionController( const { params, type } = stepTransactions[0]; const addTransactionOptions = { + excludeNativeTokenForFee, gasFeeToken, + isGasFeeSponsored: gasPayment.isGasFeeSponsored, isInternal: true, networkClientId, origin: ORIGIN_METAMASK, @@ -765,7 +785,9 @@ async function submitViaTransactionController( disableSequential: true, gasLimit7702, }), + excludeNativeTokenForFee, gasFeeToken, + isGasFeeSponsored: gasPayment.isGasFeeSponsored, isInternal: true, networkClientId, origin: ORIGIN_METAMASK, @@ -793,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 new file mode 100644 index 00000000000..20f89f0239c --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/gas-payment.test.ts @@ -0,0 +1,597 @@ +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, + logGasPaymentOutcome, + 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(), +})); + +// `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, + 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, + 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({ + excludeNativeTokenForFee: true, + gasFeeToken: REQUEST_MOCK.sourceTokenAddress, + mode: GasPaymentMode.GasFeeToken, + }); + }); + + 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, + isSourceGasFeeToken: true, + }).excludeNativeTokenForFee, + ).toBe(true); + }); + + 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, + }); + }); + + 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 }, + ); + }); + + 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', () => { + 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, + 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..769d16f387e --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/gas-payment.ts @@ -0,0 +1,642 @@ +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', +} + +/** + * 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}. + * + * 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', + [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 + * `addTransactionBatch`. + */ +export type GasPayment = { + /** + * Stop the `TransactionController` silently falling back to native gas when + * 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; + + /** + * 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; +}; + +type GasPaymentRequest = { + /** + * 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. */ +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; +}; + +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; +}; + +type GasStationStepData = { + data: Hex; + to: Hex; + value?: string; +}; + +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 { + 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; + } + + log('Requesting gas fee token as insufficient native token', { + nativeBalanceRaw, + nativeGasCostRaw, + sourceChainId: request.sourceChainId, + }); + + return true; +} + +/** + * 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. + * + * 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. + */ +export function resolveGasPayment(request: GasPaymentRequest): GasPayment { + const gasPayment = getGasPayment(request); + + if ('isSourceGasFeeToken' in 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, + 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) { + // 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: 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 }; + } + + const { isSufficient, nativeBalanceRaw } = checkNativeBalance({ + messenger, + nativeGasCostRaw, + request, + }); + + if (isSufficient) { + log('Skipping gas station as native balance covers gas', { + nativeBalanceRaw, + nativeGasCostRaw, + sourceChainId, + }); + return { isAvailable: false }; + } + + if (!canUseGasStation(messenger, sourceChainId)) { + return { isAvailable: false }; + } + + log('Requesting gas fee token as insufficient native token', { + nativeBalanceRaw, + nativeGasCostRaw, + sourceChainId, + sourceTokenAddress, + }); + + 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; + } + + log('Found gas fee token', { + amount: gasFeeToken.amount, + gas: gasFeeToken.gas, + sourceChainId, + sourceTokenAddress, + }); + + 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); +} + +/** + * 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 | undefined; + request: Pick; +}): { isSufficient: boolean; nativeBalanceRaw: string } { + const { from, sourceChainId } = request; + + const nativeBalanceRaw = getTokenBalance( + messenger, + from, + sourceChainId, + getNativeToken(sourceChainId), + ); + + return { + isSufficient: + nativeGasCostRaw !== undefined && + new BigNumber(nativeBalanceRaw).isGreaterThanOrEqualTo(nativeGasCostRaw), + nativeBalanceRaw, + }; +} + +/** + * 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); -} 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": {