@@ -5,13 +5,19 @@ import type { TransactionMeta } from '@metamask/transaction-controller';
55import type { Hex } from '@metamask/utils' ;
66
77import { getAcrossQuotes } from './across-quotes' ;
8+ import * as acrossTransactions from './transactions' ;
89import type { AcrossSwapApprovalResponse } from './types' ;
910import { getDefaultRemoteFeatureFlagControllerState } from '../../../../remote-feature-flag-controller/src/remote-feature-flag-controller' ;
1011import { TransactionPayStrategy } from '../../constants' ;
1112import { getMessengerMock } from '../../tests/messenger-mock' ;
1213import 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' ;
1419import { calculateGasCost } from '../../utils/gas' ;
20+ import * as quoteGasUtils from '../../utils/quote-gas' ;
1521import { getTokenFiatRate } from '../../utils/token' ;
1622
1723jest . mock ( '../../utils/token' ) ;
@@ -21,6 +27,7 @@ jest.mock('../../utils/gas', () => ({
2127} ) ) ;
2228jest . 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[] } {
108115describe ( '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