diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 5473162cd7..11bf90c705 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Reserve source-network fees from native-token Relay Max quotes when the paying account does not support EIP-7702 ([#10237](https://github.com/MetaMask/core/pull/10237)) - Detect nested `perpsDepositAndOrder` and `predictDepositAndOrder` transactions when selecting `EXACT_OUTPUT` Relay quotes ([#10222](https://github.com/MetaMask/core/pull/10222)) ## [28.0.2] diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-max-gas-station.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-max-gas-station.test.ts index 346d95c4e3..c9613766c7 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-max-gas-station.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-max-gas-station.test.ts @@ -5,6 +5,7 @@ import type { import type { Hex } from '@metamask/utils'; import { getDefaultRemoteFeatureFlagControllerState } from '../../../../remote-feature-flag-controller/src/remote-feature-flag-controller.js'; +import { CHAIN_ID_POLYGON, NATIVE_TOKEN_ADDRESS } from '../../constants.js'; import { TransactionPayStrategy } from '../../index.js'; import { getMessengerMock } from '../../tests/messenger-mock.js'; import type { @@ -224,6 +225,123 @@ describe('relay-max-gas-station', () => { expect(result).toBe(phase1Quote); }); + it('reserves network fees when max source token is native and account does not support EIP-7702', async () => { + const phase1Quote = makeQuote({ + sourceAmountRaw: '100000', + sourceNetworkGasRaw: '100', + }); + const phase2Quote = makeQuote({ + sourceAmountRaw: '99900', + sourceNetworkGasRaw: '100', + }); + const getSingleQuote = jest + .fn() + .mockResolvedValueOnce(phase1Quote) + .mockResolvedValueOnce(phase2Quote); + getTokenBalanceMock.mockReturnValue('100000'); + const nativeToken = getNativeTokenMock(); + const request = { + ...BASE_REQUEST, + sourceTokenAddress: nativeToken, + sourceTokenAmount: '100000', + }; + + const result = await getRelayMaxGasStationQuote( + request, + makeFullRequest(messenger, request), + getSingleQuote, + ); + + expect(getSingleQuote).toHaveBeenCalledTimes(2); + expect(getSingleQuote).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ sourceTokenAmount: '99900' }), + expect.any(Object), + ); + expect(result).toBe(phase2Quote); + }); + + it('reserves network fees when Polygon native Max uses the Relay address', async () => { + const phase1Quote = makeQuote({ + sourceAmountRaw: '100000', + sourceNetworkGasRaw: '100', + }); + const phase2Quote = makeQuote({ + sourceAmountRaw: '99900', + sourceNetworkGasRaw: '100', + }); + const getSingleQuote = jest + .fn() + .mockResolvedValueOnce(phase1Quote) + .mockResolvedValueOnce(phase2Quote); + const request = { + ...BASE_REQUEST, + sourceChainId: CHAIN_ID_POLYGON, + sourceTokenAddress: NATIVE_TOKEN_ADDRESS, + sourceTokenAmount: '100000', + }; + + const result = await getRelayMaxGasStationQuote( + request, + makeFullRequest(messenger, request), + getSingleQuote, + ); + + expect(getSingleQuote).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ sourceTokenAmount: '99900' }), + expect.any(Object), + ); + expect(result).toBe(phase2Quote); + }); + + it('returns phase-1 quote when the adjusted native Max quote fails', async () => { + const phase1Quote = makeQuote({ + sourceAmountRaw: '100000', + sourceNetworkGasRaw: '100', + }); + const getSingleQuote = jest + .fn() + .mockResolvedValueOnce(phase1Quote) + .mockRejectedValueOnce(new Error('adjusted quote failed')); + const request = { + ...BASE_REQUEST, + sourceTokenAddress: getNativeTokenMock(), + sourceTokenAmount: '100000', + }; + + const result = await getRelayMaxGasStationQuote( + request, + makeFullRequest(messenger, request), + getSingleQuote, + ); + + expect(getSingleQuote).toHaveBeenCalledTimes(2); + expect(result).toBe(phase1Quote); + }); + + it('returns phase-1 quote when native network fees consume the Max amount', async () => { + const phase1Quote = makeQuote({ + sourceAmountRaw: '100', + sourceNetworkGasRaw: '100', + }); + const getSingleQuote = jest.fn().mockResolvedValue(phase1Quote); + const request = { + ...BASE_REQUEST, + sourceTokenAddress: getNativeTokenMock(), + sourceTokenAmount: '100', + }; + + const result = await getRelayMaxGasStationQuote( + request, + makeFullRequest(messenger, request), + getSingleQuote, + ); + + expect(getSingleQuote).toHaveBeenCalledTimes(1); + expect(result).toBe(phase1Quote); + }); + it('returns phase-1 quote when source chain is not gas-station eligible', async () => { const phase1Quote = makeQuote(); const getSingleQuote = jest.fn().mockResolvedValue(phase1Quote); diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-max-gas-station.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-max-gas-station.ts index f66f9c5c28..9dd5cec6af 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-max-gas-station.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-max-gas-station.ts @@ -1,6 +1,7 @@ import { createModuleLogger } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; +import { NATIVE_TOKEN_ADDRESS } from '../../constants.js'; import { projectLogger } from '../../logger.js'; import type { PayStrategyGetQuotesRequest, @@ -88,6 +89,14 @@ export async function getRelayMaxGasStationQuote( return phase1Quote; } + if (!fullRequest.accountSupports7702 && isNativeSourceToken(request)) { + return getNativeMaxQuoteWithReservedFees( + phase1Quote, + new BigNumber(sourceTokenAmount), + context, + ); + } + const nativeBalanceCheck = checkEnoughNativeBalanceIfSourceGasFeeTokenNotUsed( phase1Quote, messenger, @@ -218,6 +227,46 @@ export async function getRelayMaxGasStationQuote( return phase2Quote; } +function isNativeSourceToken(request: QuoteRequest): boolean { + const sourceTokenAddress = request.sourceTokenAddress.toLowerCase(); + + return ( + sourceTokenAddress === + getNativeToken(request.sourceChainId).toLowerCase() || + sourceTokenAddress === NATIVE_TOKEN_ADDRESS.toLowerCase() + ); +} + +async function getNativeMaxQuoteWithReservedFees( + phase1Quote: TransactionPayQuote, + sourceAmount: BigNumber, + context: MaxAmountQuoteContext, +): Promise> { + const networkFee = new BigNumber(phase1Quote.fees.sourceNetwork.max.raw); + const adjustedSourceAmount = getAdjustedSourceAmount( + sourceAmount, + networkFee, + ); + + if (!networkFee.isGreaterThan(0) || !adjustedSourceAmount.isGreaterThan(0)) { + return fallbackToPhase1( + phase1Quote, + 'Unable to reserve native network fees', + ); + } + + const adjustedQuote = await getAdjustedPhase2Quote( + adjustedSourceAmount, + { + amount: networkFee, + source: GasCostEstimateSource.Quote, + }, + context, + ); + + return adjustedQuote ?? phase1Quote; +} + function checkEnoughNativeBalanceIfSourceGasFeeTokenNotUsed( quote: TransactionPayQuote, messenger: TransactionPayControllerMessenger,