Skip to content
Open
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 @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Bump `@metamask/assets-controllers` from `^100.2.0` to `^100.2.1` ([#8162](https://github.com/MetaMask/core/pull/8162))
- Bump `@metamask/bridge-controller` from `^69.0.0` to `^69.1.0` ([#8162](https://github.com/MetaMask/core/pull/8162), [#8168](https://github.com/MetaMask/core/pull/8168))
- Bump `@metamask/bridge-status-controller` from `^68.0.1` to `^68.1.0` ([#8162](https://github.com/MetaMask/core/pull/8162), [#8168](https://github.com/MetaMask/core/pull/8168))
- Use shared quote gas estimation for Across and Relay, including EIP-7702 batch estimation on supported source chains with per-transaction fallback when batching is unavailable or fails ([#8145](https://github.com/MetaMask/core/pull/8145))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
- Use shared quote gas estimation for Across and Relay, including EIP-7702 batch estimation on supported source chains with per-transaction fallback when batching is unavailable or fails ([#8145](https://github.com/MetaMask/core/pull/8145))
- Remove duplication in gas estimation for Relay and Across strategies ([#8145](https://github.com/MetaMask/core/pull/8145))


## [16.4.1]

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,19 @@ import type { TransactionMeta } from '@metamask/transaction-controller';
import type { Hex } from '@metamask/utils';

import { getAcrossQuotes } from './across-quotes';
import * as acrossTransactions from './transactions';
import type { AcrossSwapApprovalResponse } from './types';
import { getDefaultRemoteFeatureFlagControllerState } from '../../../../remote-feature-flag-controller/src/remote-feature-flag-controller';
import { TransactionPayStrategy } from '../../constants';
import { getMessengerMock } from '../../tests/messenger-mock';
import type { QuoteRequest } from '../../types';
import { getGasBuffer, getSlippage } from '../../utils/feature-flags';
import {
getGasBuffer,
isEIP7702Chain,
getSlippage,
} from '../../utils/feature-flags';
import { calculateGasCost } from '../../utils/gas';
import * as quoteGasUtils from '../../utils/quote-gas';
import { getTokenFiatRate } from '../../utils/token';

jest.mock('../../utils/token');
Expand All @@ -22,6 +28,7 @@ jest.mock('../../utils/gas', () => ({
jest.mock('../../utils/feature-flags', () => ({
...jest.requireActual('../../utils/feature-flags'),
getGasBuffer: jest.fn(),
isEIP7702Chain: jest.fn(),
getSlippage: jest.fn(),
}));

Expand Down Expand Up @@ -109,12 +116,14 @@ describe('Across Quotes', () => {
const successfulFetchMock = jest.mocked(successfulFetch);
const getTokenFiatRateMock = jest.mocked(getTokenFiatRate);
const getGasBufferMock = jest.mocked(getGasBuffer);
const isEIP7702ChainMock = jest.mocked(isEIP7702Chain);
const getSlippageMock = jest.mocked(getSlippage);
const calculateGasCostMock = jest.mocked(calculateGasCost);

const {
messenger,
estimateGasMock,
estimateGasBatchMock,
findNetworkClientIdByChainIdMock,
getRemoteFeatureFlagControllerStateMock,
} = getMessengerMock();
Expand Down Expand Up @@ -149,6 +158,7 @@ describe('Across Quotes', () => {
});

getGasBufferMock.mockReturnValue(1.0);
isEIP7702ChainMock.mockReturnValue(false);
getSlippageMock.mockReturnValue(0.005);

findNetworkClientIdByChainIdMock.mockReturnValue('mainnet');
Expand Down Expand Up @@ -809,6 +819,127 @@ describe('Across Quotes', () => {
});
});

it('uses batch gas estimation on EIP-7702-supported chains when multiple transactions are submitted', async () => {
isEIP7702ChainMock.mockReturnValue(true);
estimateGasBatchMock.mockResolvedValue({
totalGasLimit: 51000,
gasLimits: [51000],
});

successfulFetchMock.mockResolvedValue({
json: async () => ({
...QUOTE_MOCK,
approvalTxns: [
{
chainId: 1,
data: '0xaaaa' as Hex,
to: '0xapprove1' as Hex,
value: '0x1' as Hex,
},
],
}),
} as Response);

const result = await getAcrossQuotes({
messenger,
requests: [QUOTE_REQUEST_MOCK],
transaction: TRANSACTION_META_MOCK,
});

expect(estimateGasBatchMock).toHaveBeenCalledWith({
chainId: '0x1',
from: FROM_MOCK,
transactions: [
expect.objectContaining({
data: '0xaaaa',
to: '0xapprove1',
value: '0x1',
}),
expect.objectContaining({
data: QUOTE_MOCK.swapTx.data,
to: QUOTE_MOCK.swapTx.to,
}),
],
});
expect(
(result[0].original.metamask.gasLimits as { batch?: unknown }).batch,
).toStrictEqual({
estimate: 51000,
max: 51000,
});
expect(calculateGasCostMock).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
chainId: '0x1',
gas: 51000,
maxFeePerGas: '0x1',
maxPriorityFeePerGas: '0x1',
}),
);
expect(calculateGasCostMock).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
chainId: '0x1',
gas: 51000,
isMax: true,
maxFeePerGas: '0x1',
maxPriorityFeePerGas: '0x1',
}),
);
});

it('falls back to per-transaction gas estimation when batch estimation fails on EIP-7702-supported chains', async () => {
isEIP7702ChainMock.mockReturnValue(true);
estimateGasBatchMock.mockRejectedValue(
new Error('Batch estimation failed'),
);
estimateGasMock
.mockResolvedValueOnce({
gas: '0x7530',
simulationFails: undefined,
})
.mockResolvedValueOnce({
gas: '0x5208',
simulationFails: undefined,
});

successfulFetchMock.mockResolvedValue({
json: async () => ({
...QUOTE_MOCK,
approvalTxns: [
{
chainId: 1,
data: '0xaaaa' as Hex,
to: '0xapprove1' as Hex,
value: '0x1' as Hex,
},
],
}),
} as Response);

const result = await getAcrossQuotes({
messenger,
requests: [QUOTE_REQUEST_MOCK],
transaction: TRANSACTION_META_MOCK,
});

expect(estimateGasBatchMock).toHaveBeenCalledTimes(1);
expect(estimateGasMock).toHaveBeenCalledTimes(2);
expect(
(result[0].original.metamask.gasLimits as { batch?: unknown }).batch,
).toBeUndefined();
expect(result[0].original.metamask.gasLimits.approval).toStrictEqual([
{
estimate: 30000,
max: 30000,
},
]);
expect(result[0].original.metamask.gasLimits.swap).toStrictEqual({
estimate: 21000,
max: 21000,
});
});

it('uses swapTx.gas from Across response when provided', async () => {
successfulFetchMock.mockResolvedValue({
json: async () => ({
Expand Down Expand Up @@ -863,6 +994,115 @@ describe('Across Quotes', () => {
});
});

it('throws when the shared gas estimator omits the swap gas result', async () => {
const estimateQuoteGasLimitsSpy = jest.spyOn(
quoteGasUtils,
'estimateQuoteGasLimits',
);

estimateQuoteGasLimitsSpy.mockResolvedValueOnce({
gasLimits: [],
totalGasEstimate: 0,
totalGasLimit: 0,
usedBatch: false,
});

successfulFetchMock.mockResolvedValue({
json: async () => QUOTE_MOCK,
} as Response);

await expect(
getAcrossQuotes({
messenger,
requests: [QUOTE_REQUEST_MOCK],
transaction: TRANSACTION_META_MOCK,
}),
).rejects.toThrow(
'Failed to fetch Across quotes: Error: Across swap gas estimate missing',
);

estimateQuoteGasLimitsSpy.mockRestore();
});

it('falls back to the swap chain id when an approval transaction chain id is missing during cost calculation', async () => {
const estimateQuoteGasLimitsSpy = jest.spyOn(
quoteGasUtils,
'estimateQuoteGasLimits',
);
const orderedTransactionsSpy = jest.spyOn(
acrossTransactions,
'getAcrossOrderedTransactions',
);

estimateQuoteGasLimitsSpy.mockResolvedValueOnce({
gasLimits: [
{
estimate: 30000,
max: 35000,
source: 'estimated',
},
{
estimate: 21000,
max: 22000,
source: 'estimated',
},
],
totalGasEstimate: 51000,
totalGasLimit: 57000,
usedBatch: false,
});
orderedTransactionsSpy.mockReturnValueOnce([
{
chainId: 1,
data: '0xaaaa' as Hex,
kind: 'approval',
to: '0xapprove1' as Hex,
},
{
...QUOTE_MOCK.swapTx,
kind: 'swap',
},
]);

successfulFetchMock.mockResolvedValue({
json: async () => ({
...QUOTE_MOCK,
approvalTxns: [
{
chainId: undefined,
data: '0xaaaa' as Hex,
to: '0xapprove1' as Hex,
},
],
}),
} as Response);

await getAcrossQuotes({
messenger,
requests: [QUOTE_REQUEST_MOCK],
transaction: TRANSACTION_META_MOCK,
});

expect(calculateGasCostMock).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
chainId: '0x1',
gas: 30000,
}),
);
expect(calculateGasCostMock).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
chainId: '0x1',
gas: 35000,
isMax: true,
}),
);

orderedTransactionsSpy.mockRestore();
estimateQuoteGasLimitsSpy.mockRestore();
});

it('handles missing approval transactions in Across quote response', async () => {
successfulFetchMock.mockResolvedValue({
json: async () => ({
Expand Down
Loading
Loading