From b8936493b15e2671a4d0183cba2efbad935f7220 Mon Sep 17 00:00:00 2001 From: Daniel <80175477+dan437@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:25:27 +0200 Subject: [PATCH 1/3] feat: reserve Relay fees from native Max --- .../transaction-pay-controller/CHANGELOG.md | 1 + .../relay/relay-max-gas-station.test.ts | 36 +++++++++++++++ .../strategy/relay/relay-max-gas-station.ts | 44 +++++++++++++++++++ 3 files changed, 81 insertions(+) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 5473162cd79..219250f281b 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. - 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 346d95c4e37..6d731902af9 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 @@ -224,6 +224,42 @@ 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('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 f66f9c5c28a..c7e83bda361 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 @@ -88,6 +88,18 @@ export async function getRelayMaxGasStationQuote( return phase1Quote; } + if ( + !fullRequest.accountSupports7702 && + request.sourceTokenAddress.toLowerCase() === + getNativeToken(sourceChainId).toLowerCase() + ) { + return getNativeMaxQuoteWithReservedFees( + phase1Quote, + new BigNumber(sourceTokenAmount), + context, + ); + } + const nativeBalanceCheck = checkEnoughNativeBalanceIfSourceGasFeeTokenNotUsed( phase1Quote, messenger, @@ -218,6 +230,38 @@ export async function getRelayMaxGasStationQuote( return phase2Quote; } +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, From c40a55dc9c7c72abae1992689efe4f32bdb4e601 Mon Sep 17 00:00:00 2001 From: Daniel <80175477+dan437@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:41:43 +0200 Subject: [PATCH 2/3] feat: clarify native Relay fee reservation --- .../transaction-pay-controller/CHANGELOG.md | 2 +- .../relay/relay-max-gas-station.test.ts | 47 +++++++++++++++++++ .../strategy/relay/relay-max-gas-station.ts | 4 +- 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 219250f281b..11bf90c7054 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -16,7 +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. +- 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 6d731902af9..acac3a1a9e7 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 @@ -260,6 +260,53 @@ describe('relay-max-gas-station', () => { 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 c7e83bda361..72ea27dfde5 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 @@ -235,9 +235,7 @@ async function getNativeMaxQuoteWithReservedFees( sourceAmount: BigNumber, context: MaxAmountQuoteContext, ): Promise> { - const networkFee = new BigNumber( - phase1Quote.fees.sourceNetwork.max.raw, - ); + const networkFee = new BigNumber(phase1Quote.fees.sourceNetwork.max.raw); const adjustedSourceAmount = getAdjustedSourceAmount( sourceAmount, networkFee, From 63397aa247fb6e59f962e0e2b6c5e42ddc66345c Mon Sep 17 00:00:00 2001 From: Daniel <80175477+dan437@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:47:08 +0200 Subject: [PATCH 3/3] feat: reserve Polygon native Max fees --- .../relay/relay-max-gas-station.test.ts | 35 +++++++++++++++++++ .../strategy/relay/relay-max-gas-station.ts | 17 ++++++--- 2 files changed, 47 insertions(+), 5 deletions(-) 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 acac3a1a9e7..c9613766c79 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 { @@ -260,6 +261,40 @@ describe('relay-max-gas-station', () => { 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', 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 72ea27dfde5..9dd5cec6afd 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,11 +89,7 @@ export async function getRelayMaxGasStationQuote( return phase1Quote; } - if ( - !fullRequest.accountSupports7702 && - request.sourceTokenAddress.toLowerCase() === - getNativeToken(sourceChainId).toLowerCase() - ) { + if (!fullRequest.accountSupports7702 && isNativeSourceToken(request)) { return getNativeMaxQuoteWithReservedFees( phase1Quote, new BigNumber(sourceTokenAmount), @@ -230,6 +227,16 @@ 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,