Skip to content

Commit 0c900ca

Browse files
feat(transaction-pay-controller): Add shared EIP-7702 quote gas estimation
1 parent 025bf8c commit 0c900ca

11 files changed

Lines changed: 1315 additions & 325 deletions

File tree

packages/transaction-pay-controller/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3535
- Add Across pay strategy support ([#7886](https://github.com/MetaMask/core/pull/7886))
3636
- Add `fiatPayment` transaction state into `transactionData` and `updateFiatPayment` callback action ([#8093](https://github.com/MetaMask/core/pull/8093))
3737

38+
### Changed
39+
40+
- 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))
41+
3842
## [16.3.0]
3943

4044
### Added

packages/transaction-pay-controller/src/strategy/across/across-quotes.test.ts

Lines changed: 241 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,19 @@ import type { TransactionMeta } from '@metamask/transaction-controller';
55
import type { Hex } from '@metamask/utils';
66

77
import { getAcrossQuotes } from './across-quotes';
8+
import * as acrossTransactions from './transactions';
89
import type { AcrossSwapApprovalResponse } from './types';
910
import { getDefaultRemoteFeatureFlagControllerState } from '../../../../remote-feature-flag-controller/src/remote-feature-flag-controller';
1011
import { TransactionPayStrategy } from '../../constants';
1112
import { getMessengerMock } from '../../tests/messenger-mock';
1213
import type { QuoteRequest } from '../../types';
13-
import { getGasBuffer, getSlippage } from '../../utils/feature-flags';
14+
import {
15+
getEIP7702SupportedChains,
16+
getGasBuffer,
17+
getSlippage,
18+
} from '../../utils/feature-flags';
1419
import { calculateGasCost } from '../../utils/gas';
20+
import * as quoteGasUtils from '../../utils/quote-gas';
1521
import { getTokenFiatRate } from '../../utils/token';
1622

1723
jest.mock('../../utils/token');
@@ -21,6 +27,7 @@ jest.mock('../../utils/gas', () => ({
2127
}));
2228
jest.mock('../../utils/feature-flags', () => ({
2329
...jest.requireActual('../../utils/feature-flags'),
30+
getEIP7702SupportedChains: jest.fn(),
2431
getGasBuffer: jest.fn(),
2532
getSlippage: jest.fn(),
2633
}));
@@ -108,13 +115,15 @@ function getRequestBody(): { actions: unknown[] } {
108115
describe('Across Quotes', () => {
109116
const successfulFetchMock = jest.mocked(successfulFetch);
110117
const getTokenFiatRateMock = jest.mocked(getTokenFiatRate);
118+
const getEIP7702SupportedChainsMock = jest.mocked(getEIP7702SupportedChains);
111119
const getGasBufferMock = jest.mocked(getGasBuffer);
112120
const getSlippageMock = jest.mocked(getSlippage);
113121
const calculateGasCostMock = jest.mocked(calculateGasCost);
114122

115123
const {
116124
messenger,
117125
estimateGasMock,
126+
estimateGasBatchMock,
118127
findNetworkClientIdByChainIdMock,
119128
getRemoteFeatureFlagControllerStateMock,
120129
} = getMessengerMock();
@@ -148,6 +157,7 @@ describe('Across Quotes', () => {
148157
usd: '3.45',
149158
});
150159

160+
getEIP7702SupportedChainsMock.mockReturnValue([]);
151161
getGasBufferMock.mockReturnValue(1.0);
152162
getSlippageMock.mockReturnValue(0.005);
153163

@@ -809,6 +819,127 @@ describe('Across Quotes', () => {
809819
});
810820
});
811821

822+
it('uses batch gas estimation on EIP-7702-supported chains when multiple transactions are submitted', async () => {
823+
getEIP7702SupportedChainsMock.mockReturnValue(['0x1']);
824+
estimateGasBatchMock.mockResolvedValue({
825+
totalGasLimit: 51000,
826+
gasLimits: [51000],
827+
});
828+
829+
successfulFetchMock.mockResolvedValue({
830+
json: async () => ({
831+
...QUOTE_MOCK,
832+
approvalTxns: [
833+
{
834+
chainId: 1,
835+
data: '0xaaaa' as Hex,
836+
to: '0xapprove1' as Hex,
837+
value: '0x1' as Hex,
838+
},
839+
],
840+
}),
841+
} as Response);
842+
843+
const result = await getAcrossQuotes({
844+
messenger,
845+
requests: [QUOTE_REQUEST_MOCK],
846+
transaction: TRANSACTION_META_MOCK,
847+
});
848+
849+
expect(estimateGasBatchMock).toHaveBeenCalledWith({
850+
chainId: '0x1',
851+
from: FROM_MOCK,
852+
transactions: [
853+
expect.objectContaining({
854+
data: '0xaaaa',
855+
to: '0xapprove1',
856+
value: '0x1',
857+
}),
858+
expect.objectContaining({
859+
data: QUOTE_MOCK.swapTx.data,
860+
to: QUOTE_MOCK.swapTx.to,
861+
}),
862+
],
863+
});
864+
expect(
865+
(result[0].original.metamask.gasLimits as { batch?: unknown }).batch,
866+
).toStrictEqual({
867+
estimate: 51000,
868+
max: 51000,
869+
});
870+
expect(calculateGasCostMock).toHaveBeenNthCalledWith(
871+
1,
872+
expect.objectContaining({
873+
chainId: '0x1',
874+
gas: 51000,
875+
maxFeePerGas: '0x1',
876+
maxPriorityFeePerGas: '0x1',
877+
}),
878+
);
879+
expect(calculateGasCostMock).toHaveBeenNthCalledWith(
880+
2,
881+
expect.objectContaining({
882+
chainId: '0x1',
883+
gas: 51000,
884+
isMax: true,
885+
maxFeePerGas: '0x1',
886+
maxPriorityFeePerGas: '0x1',
887+
}),
888+
);
889+
});
890+
891+
it('falls back to per-transaction gas estimation when batch estimation fails on EIP-7702-supported chains', async () => {
892+
getEIP7702SupportedChainsMock.mockReturnValue(['0x1']);
893+
estimateGasBatchMock.mockRejectedValue(
894+
new Error('Batch estimation failed'),
895+
);
896+
estimateGasMock
897+
.mockResolvedValueOnce({
898+
gas: '0x7530',
899+
simulationFails: undefined,
900+
})
901+
.mockResolvedValueOnce({
902+
gas: '0x5208',
903+
simulationFails: undefined,
904+
});
905+
906+
successfulFetchMock.mockResolvedValue({
907+
json: async () => ({
908+
...QUOTE_MOCK,
909+
approvalTxns: [
910+
{
911+
chainId: 1,
912+
data: '0xaaaa' as Hex,
913+
to: '0xapprove1' as Hex,
914+
value: '0x1' as Hex,
915+
},
916+
],
917+
}),
918+
} as Response);
919+
920+
const result = await getAcrossQuotes({
921+
messenger,
922+
requests: [QUOTE_REQUEST_MOCK],
923+
transaction: TRANSACTION_META_MOCK,
924+
});
925+
926+
expect(estimateGasBatchMock).toHaveBeenCalledTimes(1);
927+
expect(estimateGasMock).toHaveBeenCalledTimes(2);
928+
expect(
929+
(result[0].original.metamask.gasLimits as { batch?: unknown }).batch,
930+
).toBeUndefined();
931+
expect(result[0].original.metamask.gasLimits.approval).toStrictEqual([
932+
{
933+
estimate: 30000,
934+
max: 30000,
935+
},
936+
]);
937+
expect(result[0].original.metamask.gasLimits.swap).toStrictEqual({
938+
estimate: 21000,
939+
max: 21000,
940+
});
941+
});
942+
812943
it('uses swapTx.gas from Across response when provided', async () => {
813944
successfulFetchMock.mockResolvedValue({
814945
json: async () => ({
@@ -863,6 +994,115 @@ describe('Across Quotes', () => {
863994
});
864995
});
865996

997+
it('throws when the shared gas estimator omits the swap gas result', async () => {
998+
const estimateQuoteGasLimitsSpy = jest.spyOn(
999+
quoteGasUtils,
1000+
'estimateQuoteGasLimits',
1001+
);
1002+
1003+
estimateQuoteGasLimitsSpy.mockResolvedValueOnce({
1004+
gasLimits: [],
1005+
totalGasEstimate: 0,
1006+
totalGasLimit: 0,
1007+
usedBatch: false,
1008+
});
1009+
1010+
successfulFetchMock.mockResolvedValue({
1011+
json: async () => QUOTE_MOCK,
1012+
} as Response);
1013+
1014+
await expect(
1015+
getAcrossQuotes({
1016+
messenger,
1017+
requests: [QUOTE_REQUEST_MOCK],
1018+
transaction: TRANSACTION_META_MOCK,
1019+
}),
1020+
).rejects.toThrow(
1021+
'Failed to fetch Across quotes: Error: Across swap gas estimate missing',
1022+
);
1023+
1024+
estimateQuoteGasLimitsSpy.mockRestore();
1025+
});
1026+
1027+
it('falls back to the swap chain id when an approval transaction chain id is missing during cost calculation', async () => {
1028+
const estimateQuoteGasLimitsSpy = jest.spyOn(
1029+
quoteGasUtils,
1030+
'estimateQuoteGasLimits',
1031+
);
1032+
const orderedTransactionsSpy = jest.spyOn(
1033+
acrossTransactions,
1034+
'getAcrossOrderedTransactions',
1035+
);
1036+
1037+
estimateQuoteGasLimitsSpy.mockResolvedValueOnce({
1038+
gasLimits: [
1039+
{
1040+
estimate: 30000,
1041+
max: 35000,
1042+
source: 'estimated',
1043+
},
1044+
{
1045+
estimate: 21000,
1046+
max: 22000,
1047+
source: 'estimated',
1048+
},
1049+
],
1050+
totalGasEstimate: 51000,
1051+
totalGasLimit: 57000,
1052+
usedBatch: false,
1053+
});
1054+
orderedTransactionsSpy.mockReturnValueOnce([
1055+
{
1056+
chainId: 1,
1057+
data: '0xaaaa' as Hex,
1058+
kind: 'approval',
1059+
to: '0xapprove1' as Hex,
1060+
},
1061+
{
1062+
...QUOTE_MOCK.swapTx,
1063+
kind: 'swap',
1064+
},
1065+
]);
1066+
1067+
successfulFetchMock.mockResolvedValue({
1068+
json: async () => ({
1069+
...QUOTE_MOCK,
1070+
approvalTxns: [
1071+
{
1072+
chainId: undefined,
1073+
data: '0xaaaa' as Hex,
1074+
to: '0xapprove1' as Hex,
1075+
},
1076+
],
1077+
}),
1078+
} as Response);
1079+
1080+
await getAcrossQuotes({
1081+
messenger,
1082+
requests: [QUOTE_REQUEST_MOCK],
1083+
transaction: TRANSACTION_META_MOCK,
1084+
});
1085+
1086+
expect(calculateGasCostMock).toHaveBeenNthCalledWith(
1087+
1,
1088+
expect.objectContaining({
1089+
chainId: '0x1',
1090+
gas: 30000,
1091+
}),
1092+
);
1093+
expect(calculateGasCostMock).toHaveBeenNthCalledWith(
1094+
3,
1095+
expect.objectContaining({
1096+
chainId: '0x1',
1097+
gas: 35000,
1098+
isMax: true,
1099+
}),
1100+
);
1101+
1102+
orderedTransactionsSpy.mockRestore();
1103+
estimateQuoteGasLimitsSpy.mockRestore();
1104+
});
1105+
8661106
it('handles missing approval transactions in Across quote response', async () => {
8671107
successfulFetchMock.mockResolvedValue({
8681108
json: async () => ({

0 commit comments

Comments
 (0)