diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 2ffe1ff8e6..2268ae8bb7 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/bridge-controller` from `^75.1.1` to `^75.2.0` ([#9214](https://github.com/MetaMask/core/pull/9214)) +### Fixed + +- Inherit `isGasFeeSponsored` from the parent transaction into same-chain Relay source-network fees and submission options ([#9216](https://github.com/MetaMask/core/pull/9216)) + ## [23.13.0] ### Changed diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-direct-musd.test.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-direct-musd.test.ts index 68e08b5dc0..af6b626fd8 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-direct-musd.test.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-direct-musd.test.ts @@ -390,6 +390,7 @@ describe('fiat-direct-musd', () => { networkClientId: NETWORK_CLIENT_ID_MOCK, origin: 'metamask', requireApproval: false, + skipInitialGasEstimate: true, transactions: [ { params: { data: '0xnewApprove', to: '0xapprove', value: '0x0' }, diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-direct-musd.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-direct-musd.ts index 9fedef9202..f7dc4c3efb 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-direct-musd.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-direct-musd.ts @@ -292,6 +292,7 @@ export async function submitDirectMusdVaultDeposit({ networkClientId, origin: ORIGIN_METAMASK, requireApproval: false, + skipInitialGasEstimate: true, transactions: nestedTransactions.map((nestedTransaction, index) => ({ params: { data: nestedTransaction.data, diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts index 2a21f0cf30..cd210a9b0c 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts @@ -2529,6 +2529,64 @@ describe('Relay Quotes Utils', () => { ); }); + it('zeroes source network fees and gas limits when parent sponsorship applies', async () => { + successfulFetchMock.mockResolvedValue({ + ok: true, + json: async () => QUOTE_MOCK, + } as never); + + const result = await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + targetChainId: QUOTE_REQUEST_MOCK.sourceChainId, + }, + ], + transaction: { + ...TRANSACTION_META_MOCK, + chainId: QUOTE_REQUEST_MOCK.sourceChainId, + isGasFeeSponsored: true, + }, + }); + + const zeroAmount = { fiat: '0', human: '0', raw: '0', usd: '0' }; + + expect(result[0].fees.sourceNetwork.estimate).toStrictEqual(zeroAmount); + expect(result[0].fees.sourceNetwork.max).toStrictEqual(zeroAmount); + expect(result[0].original.metamask.gasLimits).toStrictEqual([0]); + }); + + it('does not zero source network fees when parent sponsorship is missing', async () => { + successfulFetchMock.mockResolvedValue({ + ok: true, + json: async () => QUOTE_MOCK, + } as never); + + const result = await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + targetChainId: QUOTE_REQUEST_MOCK.sourceChainId, + }, + ], + transaction: { + ...TRANSACTION_META_MOCK, + chainId: QUOTE_REQUEST_MOCK.sourceChainId, + }, + }); + + expect(result[0].fees.sourceNetwork.estimate).toStrictEqual({ + fiat: '4.56', + human: '1.725', + raw: '1725000000000000', + usd: '3.45', + }); + }); + it('using gas total from multiple transactions', async () => { const quoteMock = cloneDeep(QUOTE_MOCK); 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 49b6248427..75a68ef14e 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -60,6 +60,7 @@ import { TOKEN_TRANSFER_FOUR_BYTE } from './constants'; import { applyPolymarketDepositWalletOverrides } from './polymarket/withdraw'; import { fetchRelayQuote } from './relay-api'; import { getRelayMaxGasStationQuote } from './relay-max-gas-station'; + import type { RelayQuote, RelayQuoteMetamask, @@ -75,6 +76,16 @@ const POST_QUOTE_GAS_BUFFER = 1.1; // Hardcoded gas allowance for the prepended payment override transaction(s). const PAYMENT_OVERRIDE_GAS = 75_000; +const ZERO_AMOUNT = { fiat: '0', human: '0', raw: '0', usd: '0' }; + +type RelayStepData = RelayTransactionStep['items'][0]['data']; + +type RelayGasResult = { + totalGasEstimate: number; + totalGasLimit: number; + gasLimits: number[]; + is7702: boolean; +}; /** * Fetches Relay quotes. @@ -749,11 +760,9 @@ async function calculateSourceNetworkCost( if (quote.metamask?.isExecute) { log('Zeroing network fees for execute flow'); - const zeroAmount = { fiat: '0', human: '0', raw: '0', usd: '0' }; - return { - estimate: zeroAmount, - max: zeroAmount, + estimate: ZERO_AMOUNT, + max: ZERO_AMOUNT, gasLimits: [], is7702: false, }; @@ -764,16 +773,31 @@ async function calculateSourceNetworkCost( if (request.isHyperliquidSource) { log('Zeroing network fees for HyperLiquid withdrawal (gasless)'); - const zeroAmount = { fiat: '0', human: '0', raw: '0', usd: '0' }; - return { - estimate: zeroAmount, - max: zeroAmount, + estimate: ZERO_AMOUNT, + max: ZERO_AMOUNT, gasLimits: [], is7702: false, }; } + if ( + transaction.isGasFeeSponsored && + request.sourceChainId === transaction.chainId && + request.targetChainId === transaction.chainId + ) { + log('Zeroing source network fees for sponsored same-chain Relay route'); + + // Gas limit is zero as sponsored transactions go through the EIP-7702 + // gas station hook and do not require user-paid gas. + return { + estimate: ZERO_AMOUNT, + max: ZERO_AMOUNT, + gasLimits: [0], + is7702: true, + }; + } + const txSteps = quote.steps.filter( (step): step is RelayTransactionStep => step.kind === 'transaction', ); @@ -1019,8 +1043,6 @@ function toRelayQuoteGasTransaction( }; } -type RelayStepData = RelayTransactionStep['items'][0]['data']; - function getOriginalTxGasParams( request: QuoteRequest, transaction: TransactionMeta, @@ -1058,13 +1080,6 @@ function getOriginalTxGasParams( }; } -type RelayGasResult = { - totalGasEstimate: number; - totalGasLimit: number; - gasLimits: number[]; - is7702: boolean; -}; - function combinePrependedGas( relayOnlyGas: RelayGasResult, request: QuoteRequest, 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 cc0ad27ec8..fe35d3917b 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 @@ -239,6 +239,22 @@ describe('Relay Submit Utils', () => { ); }); + it('passes sponsored gas options when parent sponsorship applies to same-chain quote', async () => { + request.transaction.chainId = CHAIN_ID_MOCK; + request.transaction.isGasFeeSponsored = true; + request.quotes[0].request.targetChainId = CHAIN_ID_MOCK; + request.quotes[0].original.details.currencyOut.currency.chainId = 1; + + await submitRelayQuotes(request); + + expect(addTransactionMock).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + isGasFeeSponsored: true, + }), + ); + }); + it('uses predictRelayDeposit type when parent transaction is predictDeposit', async () => { request.transaction = { ...request.transaction, @@ -1176,25 +1192,6 @@ describe('Relay Submit Utils', () => { ); }); - it('sets gas to undefined when gasLimits entry is missing', async () => { - request.quotes[0].original.metamask.gasLimits = []; - - await submitRelayQuotes(request); - - expect(addTransactionBatchMock).toHaveBeenCalledWith( - expect.objectContaining({ - transactions: expect.arrayContaining([ - expect.objectContaining({ - params: expect.objectContaining({ - gas: undefined, - }), - type: TransactionType.relayDeposit, - }), - ]), - }), - ); - }); - it('does not activate 7702 mode with multiple post-quote gas limits', async () => { request.quotes[0].original.metamask.gasLimits = [21000, 21000]; @@ -1385,6 +1382,24 @@ describe('Relay Submit Utils', () => { ); }); + it('passes sponsored gas options to same-chain batch submissions', async () => { + request.transaction.chainId = CHAIN_ID_MOCK; + request.transaction.isGasFeeSponsored = true; + request.quotes[0].request.targetChainId = CHAIN_ID_MOCK; + request.quotes[0].original.details.currencyOut.currency.chainId = 1; + request.quotes[0].original.steps[0].items.push({ + ...request.quotes[0].original.steps[0].items[0], + }); + + await submitRelayQuotes(request); + + expect(addTransactionBatchMock).toHaveBeenCalledWith( + expect.objectContaining({ + isGasFeeSponsored: true, + }), + ); + }); + it('validates source balance before submitting single transaction', async () => { await submitRelayQuotes(request); diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts index 1faf269885..04189f1546 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts @@ -44,6 +44,7 @@ import { submitPolymarketWithdraw, } from './polymarket/withdraw'; import { getRelayStatus, submitRelayExecute } from './relay-api'; + import type { RelayExecuteRequest, RelayQuote, @@ -661,7 +662,12 @@ async function submitViaTransactionController( let result: { result: Promise } | undefined; - const gasFeeToken = quote.fees.isSourceGasFeeToken + const isSourceGasFeeSponsored = + transaction.isGasFeeSponsored && + quote.request.sourceChainId === transaction.chainId && + quote.request.targetChainId === transaction.chainId; + + const gasFeeToken = !isSourceGasFeeSponsored && quote.fees.isSourceGasFeeToken ? sourceTokenAddress : undefined; @@ -701,6 +707,7 @@ async function submitViaTransactionController( networkClientId, origin: ORIGIN_METAMASK, isInternal: true, + isGasFeeSponsored: isSourceGasFeeSponsored, requireApproval: false, type: getRelayDepositType(getEffectiveTransactionType(transaction)), }, @@ -745,6 +752,7 @@ async function submitViaTransactionController( networkClientId, origin: ORIGIN_METAMASK, isInternal: true, + isGasFeeSponsored: isSourceGasFeeSponsored, overwriteUpgrade: true, requireApproval: false, transactions,