Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/transaction-pay-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<RelayQuote>,
sourceAmount: BigNumber,
context: MaxAmountQuoteContext,
): Promise<TransactionPayQuote<RelayQuote>> {
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<RelayQuote>,
messenger: TransactionPayControllerMessenger,
Expand Down