diff --git a/src/SwapsController.test.ts b/src/SwapsController.test.ts index 644d3409..c094c4d1 100644 --- a/src/SwapsController.test.ts +++ b/src/SwapsController.test.ts @@ -1,18 +1,25 @@ -import { ComposableController } from '@metamask/composable-controller'; - -import SwapsController, { - INITIAL_CHAIN_DATA, - isGasFeeStateEthGasPrice, - isGasFeeStateLegacy, -} from './SwapsController'; -import * as swapsUtil from './swapsUtil'; -import { Quote } from './swapsInterfaces'; -import BigNumber from 'bignumber.js'; -import { BaseControllerV1 } from '@metamask/base-controller'; -import { GasFeeEstimates } from '@metamask/gas-fee-controller'; import { ChainId } from '@metamask/controller-utils'; - -const POLL_COUNT_LIMIT = 3; +import { GasFeeEstimates } from '@metamask/gas-fee-controller'; +import SwapsController from './SwapsController'; +import { Quote, SwapsControllerMessenger } from './types'; +import * as swapsUtil from './swapsUtil'; +import { Provider } from '@metamask/network-controller'; + +const INITIAL_CONTROLLER_OPTIONS = { + pollCountLimit: 3, + fetchAggregatorMetadataThreshold: 1000 * 60 * 60 * 24 * 15, + fetchTokensThreshold: 1000 * 60 * 60 * 24, + fetchTopAssetsThreshold: 1000 * 60 * 30, + chainId: swapsUtil.ETH_CHAIN_ID, + supportedChainIds: [ + swapsUtil.ETH_CHAIN_ID, + swapsUtil.BSC_CHAIN_ID, + swapsUtil.SWAPS_TESTNET_CHAIN_ID, + swapsUtil.POLYGON_CHAIN_ID, + swapsUtil.AVALANCHE_CHAIN_ID, + ], + clientId: undefined, +}; const API_TRADES: { [key: string]: Quote; @@ -46,10 +53,10 @@ const API_TRADES: { gasMultiplier: 1.5, quoteRefreshSeconds: 60, savings: { - total: new BigNumber(0), - performance: new BigNumber(0), - fee: new BigNumber(0), - medianMetaMaskFee: new BigNumber(0), + total: '0', + performance: '0', + fee: '0', + medianMetaMaskFee: '0', }, gasEstimate: '100000', gasEstimateWithRefund: '90000', @@ -86,10 +93,10 @@ const API_TRADES: { gasMultiplier: 1.5, quoteRefreshSeconds: 60, savings: { - total: new BigNumber(0), - performance: new BigNumber(0), - fee: new BigNumber(0), - medianMetaMaskFee: new BigNumber(0), + total: '0', + performance: '0', + fee: '0', + medianMetaMaskFee: '0', }, gasEstimate: '100000', gasEstimateWithRefund: '90000', @@ -99,18 +106,18 @@ const API_TRADES: { }, }; -const mockFlags: { [key: string]: any } = { - estimateGas: null, -}; +// Create a single mock object +const messengerMock = { + call: jest.fn(), + registerActionHandler: jest.fn(), + registerInitialEventPayload: jest.fn(), + publish: jest.fn(), +} as unknown as jest.Mocked; jest.mock('@metamask/eth-query', () => jest.fn().mockImplementation(() => { return { estimateGas: (_transaction: any, callback: any) => { - if (mockFlags.estimateGas) { - callback(new Error(mockFlags.estimateGas)); - return; - } callback(undefined, '0x0'); }, gasPrice: (callback: any) => { @@ -173,19 +180,18 @@ describe('SwapsController', () => { estimatedGasFeeTimeBounds: {}, gasEstimateType: 'none', })); - - fetchGasFeeEstimates = jest.fn(); + fetchEstimatedMultiLayerL1Fee = jest.fn().mockImplementation(() => '0x0'); swapsController = new SwapsController( { + ...INITIAL_CONTROLLER_OPTIONS, + messenger: messengerMock, + // TODO: Remove once GasFeeController exports this action type fetchGasFeeEstimates, fetchEstimatedMultiLayerL1Fee, }, - { - pollCountLimit: POLL_COUNT_LIMIT, - }, + swapsUtil.getDefaultSwapsControllerState(), ); - new ComposableController([swapsController as BaseControllerV1]); swapsUtilFetchTokens = jest .spyOn(swapsUtil, 'fetchTokens') @@ -217,23 +223,42 @@ describe('SwapsController', () => { swapsUtilEstimateGas.mockRestore(); }); - it('should set default config', () => { - expect(swapsController.config).toStrictEqual(swapsController.defaultConfig); - expect(swapsController.config).toStrictEqual({ - chainId: '0x1', - supportedChainIds: ['0x1', '0x38', '0x539', '0x89', '0xa86a'], - maxGasLimit: 2500000, - pollCountLimit: 3, - fetchAggregatorMetadataThreshold: 1000 * 60 * 60 * 24 * 15, - fetchTokensThreshold: 1000 * 60 * 60 * 24, - fetchTopAssetsThreshold: 1000 * 60 * 30, - provider: undefined, - clientId: undefined, - }); + it('should set default options', () => { + expect(swapsController.__test__getInternal('#chainId')).toStrictEqual( + INITIAL_CONTROLLER_OPTIONS.chainId, + ); + expect( + swapsController.__test__getInternal('#supportedChainIds'), + ).toStrictEqual(INITIAL_CONTROLLER_OPTIONS.supportedChainIds); + expect( + swapsController.__test__getInternal('#pollCountLimit'), + ).toStrictEqual(INITIAL_CONTROLLER_OPTIONS.pollCountLimit); + expect( + swapsController.__test__getInternal('#fetchAggregatorMetadataThreshold'), + ).toStrictEqual( + INITIAL_CONTROLLER_OPTIONS.fetchAggregatorMetadataThreshold, + ); + expect( + swapsController.__test__getInternal('#fetchTokensThreshold'), + ).toStrictEqual(INITIAL_CONTROLLER_OPTIONS.fetchTokensThreshold); + expect( + swapsController.__test__getInternal('#fetchTopAssetsThreshold'), + ).toStrictEqual(INITIAL_CONTROLLER_OPTIONS.fetchTopAssetsThreshold); + expect(swapsController.__test__getInternal('#clientId')).toStrictEqual( + INITIAL_CONTROLLER_OPTIONS.clientId, + ); + expect(swapsController.__test__getInternal('#fetchGasFeeEstimates')).toBe( + fetchGasFeeEstimates, + ); + expect( + swapsController.__test__getInternal('#fetchEstimatedMultiLayerL1Fee'), + ).toBe(fetchEstimatedMultiLayerL1Fee); }); it('should set default state', () => { - expect(swapsController.state).toStrictEqual(swapsController.defaultState); + expect(swapsController.state).toStrictEqual( + swapsUtil.getDefaultSwapsControllerState(), + ); expect(swapsController.state).toStrictEqual({ quotes: {}, quoteValues: {}, @@ -285,9 +310,46 @@ describe('SwapsController', () => { }); }); + it('should set default options if not present', () => { + swapsController = new SwapsController( + { + messenger: messengerMock, + fetchGasFeeEstimates, + fetchEstimatedMultiLayerL1Fee, + }, + {}, + ); + + expect(swapsController.__test__getInternal('#chainId')).toStrictEqual( + INITIAL_CONTROLLER_OPTIONS.chainId, + ); + expect( + swapsController.__test__getInternal('#supportedChainIds'), + ).toStrictEqual(INITIAL_CONTROLLER_OPTIONS.supportedChainIds); + expect( + swapsController.__test__getInternal('#pollCountLimit'), + ).toStrictEqual(INITIAL_CONTROLLER_OPTIONS.pollCountLimit); + expect( + swapsController.__test__getInternal('#fetchAggregatorMetadataThreshold'), + ).toStrictEqual( + INITIAL_CONTROLLER_OPTIONS.fetchAggregatorMetadataThreshold, + ); + expect( + swapsController.__test__getInternal('#fetchTokensThreshold'), + ).toStrictEqual(INITIAL_CONTROLLER_OPTIONS.fetchTokensThreshold); + expect( + swapsController.__test__getInternal('#fetchTopAssetsThreshold'), + ).toStrictEqual(INITIAL_CONTROLLER_OPTIONS.fetchTopAssetsThreshold); + expect(swapsController.__test__getInternal('#clientId')).toStrictEqual( + INITIAL_CONTROLLER_OPTIONS.clientId, + ); + }); + it('should set a default value for pollingCyclesLeft', () => { swapsController = new SwapsController( { + ...INITIAL_CONTROLLER_OPTIONS, + messenger: messengerMock, fetchGasFeeEstimates, fetchEstimatedMultiLayerL1Fee, }, @@ -296,24 +358,20 @@ describe('SwapsController', () => { expect(swapsController.state.pollingCyclesLeft).toBe(3); }); - it('should use INITIAL_CHAIN_DATA when chainCache does not have data for the chainId', () => { + it('should use swapsUtil.INITIAL_CHAIN_DATA when chainCache does not have data for the chainId', () => { const chainId = ChainId.aurora; - // add to supportedChainIds - swapsController.configure({ - supportedChainIds: [chainId], - }); + swapsController.__test__updatePrivate('#supportedChainIds', [chainId]); - // clear chainCache - swapsController.update({ + // add to supportedChainIds, clear chainCache and set chainId + swapsController.__test__updateState({ chainCache: {}, }); - // set chainId - swapsController.configure({ chainId }); + swapsController.setChainId(chainId); const cachedData = swapsController.state.chainCache[chainId]; - expect(cachedData).toEqual(INITIAL_CHAIN_DATA); + expect(cachedData).toEqual(swapsUtil.INITIAL_CHAIN_DATA); }); describe('provider', () => { @@ -323,99 +381,119 @@ describe('SwapsController', () => { type: 'test', chainId: '0x1', rpcUrl: 'test', - }; - expect(swapsController.defaultConfig.provider).toBeUndefined(); - swapsController.configure({ - provider, - }); - expect(swapsController.defaultConfig.provider.name).toBe(provider.name); + } as unknown as Provider; + + expect(swapsController.__test__getInternal('#web3')).toBeUndefined(); + expect(swapsController.__test__getInternal('#ethQuery')).toBeUndefined(); + + swapsController.setProvider(provider); + + expect(swapsController.__test__getInternal('#web3')).toBeDefined(); + expect(swapsController.__test__getInternal('#ethQuery')).toBeDefined(); }); }); - describe('chain cache', () => { - it('should update cache configuration', () => { - expect(swapsController.config).toMatchObject({ - fetchAggregatorMetadataThreshold: 1000 * 60 * 60 * 24 * 15, - fetchTokensThreshold: 1000 * 60 * 60 * 24, - fetchTopAssetsThreshold: 1000 * 60 * 30, - }); + describe('provider', () => { + it('should set provider with options', () => { + const provider = { + name: 'test', + type: 'test', + chainId: '0x1', + rpcUrl: 'test', + } as unknown as Provider; - swapsController.configure({ - fetchAggregatorMetadataThreshold: 0, - fetchTokensThreshold: 0, - fetchTopAssetsThreshold: 0, - }); + expect(swapsController.__test__getInternal('#web3')).toBeUndefined(); + expect(swapsController.__test__getInternal('#ethQuery')).toBeUndefined(); - expect(swapsController.config).toMatchObject({ - fetchAggregatorMetadataThreshold: 0, - fetchTokensThreshold: 0, - fetchTopAssetsThreshold: 0, + swapsController.setProvider(provider, { + chainId: '0x23', + pollCountLimit: 10, }); + + expect(swapsController.__test__getInternal('#web3')).toBeDefined(); + expect(swapsController.__test__getInternal('#ethQuery')).toBeDefined(); + expect(swapsController.__test__getInternal('#chainId')).toBe('0x23'); + expect(swapsController.__test__getInternal('#pollCountLimit')).toBe(10); }); + }); + describe('chain cache', () => { it('should update chainId configuration', () => { - swapsController.configure({ chainId: '0x23' }); - expect(swapsController.config.chainId).toBe('0x23'); - - swapsController.configure({ chainId: '0x24' }); - expect(swapsController.config.chainId).toBe('0x24'); - - swapsController.configure({ chainId: '0x291' }); - expect(swapsController.config.chainId).toBe('0x291'); + swapsController.__test__updatePrivate('#supportedChainIds', [ + '0x23', + '0x24', + '0x291', + ]); + swapsController.setChainId('0x23'); + expect(swapsController.__test__getInternal('#chainId')).toBe('0x23'); + + swapsController.setChainId('0x24'); + expect(swapsController.__test__getInternal('#chainId')).toBe('0x24'); + + swapsController.setChainId('0x291'); + expect(swapsController.__test__getInternal('#chainId')).toBe('0x291'); }); it('should create default cache for supported chainIds', () => { - swapsController.configure({ - supportedChainIds: ['0x23', '0x24', '0x291'], - }); - swapsController.configure({ chainId: '0x23' }); + swapsController.__test__updatePrivate('#supportedChainIds', [ + '0x23', + '0x24', + '0x291', + ]); + swapsController.setChainId('0x23'); expect(swapsController.state.chainCache['0x23']).toStrictEqual( - INITIAL_CHAIN_DATA, + swapsUtil.INITIAL_CHAIN_DATA, ); - swapsController.configure({ chainId: '0x24' }); + swapsController.setChainId('0x24'); expect(swapsController.state.chainCache['0x24']).toStrictEqual( - INITIAL_CHAIN_DATA, + swapsUtil.INITIAL_CHAIN_DATA, ); - swapsController.configure({ chainId: '0x291' }); + swapsController.setChainId('0x291'); expect(swapsController.state.chainCache['0x291']).toStrictEqual( - INITIAL_CHAIN_DATA, + swapsUtil.INITIAL_CHAIN_DATA, ); }); - it('should not create default cache for supported chainIds', () => { - swapsController.configure({ chainId: '0x23' }); + it('should not create default cache for unsupported chainIds', () => { + swapsController.setChainId('0x23'); expect(swapsController.state.chainCache['0x23']).toBeUndefined(); - swapsController.configure({ chainId: '0x24' }); + swapsController.setChainId('0x24'); expect(swapsController.state.chainCache['0x24']).toBeUndefined(); - swapsController.configure({ chainId: '0x291' }); + swapsController.setChainId('0x291'); expect(swapsController.state.chainCache['0x291']).toBeUndefined(); }); it('should load existing cache for chainId', () => { + swapsController.__test__updatePrivate('#supportedChainIds', [ + '0x23', + '0x24', + '0x291', + ]); + const chainData23 = { - ...INITIAL_CHAIN_DATA, + ...swapsUtil.INITIAL_CHAIN_DATA, tokensLastFetched: 231, topAssetsLastFetched: 232, aggregatorMetadataLastFetched: 233, }; const chainData24 = { - ...INITIAL_CHAIN_DATA, + ...swapsUtil.INITIAL_CHAIN_DATA, tokensLastFetched: 241, topAssetsLastFetched: 242, aggregatorMetadataLastFetched: 243, }; const chainData0x123 = { - ...INITIAL_CHAIN_DATA, + ...swapsUtil.INITIAL_CHAIN_DATA, tokensLastFetched: 2911, topAssetsLastFetched: 2912, aggregatorMetadataLastFetched: 2913, }; - swapsController.update({ + swapsController.__test__updateState({ chainCache: { '0x23': chainData23, '0x24': chainData24, @@ -423,17 +501,17 @@ describe('SwapsController', () => { }, }); - swapsController.configure({ chainId: '0x23' }); + swapsController.setChainId('0x23'); expect(swapsController.state.chainCache['0x23']).toStrictEqual( chainData23, ); - swapsController.configure({ chainId: '0x24' }); + swapsController.setChainId('0x24'); expect(swapsController.state.chainCache['0x24']).toStrictEqual( chainData24, ); - swapsController.configure({ chainId: '0x291' }); + swapsController.setChainId('0x291'); expect(swapsController.state.chainCache['0x291']).toStrictEqual( chainData0x123, ); @@ -442,7 +520,7 @@ describe('SwapsController', () => { describe('tokens cache', () => { it('should fetch tokens when no tokens in state', async () => { - swapsController.update({ + swapsController.__test__updateState({ tokens: [], }); await swapsController.fetchTokenWithCache(); @@ -450,7 +528,7 @@ describe('SwapsController', () => { }); it('should fetch tokens when last fetched is 0', async () => { - swapsController.update({ + swapsController.__test__updateState({ tokens: [], tokensLastFetched: 0, }); @@ -460,8 +538,8 @@ describe('SwapsController', () => { it('should fetch tokens when last fetched is over threshold', async () => { const threshold = 5000; - swapsController.configure({ fetchTokensThreshold: threshold }); - swapsController.update({ + swapsController.__test__updatePrivate('#fetchTokensThreshold', threshold); + swapsController.__test__updateState({ tokens: [], tokensLastFetched: Date.now() - threshold - 1, }); @@ -470,7 +548,7 @@ describe('SwapsController', () => { }); it('should not fetch tokens when no threshold reached', async () => { - swapsController.update({ + swapsController.__test__updateState({ tokens: [], tokensLastFetched: Date.now(), }); @@ -479,7 +557,7 @@ describe('SwapsController', () => { }); it('should not fetch tokens when no threshold reached or tokens are available', async () => { - swapsController.update({ + swapsController.__test__updateState({ tokens: [], tokensLastFetched: Date.now(), }); @@ -492,8 +570,8 @@ describe('SwapsController', () => { throw new Error(); }); const threshold = 5000; - swapsController.configure({ fetchTokensThreshold: threshold }); - swapsController.update({ + swapsController.__test__updatePrivate('#fetchTokensThreshold', threshold); + swapsController.__test__updateState({ tokens: [], tokensLastFetched: Date.now() - threshold - 1, }); @@ -503,12 +581,13 @@ describe('SwapsController', () => { }); it('should not fetch tokens if chain id is not supported', async () => { - swapsController.configure({ - supportedChainIds: ['0x1'], + swapsController.__test__updateState({ + tokens: [], + tokensLastFetched: 0, }); - swapsController.state.tokens = []; - swapsController.state.tokensLastFetched = 0; - swapsController.configure({ chainId: '0x2' }); + swapsController.__test__updatePrivate('#supportedChainIds', ['0x1']); + swapsController.setChainId('0x2'); + await swapsController.fetchTokenWithCache(); expect(swapsUtilFetchTokens).not.toHaveBeenCalled(); }); @@ -516,7 +595,7 @@ describe('SwapsController', () => { describe('top assets cache', () => { it('should fetch top assets when no top assets in state', async () => { - swapsController.update({ + swapsController.__test__updateState({ topAssets: null, }); await swapsController.fetchTopAssetsWithCache(); @@ -524,7 +603,7 @@ describe('SwapsController', () => { }); it('should fetch top assets when last fetched is 0', async () => { - swapsController.update({ + swapsController.__test__updateState({ topAssets: [], topAssetsLastFetched: 0, }); @@ -534,8 +613,11 @@ describe('SwapsController', () => { it('should fetch top assets when last fetched is over threshold', async () => { const threshold = 5000; - swapsController.configure({ fetchTopAssetsThreshold: threshold }); - swapsController.update({ + swapsController.__test__updatePrivate( + '#fetchTopAssetsThreshold', + threshold, + ); + swapsController.__test__updateState({ topAssets: [], topAssetsLastFetched: Date.now() - threshold - 1, }); @@ -544,7 +626,7 @@ describe('SwapsController', () => { }); it('should not fetch top assets when no threshold reached', async () => { - swapsController.update({ + swapsController.__test__updateState({ topAssets: [], topAssetsLastFetched: Date.now(), }); @@ -553,7 +635,7 @@ describe('SwapsController', () => { }); it('should not fetch top assets when no threshold reached or tokens are available', async () => { - swapsController.update({ + swapsController.__test__updateState({ topAssets: [], topAssetsLastFetched: Date.now(), }); @@ -566,8 +648,11 @@ describe('SwapsController', () => { throw new Error(); }); const threshold = 5000; - swapsController.configure({ fetchTopAssetsThreshold: threshold }); - swapsController.update({ + swapsController.__test__updatePrivate( + '#fetchTopAssetsThreshold', + threshold, + ); + swapsController.__test__updateState({ topAssets: [], topAssetsLastFetched: Date.now() - threshold - 1, }); @@ -577,12 +662,13 @@ describe('SwapsController', () => { }); it('should return undefined if chain id is not supported', async () => { - swapsController.configure({ - supportedChainIds: ['0x1'], + swapsController.__test__updatePrivate('#supportedChainIds', ['0x1']); + swapsController.__test__updateState({ + topAssets: [], + topAssetsLastFetched: 0, }); - swapsController.state.topAssets = []; - swapsController.state.topAssetsLastFetched = 0; - swapsController.configure({ chainId: '0x2' }); + + swapsController.setChainId('0x2'); await swapsController.fetchTopAssetsWithCache(); expect(swapsUtilFetchTopAssets).not.toHaveBeenCalled(); }); @@ -590,7 +676,7 @@ describe('SwapsController', () => { describe('aggregator metadata cache', () => { it('should fetch aggregator metadata when no aggregator metadata in state', async () => { - swapsController.update({ + swapsController.__test__updateState({ aggregatorMetadata: null, }); await swapsController.fetchAggregatorMetadataWithCache(); @@ -598,7 +684,7 @@ describe('SwapsController', () => { }); it('should fetch aggregator metadata when last fetched is 0', async () => { - swapsController.update({ + swapsController.__test__updateState({ aggregatorMetadata: {}, aggregatorMetadataLastFetched: 0, }); @@ -608,10 +694,11 @@ describe('SwapsController', () => { it('should fetch aggregator metadata when last fetched is over threshold', async () => { const threshold = 5000; - swapsController.configure({ - fetchAggregatorMetadataThreshold: threshold, - }); - swapsController.update({ + swapsController.__test__updatePrivate( + '#fetchAggregatorMetadataThreshold', + threshold, + ); + swapsController.__test__updateState({ aggregatorMetadata: {}, aggregatorMetadataLastFetched: Date.now() - threshold - 1, }); @@ -620,7 +707,7 @@ describe('SwapsController', () => { }); it('should not fetch aggregator metadata when no threshold reached', async () => { - swapsController.update({ + swapsController.__test__updateState({ aggregatorMetadata: {}, aggregatorMetadataLastFetched: Date.now(), }); @@ -629,7 +716,7 @@ describe('SwapsController', () => { }); it('should not fetch aggregator metadata when no threshold reached or tokens are available', async () => { - swapsController.update({ + swapsController.__test__updateState({ aggregatorMetadata: {}, aggregatorMetadataLastFetched: Date.now(), }); @@ -642,10 +729,11 @@ describe('SwapsController', () => { throw new Error(); }); const threshold = 5000; - swapsController.configure({ - fetchAggregatorMetadataThreshold: threshold, - }); - swapsController.update({ + swapsController.__test__updatePrivate( + '#fetchAggregatorMetadataThreshold', + threshold, + ); + swapsController.__test__updateState({ aggregatorMetadata: {}, aggregatorMetadataLastFetched: Date.now() - threshold - 1, }); @@ -654,12 +742,12 @@ describe('SwapsController', () => { expect(swapsController.state.aggregatorMetadataLastFetched).toBe(0); }); it('should return undefined if chain id is not supported', async () => { - swapsController.configure({ - supportedChainIds: ['0x1'], + swapsController.__test__updatePrivate('#supportedChainIds', ['0x1']); + swapsController.__test__updateState({ + aggregatorMetadata: {}, + aggregatorMetadataLastFetched: 0, }); - swapsController.state.aggregatorMetadata = {}; - swapsController.state.aggregatorMetadataLastFetched = 0; - swapsController.configure({ chainId: '0x2' }); + swapsController.setChainId('0x2'); await swapsController.fetchAggregatorMetadataWithCache(); expect(swapsUtilFetchAggregatorMetadata).not.toHaveBeenCalled(); }); @@ -673,8 +761,10 @@ describe('SwapsController', () => { gasPrice: '20', }; - swapsController.state.quotes = API_TRADES; - swapsController.state.usedGasEstimate = usedGasEstimate; + swapsController.__test__updateState({ + quotes: API_TRADES, + usedGasEstimate, + }); swapsController.updateQuotesWithGasPrice(customGasFee); @@ -686,7 +776,10 @@ describe('SwapsController', () => { const customGasFee = { gasPrice: '10', }; - swapsController.state.usedGasEstimate = null; + + swapsController.__test__updateState({ + usedGasEstimate: null, + }); swapsController.updateQuotesWithGasPrice(customGasFee); @@ -697,17 +790,20 @@ describe('SwapsController', () => { describe('updateSelectedQuoteWithGasLimit', () => { it('should update selected quote with custom gas limit', () => { const customGasLimit = '0x5208'; // 21000 in hex - swapsController.state.topAggId = 'paraswap'; - swapsController.state.quotes = API_TRADES; - swapsController.state.quoteValues = { - paraswap: { - ...swapsController.state.quoteValues!.paraswap, - maxEthFee: '0', + + swapsController.__test__updateState({ + topAggId: 'paraswap', + quotes: API_TRADES, + quoteValues: { + paraswap: { + ...swapsController.state.quoteValues!.paraswap, + maxEthFee: '0', + }, }, - }; - swapsController.state.usedGasEstimate = { - gasPrice: '20', - }; + usedGasEstimate: { + gasPrice: '20', + }, + }); swapsController.updateSelectedQuoteWithGasLimit(customGasLimit); @@ -717,8 +813,11 @@ describe('SwapsController', () => { it('should not update selected quote if topAggId or usedGasEstimate is null', () => { const customGasLimit = '0x5208'; // 21000 in hex - swapsController.state.topAggId = null; - swapsController.state.usedGasEstimate = null; + + swapsController.__test__updateState({ + topAggId: null, + usedGasEstimate: null, + }); swapsController.updateSelectedQuoteWithGasLimit(customGasLimit); @@ -790,13 +889,18 @@ describe('SwapsController', () => { expect(swapsController.state.quoteValues).toEqual({}); }); - it('should clear timeout if this.handle is set', () => { + it('should clear timeout if this.#handle is set', () => { const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout'); - swapsController['handle'] = setTimeout(() => {}, 1000); // Set a timeout + swapsController.__test__updatePrivate( + '#handle', + setTimeout(() => {}, 1000), + ); swapsController.stopPollingAndResetState(); - expect(clearTimeoutSpy).toHaveBeenCalledWith(swapsController['handle']); + expect(clearTimeoutSpy).toHaveBeenCalledWith( + swapsController.__test__getInternal('#handle'), + ); clearTimeoutSpy.mockRestore(); }); }); @@ -821,11 +925,11 @@ describe('SwapsController', () => { swapsController = new SwapsController( { + ...INITIAL_CONTROLLER_OPTIONS, + messenger: messengerMock, fetchGasFeeEstimates, }, - { - pollCountLimit: POLL_COUNT_LIMIT, - }, + swapsUtil.getDefaultSwapsControllerState(), ); }); @@ -852,11 +956,11 @@ describe('SwapsController', () => { swapsController = new SwapsController( { + ...INITIAL_CONTROLLER_OPTIONS, + messenger: messengerMock, fetchGasFeeEstimates, }, - { - pollCountLimit: POLL_COUNT_LIMIT, - }, + swapsUtil.getDefaultSwapsControllerState(), ); // @ts-expect-error - testing private method @@ -868,11 +972,11 @@ describe('SwapsController', () => { it('should fetch gas price from fetchGasPrices if fetchGasFeeEstimates is not defined', async () => { swapsController = new SwapsController( { + ...INITIAL_CONTROLLER_OPTIONS, + messenger: messengerMock, fetchGasFeeEstimates: undefined, }, - { - pollCountLimit: POLL_COUNT_LIMIT, - }, + swapsUtil.getDefaultSwapsControllerState(), ); const fetchGasPricesSpy = jest @@ -910,11 +1014,11 @@ describe('SwapsController', () => { swapsController = new SwapsController( { + ...INITIAL_CONTROLLER_OPTIONS, + messenger: messengerMock, fetchGasFeeEstimates, }, - { - pollCountLimit: POLL_COUNT_LIMIT, - }, + swapsUtil.getDefaultSwapsControllerState(), ); }); @@ -1023,11 +1127,11 @@ describe('SwapsController', () => { swapsController = new SwapsController( { + ...INITIAL_CONTROLLER_OPTIONS, + messenger: messengerMock, fetchGasFeeEstimates, }, - { - pollCountLimit: POLL_COUNT_LIMIT, - }, + swapsUtil.getDefaultSwapsControllerState(), ); }); @@ -1104,11 +1208,11 @@ describe('SwapsController', () => { swapsController = new SwapsController( { + ...INITIAL_CONTROLLER_OPTIONS, + messenger: messengerMock, fetchGasFeeEstimates, }, - { - pollCountLimit: POLL_COUNT_LIMIT, - }, + swapsUtil.getDefaultSwapsControllerState(), ); }); @@ -1121,7 +1225,7 @@ describe('SwapsController', () => { }; // @ts-expect-error - incomplete type - const result = isGasFeeStateEthGasPrice(gasFeeState); + const result = swapsUtil.isGasFeeStateEthGasPrice(gasFeeState); expect(result).toBe(true); }); @@ -1138,7 +1242,7 @@ describe('SwapsController', () => { }; // @ts-expect-error - incomplete type - const result = isGasFeeStateEthGasPrice(gasFeeState); + const result = swapsUtil.isGasFeeStateEthGasPrice(gasFeeState); expect(result).toBe(false); }); @@ -1150,11 +1254,11 @@ describe('SwapsController', () => { swapsController = new SwapsController( { + ...INITIAL_CONTROLLER_OPTIONS, + messenger: messengerMock, fetchGasFeeEstimates, }, - { - pollCountLimit: POLL_COUNT_LIMIT, - }, + swapsUtil.getDefaultSwapsControllerState(), ); }); @@ -1167,7 +1271,7 @@ describe('SwapsController', () => { }; // @ts-expect-error - incomplete type - const result = isGasFeeStateLegacy(gasFeeState); + const result = swapsUtil.isGasFeeStateLegacy(gasFeeState); expect(result).toBe(true); }); @@ -1184,7 +1288,7 @@ describe('SwapsController', () => { }; // @ts-expect-error - incomplete type - const result = isGasFeeStateLegacy(gasFeeState); + const result = swapsUtil.isGasFeeStateLegacy(gasFeeState); expect(result).toBe(false); }); @@ -1195,7 +1299,7 @@ describe('SwapsController', () => { }; // @ts-expect-error - incomplete type - const result = isGasFeeStateLegacy(gasFeeState); + const result = swapsUtil.isGasFeeStateLegacy(gasFeeState); expect(result).toBe(false); }); diff --git a/src/SwapsController.ts b/src/SwapsController.ts index 92c0f783..d102d3ea 100644 --- a/src/SwapsController.ts +++ b/src/SwapsController.ts @@ -1,5 +1,5 @@ -import type { BaseConfig, BaseState } from '@metamask/base-controller'; -import { BaseControllerV1 } from '@metamask/base-controller'; +import type { StateMetadata } from '@metamask/base-controller'; +import { BaseController } from '@metamask/base-controller'; import { gweiDecToWEIBN, query, @@ -11,491 +11,170 @@ import type { FetchGasFeeEstimateOptions, GasFeeEstimates, GasFeeState, - GasFeeStateEthGasPrice, - GasFeeStateFeeMarket, - GasFeeStateLegacy, } from '@metamask/gas-fee-controller'; import { GAS_ESTIMATE_TYPES } from '@metamask/gas-fee-controller'; -import type { TransactionParams } from '@metamask/transaction-controller'; -import type { Hex } from '@metamask/utils'; +import type { Provider } from '@metamask/network-controller'; +import { + getKnownPropertyNames, + isErrorWithMessage, + type Hex, +} from '@metamask/utils'; import { Mutex } from 'async-mutex'; import { BigNumber } from 'bignumber.js'; import abiERC20 from 'human-standard-token-abi'; -import * as web3 from 'web3'; import type { Web3 as Web3Type } from 'web3'; +import * as web3 from 'web3'; -import type { - APIAggregatorMetadata, - APIFetchQuotesMetadata, - APIFetchQuotesParams, - ChainData, - ChainCache, - Quote, - QuoteSavings, - QuoteValues, - SwapsAsset, - SwapsToken, -} from './swapsInterfaces'; import { + AVALANCHE_CHAIN_ID, + BSC_CHAIN_ID, calcTokenAmount, calculateGasEstimateWithRefund, calculateGasLimits, + controllerName, + DEFAULT_ERC20_APPROVE_GAS, estimateGas, + ETH_CHAIN_ID, fetchAggregatorMetadata, fetchGasPrices, fetchTokens, fetchTopAssets, fetchTradesInfo, + getDefaultSwapsControllerState, + getNewChainCache, getSwapsContractAddress, - SwapsError, - DEFAULT_ERC20_APPROVE_GAS, + INITIAL_CHAIN_DATA, + isCustomEthGasPriceEstimate, + isCustomGasFee, + isEthGasPriceEstimate, + isGasFeeStateEthGasPrice, + isGasFeeStateFeeMarket, + isGasFeeStateLegacy, NATIVE_SWAPS_TOKEN_ADDRESS, - ETH_CHAIN_ID, - BSC_CHAIN_ID, - SWAPS_TESTNET_CHAIN_ID, - POLYGON_CHAIN_ID, - AVALANCHE_CHAIN_ID, OPTIMISM_CHAIN_ID, + POLYGON_CHAIN_ID, shouldEnableDirectWrapping, + SWAPS_TESTNET_CHAIN_ID, + SwapsError, } from './swapsUtil'; +import type { + APIFetchQuotesMetadata, + APIFetchQuotesParams, + CustomEthGasPriceEstimate, + CustomGasFee, + Quote, + QuoteValues, + SwapsControllerMessenger, + SwapsControllerOptions, + SwapsControllerState, + TxParams, +} from './types'; // Hack to fix the issue with the web3 import that works different in app vs tests const Web3 = web3.Web3 === undefined ? web3.default : web3.Web3; -// Functions to determine type of the return value from GasFeeController - -/** - * Checks if the given object is of type GasFeeStateEthGasPrice. - * @param object - The gas fee state to be checked. - * @returns Whether the given object is of type GasFeeStateEthGasPrice. - */ -export function isGasFeeStateEthGasPrice( - object: GasFeeState, -): object is GasFeeStateEthGasPrice { - return object.gasEstimateType === GAS_ESTIMATE_TYPES.ETH_GASPRICE; -} - -/** - * Determines if the given object is of type GasFeeStateFeeMarket based on its 'gasEstimateType'. - * @param object - The gas fee state to be evaluated. - * @returns Whether the object is of type GasFeeStateFeeMarket. - */ -function isGasFeeStateFeeMarket( - object: GasFeeState, -): object is GasFeeStateFeeMarket { - return object.gasEstimateType === GAS_ESTIMATE_TYPES.FEE_MARKET; -} - -/** - * Determines if the given object is of type GasFeeStateLegacy based on its 'gasEstimateType'. - * @param object - The gas fee state to be evaluated. - * @returns Whether the object is of type GasFeeStateLegacy. - */ -export function isGasFeeStateLegacy( - object: GasFeeState, -): object is GasFeeStateLegacy { - return object.gasEstimateType === GAS_ESTIMATE_TYPES.LEGACY; -} - -// Custom types for custom gas values -type CustomEthGasPriceEstimate = { - gasPrice: string; // a GWEI dec string - selected?: 'low' | 'medium' | 'high'; +const metadata: StateMetadata = { + quotes: { persist: false, anonymous: false }, + quoteValues: { persist: false, anonymous: false }, + fetchParams: { persist: false, anonymous: false }, + fetchParamsMetaData: { persist: false, anonymous: false }, + topAggSavings: { persist: false, anonymous: false }, + aggregatorMetadata: { persist: false, anonymous: true }, + tokens: { persist: false, anonymous: true }, + topAssets: { persist: false, anonymous: true }, + approvalTransaction: { persist: false, anonymous: false }, + aggregatorMetadataLastFetched: { persist: false, anonymous: true }, + quotesLastFetched: { persist: false, anonymous: true }, + topAssetsLastFetched: { persist: false, anonymous: true }, + error: { persist: false, anonymous: false }, + topAggId: { persist: false, anonymous: false }, + tokensLastFetched: { persist: false, anonymous: true }, + isInPolling: { persist: false, anonymous: true }, + pollingCyclesLeft: { persist: false, anonymous: true }, + quoteRefreshSeconds: { persist: false, anonymous: true }, + usedGasEstimate: { persist: false, anonymous: false }, + usedCustomGas: { persist: false, anonymous: false }, + chainCache: { persist: false, anonymous: false }, }; -type CustomGasFee = { - maxFeePerGas: string; // a GWEI dec string - maxPriorityFeePerGas: string; // a GWEI dec string - estimatedBaseFee?: string; // a GWEI dec string - selected?: 'low' | 'medium' | 'high'; -}; +export default class SwapsController extends BaseController< + typeof controllerName, + SwapsControllerState, + SwapsControllerMessenger +> { + #abortController?: AbortController; -/** - * Determines if the given object is of type EthGasPriceEstimate. - * @param object - The object to be evaluated. - * @returns Whether the object is of type EthGasPriceEstimate. - */ -function isEthGasPriceEstimate(object: any): object is EthGasPriceEstimate { - return Boolean(object) && object?.gasPrice !== undefined; -} + #clientId?: string; -/** - * Determines if the given object is of type CustomEthGasPriceEstimate. - * @param object - The object to be evaluated. - * @returns Whether the object is of type CustomEthGasPriceEstimate. - */ -function isCustomEthGasPriceEstimate( - object: any, -): object is CustomEthGasPriceEstimate { - return Boolean(object) && object?.gasPrice !== undefined; -} + #ethQuery: EthQuery; -/** - * Determines if the given object is of type CustomGasFee. - * @param object - The object to be evaluated. - * @returns Whether the object is of type CustomGasFee. - */ -function isCustomGasFee(object: any): object is CustomGasFee { - return ( - Boolean(object) && - 'maxFeePerGas' in object && - 'maxPriorityFeePerGas' in object - ); -} + #fetchAggregatorMetadataThreshold: number; -export type SwapsConfig = { - clientId?: string; - maxGasLimit: number; - pollCountLimit: number; - fetchAggregatorMetadataThreshold: number; - fetchTokensThreshold: number; - fetchTopAssetsThreshold: number; - provider: any; - chainId: Hex; - supportedChainIds: Hex[]; -} & BaseConfig; - -export type SwapsState = { - quotes: { [key: string]: Quote }; - fetchParams: APIFetchQuotesParams; - fetchParamsMetaData: APIFetchQuotesMetadata; - topAggSavings: QuoteSavings | null; - quotesLastFetched: null | number; - error: { key: null | SwapsError; description: null | string }; - topAggId: null | string; - isInPolling: boolean; - pollingCyclesLeft: number; - approvalTransaction: TransactionParams | null; - quoteValues: { [key: string]: QuoteValues } | null; - quoteRefreshSeconds: number | null; - usedGasEstimate: EthGasPriceEstimate | GasFeeEstimates | null; - usedCustomGas: CustomEthGasPriceEstimate | CustomGasFee | null; - aggregatorMetadata: null | { [key: string]: APIAggregatorMetadata }; - aggregatorMetadataLastFetched: number; - tokens: null | SwapsToken[]; - tokensLastFetched: number; - topAssets: null | SwapsAsset[]; - topAssetsLastFetched: number; - chainCache: ChainCache; -} & BaseState; - -type SwapsNextState = { - quotes: { [key: string]: Quote }; - quotesLastFetched: null | number; - approvalTransaction: TransactionParams | null; - topAggId: null | string; - topAggSavings?: QuoteSavings | null; - quoteValues: { [key: string]: QuoteValues } | null; - quoteRefreshSeconds: number | null; -}; + #fetchTokensThreshold: number; -export const INITIAL_CHAIN_DATA: ChainData = { - aggregatorMetadata: null, - tokens: null, - topAssets: null, - aggregatorMetadataLastFetched: 0, - topAssetsLastFetched: 0, - tokensLastFetched: 0, -}; + #fetchTopAssetsThreshold: number; -/** - * Gets a new chainCache for a chainId with updated data. - * @param chainCache - Current chainCache from state. - * @param chainId - Current chainId from the config. - * @param data - Data to be updated. - * @returns The new chainCache. - */ -function getNewChainCache( - chainCache: ChainCache, - chainId: Hex, - data: Partial, -): ChainCache { - return { - ...chainCache, - [chainId]: { - ...chainCache?.[chainId], - ...data, - }, - }; -} + #handle?: NodeJS.Timeout; -export default class SwapsController extends BaseControllerV1< - SwapsConfig, - SwapsState -> { - private handle?: NodeJS.Timeout; + #mutex = new Mutex(); - private web3: Web3Type; + #pollCount = 0; - private ethQuery: any; + #pollCountLimit: number; - private pollCount = 0; + #supportedChainIds: Hex[]; - private readonly mutex = new Mutex(); + #web3: Web3Type; - private abortController?: AbortController; + #chainId: Hex; - private readonly fetchGasFeeEstimates?: ( + // TODO: Remove once GasFeeController exports this action type + readonly #fetchGasFeeEstimates?: ( options?: FetchGasFeeEstimateOptions, ) => Promise; - private readonly fetchEstimatedMultiLayerL1Fee?: ( - eth: any, + readonly #fetchEstimatedMultiLayerL1Fee?: ( + eth: EthQuery, options: { - txParams: TransactionParams; + txParams: TxParams; chainId: Hex; }, ) => Promise; - /** - * Fetch current gas price - * @returns Promise resolving to the current gas price or throw an error - */ - /* istanbul ignore next */ - private async getGasPrice(): Promise { - if (this.fetchGasFeeEstimates) { - const gasFeeState = await this.fetchGasFeeEstimates({ - shouldUpdateState: this.pollCount === 1, - }); - if ( - !gasFeeState || - gasFeeState.gasEstimateType === GAS_ESTIMATE_TYPES.NONE - ) { - throw new Error(SwapsError.SWAPS_GAS_PRICE_ESTIMATION); - } - - if (isGasFeeStateFeeMarket(gasFeeState)) { - return gasFeeState.gasFeeEstimates; - } else if (isGasFeeStateLegacy(gasFeeState)) { - return { gasPrice: gasFeeState.gasFeeEstimates.medium }; - } else if (isGasFeeStateEthGasPrice(gasFeeState)) { - return { gasPrice: gasFeeState.gasFeeEstimates.gasPrice }; - } - } - - try { - const { proposedGasPrice } = await fetchGasPrices( - this.config.chainId, - this.config.clientId, - ); - return { gasPrice: proposedGasPrice }; - } catch (error) { - // - } - - try { - const gasPrice = await query(this.ethQuery, 'gasPrice'); - return { - gasPrice: weiHexToGweiDec(gasPrice).toString(), - }; - } catch (error) { - // + #buildChainCache = (chainId: Hex) => { + if (!this.#supportedChainIds.includes(chainId)) { + return; } - throw new Error(SwapsError.SWAPS_GAS_PRICE_ESTIMATION); - } - - /** - * Calculates a quote `QuotesValue` - * @param quote - Quote object - * @param gasLimit - A hex string representing max units of gas to spend - * @param gasFeeEstimates - current gas fee estimates - * @param customGasFee - custom gas fee values - */ - /* istanbul ignore next */ - private calculateQuoteValues( - quote: Quote, - gasLimit: string | null, - gasFeeEstimates: GasFeeEstimates | EthGasPriceEstimate, - customGasFee?: CustomEthGasPriceEstimate | CustomGasFee, - ): QuoteValues { - const { destinationTokenInfo } = this.state.fetchParamsMetaData; - const { - aggregator, - averageGas, - maxGas, - destinationAmount = 0, - fee: metaMaskFee, - sourceAmount, - sourceToken, - trade, - gasEstimateWithRefund, - gasEstimate, - gasMultiplier, - approvalNeeded, - destinationTokenRate, - multiLayerL1TradeFeeTotal, - } = quote; - - // trade gas - const { tradeGasLimit, tradeMaxGasLimit } = calculateGasLimits( - Boolean(approvalNeeded), - gasEstimateWithRefund, - gasEstimate, - averageGas, - maxGas, - gasMultiplier, - gasLimit, - ); - - let totalGasInWei: BigNumber; - let maxTotalGasInWei: BigNumber; - - if (isEthGasPriceEstimate(gasFeeEstimates)) { - const gasPrice = isCustomEthGasPriceEstimate(customGasFee) - ? customGasFee.gasPrice - : gasFeeEstimates.gasPrice; - - totalGasInWei = tradeGasLimit.times( - gweiDecToWEIBN(gasPrice).toString(16), - 16, - ); - maxTotalGasInWei = new BigNumber(tradeMaxGasLimit).times( - gweiDecToWEIBN(gasPrice).toString(16), - 16, - ); - - if (multiLayerL1TradeFeeTotal) { - totalGasInWei = totalGasInWei.plus(multiLayerL1TradeFeeTotal, 16); - maxTotalGasInWei = maxTotalGasInWei.plus(multiLayerL1TradeFeeTotal, 16); - } - } else { - const estimatedBaseFee = - (isCustomGasFee(customGasFee) && customGasFee?.estimatedBaseFee) || - gasFeeEstimates.estimatedBaseFee; - - const [maxFeePerGas, maxPriorityFeePerGas] = isCustomGasFee(customGasFee) - ? [customGasFee.maxFeePerGas, customGasFee.maxPriorityFeePerGas] - : [ - gasFeeEstimates.high.suggestedMaxFeePerGas, - gasFeeEstimates.high.suggestedMaxPriorityFeePerGas, - ]; - - totalGasInWei = tradeGasLimit.times( - gweiDecToWEIBN(estimatedBaseFee) - .add(gweiDecToWEIBN(maxPriorityFeePerGas)) - .toString(16), - 16, - ); + const { chainCache } = this.state; - maxTotalGasInWei = new BigNumber(tradeMaxGasLimit).times( - gweiDecToWEIBN(maxFeePerGas).toString(16), - 16, - ); + if (!chainCache?.[chainId]) { + this.update((_state) => { + _state.aggregatorMetadata = null; + _state.tokens = null; + _state.topAssets = null; + _state.aggregatorMetadataLastFetched = 0; + _state.topAssetsLastFetched = 0; + _state.tokensLastFetched = 0; + _state.chainCache = getNewChainCache(chainCache, chainId, { + ...INITIAL_CHAIN_DATA, + }); + }); + return; } - // totalGas + trade value - // trade.value is a sum of different values depending on the transaction. - // It always includes any external fees charged by the quote source. In - // addition, if the source asset is NATIVE, trade.value includes the amount - // of swapped NATIVE. - const totalInWei = totalGasInWei.plus(trade.value, 16); - const maxTotalInWei = maxTotalGasInWei.plus(trade.value, 16); - - // if value in trade, NATIVE fee will be the gas, if not it will be the total wei - const weiFee = - sourceToken === NATIVE_SWAPS_TOKEN_ADDRESS - ? totalInWei.minus(sourceAmount, 10) - : totalInWei; // sourceAmount is in wei : totalInWei; - const maxWeiFee = - sourceToken === NATIVE_SWAPS_TOKEN_ADDRESS - ? maxTotalInWei.minus(sourceAmount, 10) - : maxTotalInWei; // sourceAmount is in wei : totalInWei; - const ethFee = calcTokenAmount(weiFee, 18); - const maxEthFee = calcTokenAmount(maxWeiFee, 18); - - const decimalAdjustedDestinationAmount = calcTokenAmount( - destinationAmount, - destinationTokenInfo.decimals, - ); - - // fees - const tokenPercentageOfPreFeeDestAmount = new BigNumber(100, 10) - .minus(metaMaskFee, 10) - .div(100); - const destinationAmountBeforeMetaMaskFee = - decimalAdjustedDestinationAmount.div(tokenPercentageOfPreFeeDestAmount); - const metaMaskFeeInTokens = destinationAmountBeforeMetaMaskFee.minus( - decimalAdjustedDestinationAmount, - ); - - const conversionRate = destinationTokenRate ?? 1; - - const ethValueOfTokens = decimalAdjustedDestinationAmount.times( - conversionRate, - 10, - ); - - // the more tokens the better - const overallValueOfQuote = ethValueOfTokens.minus(ethFee, 10); - - const quoteValues: QuoteValues = { - aggregator, - tradeGasLimit: tradeGasLimit.toString(10), - tradeMaxGasLimit: tradeMaxGasLimit.toString(10), - ethFee: ethFee.toFixed(18), - maxEthFee: maxEthFee.toFixed(18), - ethValueOfTokens: ethValueOfTokens.toFixed(18), - overallValueOfQuote: overallValueOfQuote.toFixed(18), - metaMaskFeeInEth: metaMaskFeeInTokens.times(conversionRate).toFixed(18), - }; - - return quoteValues; - } - - /* istanbul ignore next */ - private calculatesCustomLimitMaxEthFee( - quote: Quote, - gasFee: - | EthGasPriceEstimate - | GasFeeEstimates - | CustomGasFee - | CustomEthGasPriceEstimate, - gasLimit: string, - ): string { - const { - averageGas, - maxGas, - sourceAmount, - sourceToken, - trade, - gasEstimateWithRefund, - gasEstimate, - gasMultiplier, - approvalNeeded, - } = quote; - - const { tradeMaxGasLimit } = calculateGasLimits( - Boolean(approvalNeeded), - gasEstimateWithRefund, - gasEstimate, - averageGas, - maxGas, - gasMultiplier, - gasLimit, - ); - - let gasPrice; - if (isCustomEthGasPriceEstimate(gasFee) || isEthGasPriceEstimate(gasFee)) { - gasPrice = gasFee.gasPrice; - } else if (isCustomGasFee(gasFee)) { - gasPrice = gasFee.maxFeePerGas; - } else { - gasPrice = gasFee.high.suggestedMaxFeePerGas; - } + const cachedData = chainCache[chainId]; - const maxTotalGasInWei = new BigNumber(tradeMaxGasLimit).times( - gweiDecToWEIBN(gasPrice).toString(16), - 16, - ); - const maxTotalInWei = maxTotalGasInWei.plus(trade.value ?? '0x0', 16); - const maxWeiFee = - sourceToken === NATIVE_SWAPS_TOKEN_ADDRESS - ? maxTotalInWei.minus(sourceAmount, 10) - : maxTotalInWei; - const maxEthFee = calcTokenAmount(maxWeiFee, 18).toFixed(18); - return maxEthFee; - } + this.update((_state) => { + _state.aggregatorMetadata = cachedData.aggregatorMetadata; + _state.tokens = cachedData.tokens; + _state.topAssets = cachedData.topAssets; + _state.aggregatorMetadataLastFetched = + cachedData.aggregatorMetadataLastFetched; + _state.topAssetsLastFetched = cachedData.topAssetsLastFetched; + _state.tokensLastFetched = cachedData.tokensLastFetched; + }); + }; /** * Find best quote and quotes calculated values @@ -503,7 +182,7 @@ export default class SwapsController extends BaseControllerV1< * @returns Promise resolving to the best quote object and values from quotes */ /* istanbul ignore next */ - private getBestQuoteAndQuotesValues( + #getBestQuoteAndQuotesValues( quotes: { [key: string]: Quote }, gasFeeEstimates: EthGasPriceEstimate | GasFeeEstimates, customGasFee?: CustomEthGasPriceEstimate | CustomGasFee, @@ -545,11 +224,11 @@ export default class SwapsController extends BaseControllerV1< * @returns Promise resolving to allowance number */ /* istanbul ignore next */ - private async getERC20Allowance( + async #getERC20Allowance( contractAddress: string, walletAddress: string, ): Promise { - const contract = new this.web3.eth.Contract(abiERC20, contractAddress); + const contract = new this.#web3.eth.Contract(abiERC20, contractAddress); const allowanceTimeout = new Promise((_, reject) => { setTimeout(() => { reject(new Error(SwapsError.SWAPS_ALLOWANCE_TIMEOUT)); @@ -558,7 +237,7 @@ export default class SwapsController extends BaseControllerV1< const allowancePromise = async () => { const result: bigint = await contract.methods - .allowance(walletAddress, getSwapsContractAddress(this.config.chainId)) + .allowance(walletAddress, getSwapsContractAddress(this.#chainId)) .call(); return new BigNumber(result.toString()); }; @@ -567,8 +246,10 @@ export default class SwapsController extends BaseControllerV1< } /* istanbul ignore next */ - private async timedoutGasReturn( - tradeTxParams: TransactionParams | null, + async #timedoutGasReturn( + tradeTxParams: + | (Omit & Partial>) + | null, ): Promise<{ gas: string | null }> { if (!tradeTxParams) { return { gas: null }; @@ -587,7 +268,7 @@ export default class SwapsController extends BaseControllerV1< to: tradeTxParams.to, value: tradeTxParams.value, }, - this.ethQuery, + this.#ethQuery, ), gasTimeout, ]); @@ -597,36 +278,49 @@ export default class SwapsController extends BaseControllerV1< } /* istanbul ignore next */ - private async pollForNewQuotesWithThreshold( - fetchThreshold = 0, - ): Promise { - this.pollCount += 1; - if (this.handle) { - clearTimeout(this.handle); - this.handle = undefined; + async #pollForNewQuotesWithThreshold(fetchThreshold = 0): Promise { + this.#pollCount += 1; + if (this.#handle) { + clearTimeout(this.#handle); + this.#handle = undefined; } - if (this.pollCount < Number(this.config.pollCountLimit) + 1) { + if (this.#pollCount < Number(this.#pollCountLimit) + 1) { if (!this.state.isInPolling) { - this.update({ isInPolling: true }); + this.update((_state) => { + _state.isInPolling = true; + }); } const { nextQuotesState, threshold, usedGasEstimate } = - await this.fetchQuotes(); + await this.#fetchQuotes(); - this.update({ - pollingCyclesLeft: this.config.pollCountLimit - this.pollCount, + this.update((_state) => { + _state.pollingCyclesLeft = this.#pollCountLimit - this.#pollCount; }); if (threshold && nextQuotesState?.quoteRefreshSeconds) { - this.update({ ...this.state, ...nextQuotesState, usedGasEstimate }); - this.handle = setTimeout(() => { - this.pollForNewQuotesWithThreshold(threshold).catch(() => { - this.update({ isInPolling: false }); + this.update((_state) => { + // @ts-expect-error - since the keys in the quote object are aggregator ids, we can safely ignore this error + _state.quotes = nextQuotesState.quotes ?? _state.quotes; + _state.quotesLastFetched = nextQuotesState.quotesLastFetched ?? 0; + _state.approvalTransaction = + nextQuotesState.approvalTransaction ?? null; + _state.topAggId = nextQuotesState.topAggId ?? _state.topAggId; + _state.quoteValues = + nextQuotesState.quoteValues ?? _state.quoteValues; + _state.quoteRefreshSeconds = nextQuotesState.quoteRefreshSeconds ?? 0; + _state.usedGasEstimate = usedGasEstimate; + }); + this.#handle = setTimeout(() => { + this.#pollForNewQuotesWithThreshold(threshold).catch(() => { + this.update((_state) => { + _state.isInPolling = false; + }); }); }, nextQuotesState.quoteRefreshSeconds * 1000 - threshold); } } else { - this.handle = setTimeout(() => { + this.#handle = setTimeout(() => { this.stopPollingAndResetState({ key: SwapsError.QUOTES_EXPIRED_ERROR, description: null, @@ -636,13 +330,13 @@ export default class SwapsController extends BaseControllerV1< } /* istanbul ignore next */ - private async getAllQuotesWithGasEstimates(trades: { + async #getAllQuotesWithGasEstimates(trades: { [key: string]: Quote; }): Promise<{ [key: string]: Quote }> { const quoteGasData = await Promise.all( Object.values(trades).map(async (trade) => { try { - const { gas } = await this.timedoutGasReturn(trade.trade); + const { gas } = await this.#timedoutGasReturn(trade.trade); return { gas, aggId: trade.aggregator, @@ -669,56 +363,54 @@ export default class SwapsController extends BaseControllerV1< } /* istanbul ignore next */ - private async fetchQuotes(): Promise<{ - nextQuotesState: SwapsNextState | null; + async #fetchQuotes(): Promise<{ + nextQuotesState: Partial | null; threshold: number | null; usedGasEstimate: EthGasPriceEstimate | GasFeeEstimates | null; }> { const timeStarted = Date.now(); const { fetchParams } = this.state; - const { clientId, chainId } = this.config; try { /** We need to abort quotes fetch if stopPollingAndResetState is called while getting quotes */ - this.abortController = new AbortController(); - const { signal } = this.abortController; + this.#abortController = new AbortController(); + const { signal } = this.#abortController; let quotes: { [key: string]: Quote } = await fetchTradesInfo( fetchParams, signal, - chainId, - clientId, + this.#chainId, + this.#clientId, ); if (Object.values(quotes).length === 0) { throw new Error(SwapsError.QUOTES_NOT_AVAILABLE_ERROR); } - if (chainId === OPTIMISM_CHAIN_ID && Object.values(quotes).length > 0) { + if ( + this.#chainId === OPTIMISM_CHAIN_ID && + Object.values(quotes).length > 0 + ) { // Fetch an L1 fee for each quote on Optimism. await Promise.all( Object.values(quotes).map(async (quote) => { - if (quote.trade && this.fetchEstimatedMultiLayerL1Fee) { + if (quote.trade && this.#fetchEstimatedMultiLayerL1Fee) { const multiLayerL1TradeFeeTotal = - await this.fetchEstimatedMultiLayerL1Fee(this.ethQuery, { + await this.#fetchEstimatedMultiLayerL1Fee(this.#ethQuery, { txParams: quote.trade, - chainId, + chainId: this.#chainId, }); // eslint-disable-next-line require-atomic-updates - quote.multiLayerL1TradeFeeTotal = multiLayerL1TradeFeeTotal; + quote.multiLayerL1TradeFeeTotal = + multiLayerL1TradeFeeTotal ?? null; } return quote; }), ); } - let approvalTransaction: { - data?: string; - from: string; - to?: string; - gas?: string; - } | null = null; + let approvalTransaction: TxParams | null = null; const enableDirectWrappingParam = shouldEnableDirectWrapping( - chainId, + this.#chainId, fetchParams.sourceToken, fetchParams.destinationToken, ); @@ -735,199 +427,422 @@ export default class SwapsController extends BaseControllerV1< fetchParams.sourceToken !== NATIVE_SWAPS_TOKEN_ADDRESS && !enableDirectWrapping ) { - const allowance = await this.getERC20Allowance( + const allowance = await this.#getERC20Allowance( fetchParams.sourceToken, fetchParams.walletAddress, ); - // On Android, trying to cast a massive BigInt to a number will result in null - // allowance and sourceAmount are in Solidity atomic amounts, so they can be bigger than a JS Number - if (allowance.isLessThan(new BigNumber(fetchParams.sourceAmount))) { - approvalTransaction = - quotesArray.find((quote) => quote.approvalNeeded)?.approvalNeeded ?? - null; + // On Android, trying to cast a massive BigInt to a number will result in null + // allowance and sourceAmount are in Solidity atomic amounts, so they can be bigger than a JS Number + if (allowance.isLessThan(new BigNumber(fetchParams.sourceAmount))) { + approvalTransaction = + quotesArray.find((quote) => quote.approvalNeeded)?.approvalNeeded ?? + null; + + if (!approvalTransaction) { + throw new Error(SwapsError.SWAPS_ALLOWANCE_ERROR); + } + + const { gas: approvalGas } = await this.#timedoutGasReturn({ + data: approvalTransaction.data, + from: approvalTransaction.from, + to: approvalTransaction.to, + }); + + approvalTransaction = { + ...approvalTransaction, + gas: approvalGas ?? DEFAULT_ERC20_APPROVE_GAS, + }; + } + } + + quotes = await this.#getAllQuotesWithGasEstimates(quotes); + + const gasFeeEstimates: EthGasPriceEstimate | GasFeeEstimates = + await this.getGasPrice(); + + const { topAggId, quoteValues } = this.#getBestQuoteAndQuotesValues( + quotes, + gasFeeEstimates, + ); + + const quotesLastFetched = Date.now(); + + const nextQuotesState: Partial = { + quotes, + quotesLastFetched, + approvalTransaction, + topAggId: quotes[topAggId]?.aggregator, + quoteValues, + quoteRefreshSeconds: quotes[topAggId]?.quoteRefreshSeconds, + }; + return { + nextQuotesState, + threshold: quotesLastFetched - timeStarted, + usedGasEstimate: gasFeeEstimates, + }; + } catch (error: unknown) { + if (isErrorWithMessage(error)) { + const errorKey = ((message): message is SwapsError => + Object.values(SwapsError).find( + (swapsError) => swapsError === message, + ) !== undefined)(error.message) + ? error.message + : SwapsError.ERROR_FETCHING_QUOTES; + this.stopPollingAndResetState({ + key: errorKey, + description: JSON.stringify(error), + }); + } + return { + nextQuotesState: null, + threshold: null, + usedGasEstimate: null, + }; + } + } + + /** + * Creates a SwapsController instance. + * @param opts - Constructor options. + * @param opts.clientId - The client id used by the controller. + * @param opts.pollCountLimit - The maximum number of times the controller will poll for quotes. + * @param opts.fetchAggregatorMetadataThreshold - The threshold for fetching aggregator metadata. + * @param opts.fetchTokensThreshold - The threshold for fetching tokens. + * @param opts.fetchTopAssetsThreshold - The threshold for fetching top assets. + * @param opts.chainId - The chain id used by the controller. + * @param opts.supportedChainIds - The supported chain ids used by the controller. + * @param opts.fetchGasFeeEstimates - Fetches gas fee estimates from GasFeeController. + * @param opts.fetchEstimatedMultiLayerL1Fee - Fetches an L1 fee for a given transaction. + * @param opts.messenger - The messaging system used by the controller. + * @param state - Initial state to set on this controller. + */ + constructor( + { + pollCountLimit = 3, + fetchAggregatorMetadataThreshold = 1000 * 60 * 60 * 24 * 15, + fetchTokensThreshold = 1000 * 60 * 60 * 24, + fetchTopAssetsThreshold = 1000 * 60 * 30, + chainId = ETH_CHAIN_ID, + supportedChainIds = [ + ETH_CHAIN_ID, + BSC_CHAIN_ID, + SWAPS_TESTNET_CHAIN_ID, + POLYGON_CHAIN_ID, + AVALANCHE_CHAIN_ID, + ], + clientId, + messenger, + fetchGasFeeEstimates, + fetchEstimatedMultiLayerL1Fee, + }: SwapsControllerOptions, + state: Partial = {}, + ) { + super({ + name: controllerName, + metadata, + messenger, + state: { + ...getDefaultSwapsControllerState(), + ...state, + }, + }); + + this.#clientId = clientId; + this.#fetchAggregatorMetadataThreshold = fetchAggregatorMetadataThreshold; + this.#fetchEstimatedMultiLayerL1Fee = fetchEstimatedMultiLayerL1Fee; + this.#fetchGasFeeEstimates = fetchGasFeeEstimates; + this.#fetchTokensThreshold = fetchTokensThreshold; + this.#fetchTopAssetsThreshold = fetchTopAssetsThreshold; + this.#pollCountLimit = pollCountLimit; + this.#supportedChainIds = supportedChainIds; + + this.setChainId(chainId); + + this.messagingSystem.registerActionHandler( + `SwapsController:updateQuotesWithGasPrice`, + this.updateQuotesWithGasPrice.bind(this), + ); + + this.messagingSystem.registerActionHandler( + `SwapsController:updateSelectedQuoteWithGasLimit`, + this.updateSelectedQuoteWithGasLimit.bind(this), + ); + + this.messagingSystem.registerActionHandler( + `SwapsController:startFetchAndSetQuotes`, + this.startFetchAndSetQuotes.bind(this), + ); + + this.messagingSystem.registerActionHandler( + `SwapsController:fetchTokenWithCache`, + this.fetchTokenWithCache.bind(this), + ); + + this.messagingSystem.registerActionHandler( + `SwapsController:fetchTopAssetsWithCache`, + this.fetchTopAssetsWithCache.bind(this), + ); + + this.messagingSystem.registerActionHandler( + `SwapsController:fetchAggregatorMetadataWithCache`, + this.fetchAggregatorMetadataWithCache.bind(this), + ); + + this.messagingSystem.registerActionHandler( + `SwapsController:stopPollingAndResetState`, + this.stopPollingAndResetState.bind(this), + ); + } + + /** + * Fetch current gas price + * @returns Promise resolving to the current gas price or throw an error + */ + /* istanbul ignore next */ + private async getGasPrice(): Promise { + if (this.#fetchGasFeeEstimates) { + const gasFeeState = await this.#fetchGasFeeEstimates({ + shouldUpdateState: this.#pollCount === 1, + }); + if ( + !gasFeeState || + gasFeeState.gasEstimateType === GAS_ESTIMATE_TYPES.NONE + ) { + throw new Error(SwapsError.SWAPS_GAS_PRICE_ESTIMATION); + } + + if (isGasFeeStateFeeMarket(gasFeeState)) { + return gasFeeState.gasFeeEstimates; + } else if (isGasFeeStateLegacy(gasFeeState)) { + return { gasPrice: gasFeeState.gasFeeEstimates.medium }; + } else if (isGasFeeStateEthGasPrice(gasFeeState)) { + return { gasPrice: gasFeeState.gasFeeEstimates.gasPrice }; + } + } + + try { + const { proposedGasPrice } = await fetchGasPrices( + this.#chainId, + this.#clientId, + ); + return { gasPrice: proposedGasPrice }; + } catch (error) { + // + } + + try { + const gasPrice = await query(this.#ethQuery, 'gasPrice'); + return { + gasPrice: weiHexToGweiDec(gasPrice).toString(), + }; + } catch (error) { + // + } + throw new Error(SwapsError.SWAPS_GAS_PRICE_ESTIMATION); + } + + /** + * Calculates a quote `QuotesValue` + * @param quote - Quote object + * @param gasLimit - A hex string representing max units of gas to spend + * @param gasFeeEstimates - current gas fee estimates + * @param customGasFee - custom gas fee values + */ + /* istanbul ignore next */ + private calculateQuoteValues( + quote: Quote, + gasLimit: string | null, + gasFeeEstimates: GasFeeEstimates | EthGasPriceEstimate, + customGasFee?: CustomEthGasPriceEstimate | CustomGasFee, + ): QuoteValues { + const { destinationTokenInfo } = this.state.fetchParamsMetaData; + const { + aggregator, + averageGas, + maxGas, + destinationAmount = 0, + fee: metaMaskFee, + sourceAmount, + sourceToken, + trade, + gasEstimateWithRefund, + gasEstimate, + gasMultiplier, + approvalNeeded, + destinationTokenRate, + multiLayerL1TradeFeeTotal, + } = quote; + + // trade gas + const { tradeGasLimit, tradeMaxGasLimit } = calculateGasLimits( + Boolean(approvalNeeded), + gasEstimateWithRefund, + gasEstimate, + averageGas, + maxGas, + gasMultiplier, + gasLimit, + ); + + let totalGasInWei: BigNumber; + let maxTotalGasInWei: BigNumber; + + if (isEthGasPriceEstimate(gasFeeEstimates)) { + const gasPrice = isCustomEthGasPriceEstimate(customGasFee) + ? customGasFee.gasPrice + : gasFeeEstimates.gasPrice; - if (!approvalTransaction) { - throw new Error(SwapsError.SWAPS_ALLOWANCE_ERROR); - } - const { gas: approvalGas } = await this.timedoutGasReturn({ - data: approvalTransaction.data, - from: approvalTransaction.from, - to: approvalTransaction.to, - }); + totalGasInWei = tradeGasLimit.times( + gweiDecToWEIBN(gasPrice).toString(16), + 16, + ); - approvalTransaction = { - ...approvalTransaction, - gas: approvalGas ?? DEFAULT_ERC20_APPROVE_GAS, - }; - } + maxTotalGasInWei = new BigNumber(tradeMaxGasLimit).times( + gweiDecToWEIBN(gasPrice).toString(16), + 16, + ); + + if (multiLayerL1TradeFeeTotal) { + totalGasInWei = totalGasInWei.plus(multiLayerL1TradeFeeTotal, 16); + maxTotalGasInWei = maxTotalGasInWei.plus(multiLayerL1TradeFeeTotal, 16); } + } else { + const estimatedBaseFee = + (isCustomGasFee(customGasFee) && customGasFee?.estimatedBaseFee) || + gasFeeEstimates.estimatedBaseFee; - quotes = await this.getAllQuotesWithGasEstimates(quotes); + const [maxFeePerGas, maxPriorityFeePerGas] = isCustomGasFee(customGasFee) + ? [customGasFee.maxFeePerGas, customGasFee.maxPriorityFeePerGas] + : [ + gasFeeEstimates.high.suggestedMaxFeePerGas, + gasFeeEstimates.high.suggestedMaxPriorityFeePerGas, + ]; - const gasFeeEstimates: EthGasPriceEstimate | GasFeeEstimates = - await this.getGasPrice(); + totalGasInWei = tradeGasLimit.times( + gweiDecToWEIBN(estimatedBaseFee) + .add(gweiDecToWEIBN(maxPriorityFeePerGas)) + .toString(16), + 16, + ); - const { topAggId, quoteValues } = this.getBestQuoteAndQuotesValues( - quotes, - gasFeeEstimates, + maxTotalGasInWei = new BigNumber(tradeMaxGasLimit).times( + gweiDecToWEIBN(maxFeePerGas).toString(16), + 16, ); + } - const quotesLastFetched = Date.now(); + // totalGas + trade value + // trade.value is a sum of different values depending on the transaction. + // It always includes any external fees charged by the quote source. In + // addition, if the source asset is NATIVE, trade.value includes the amount + // of swapped NATIVE. + const totalInWei = totalGasInWei.plus(trade.value, 16); + const maxTotalInWei = maxTotalGasInWei.plus(trade.value, 16); - const nextQuotesState: SwapsNextState = { - quotes, - quotesLastFetched, - approvalTransaction, - topAggId: quotes[topAggId]?.aggregator, - quoteValues, - quoteRefreshSeconds: quotes[topAggId]?.quoteRefreshSeconds, - }; - return { - nextQuotesState, - threshold: quotesLastFetched - timeStarted, - usedGasEstimate: gasFeeEstimates, - }; - } catch (error: any) { - const errorKey = Object.values(SwapsError).includes(error.message) - ? error.message - : SwapsError.ERROR_FETCHING_QUOTES; - this.stopPollingAndResetState({ key: errorKey, description: error }); - return { - nextQuotesState: null, - threshold: null, - usedGasEstimate: null, - }; - } - } + // if value in trade, NATIVE fee will be the gas, if not it will be the total wei + const weiFee = + sourceToken === NATIVE_SWAPS_TOKEN_ADDRESS + ? totalInWei.minus(sourceAmount, 10) + : totalInWei; // sourceAmount is in wei : totalInWei; + const maxWeiFee = + sourceToken === NATIVE_SWAPS_TOKEN_ADDRESS + ? maxTotalInWei.minus(sourceAmount, 10) + : maxTotalInWei; // sourceAmount is in wei : totalInWei; + const ethFee = calcTokenAmount(weiFee, 18); + const maxEthFee = calcTokenAmount(maxWeiFee, 18); - /** - * Name of this controller used during composition - */ - name = 'SwapsController'; + const decimalAdjustedDestinationAmount = calcTokenAmount( + destinationAmount, + destinationTokenInfo.decimals, + ); - /** - * List of required sibling controllers this controller needs to function - */ - requiredControllers = []; + // fees + const tokenPercentageOfPreFeeDestAmount = new BigNumber(100, 10) + .minus(metaMaskFee, 10) + .div(100); + const destinationAmountBeforeMetaMaskFee = + decimalAdjustedDestinationAmount.div(tokenPercentageOfPreFeeDestAmount); + const metaMaskFeeInTokens = destinationAmountBeforeMetaMaskFee.minus( + decimalAdjustedDestinationAmount, + ); - /** - * Creates a SwapsController instance. - * @param options - Constructor options. - * @param options.fetchGasFeeEstimates - Fetches gas fee estimates from GasFeeController. - * @param options.fetchEstimatedMultiLayerL1Fee - Fetches an L1 fee for a given transaction. - * @param config - Initial options used to configure this controller. - * @param state - Initial state to set on this controller. - */ - constructor( - { - fetchGasFeeEstimates, - fetchEstimatedMultiLayerL1Fee, - }: { - fetchGasFeeEstimates?: () => Promise; - fetchEstimatedMultiLayerL1Fee?: ( - eth: EthQuery, - options: { - txParams: TransactionParams; - chainId: Hex; - }, - ) => Promise; - }, - config?: Partial, - state?: Partial, - ) { - super(config, state); - this.defaultConfig = { - maxGasLimit: 2500000, - pollCountLimit: 3, - fetchAggregatorMetadataThreshold: 1000 * 60 * 60 * 24 * 15, - fetchTokensThreshold: 1000 * 60 * 60 * 24, - fetchTopAssetsThreshold: 1000 * 60 * 30, - provider: undefined, - chainId: '0x1', - supportedChainIds: [ - ETH_CHAIN_ID, - BSC_CHAIN_ID, - SWAPS_TESTNET_CHAIN_ID, - POLYGON_CHAIN_ID, - AVALANCHE_CHAIN_ID, - ], - clientId: undefined, - }; + const conversionRate = destinationTokenRate ?? 1; - this.defaultState = { - quotes: {}, - quoteValues: {}, - fetchParams: { - slippage: 0, - sourceToken: '', - sourceAmount: 0, - destinationToken: '', - walletAddress: '', - }, - fetchParamsMetaData: { - sourceTokenInfo: { - decimals: 0, - address: '', - symbol: '', - }, - destinationTokenInfo: { - decimals: 0, - address: '', - symbol: '', - }, - }, - topAggSavings: null, - aggregatorMetadata: null, - tokens: null, - topAssets: null, - approvalTransaction: null, - aggregatorMetadataLastFetched: 0, - quotesLastFetched: 0, - topAssetsLastFetched: 0, - error: { key: null, description: null }, - topAggId: null, - tokensLastFetched: 0, - isInPolling: false, - pollingCyclesLeft: config?.pollCountLimit ?? 3, - quoteRefreshSeconds: null, - usedGasEstimate: null, - usedCustomGas: null, - chainCache: { - '0x1': INITIAL_CHAIN_DATA, - }, + const ethValueOfTokens = decimalAdjustedDestinationAmount.times( + conversionRate, + 10, + ); + + // the more tokens the better + const overallValueOfQuote = ethValueOfTokens.minus(ethFee, 10); + + const quoteValues: QuoteValues = { + aggregator, + tradeGasLimit: tradeGasLimit.toString(10), + tradeMaxGasLimit: tradeMaxGasLimit.toString(10), + ethFee: ethFee.toFixed(18), + maxEthFee: maxEthFee.toFixed(18), + ethValueOfTokens: ethValueOfTokens.toFixed(18), + overallValueOfQuote: overallValueOfQuote.toFixed(18), + metaMaskFeeInEth: metaMaskFeeInTokens.times(conversionRate).toFixed(18), }; - this.fetchGasFeeEstimates = fetchGasFeeEstimates; - this.fetchEstimatedMultiLayerL1Fee = fetchEstimatedMultiLayerL1Fee; - this.initialize(); + return quoteValues; } - set provider(provider: any) { - if (provider) { - this.ethQuery = new EthQuery(provider); - this.web3 = new Web3(provider); - } - } + /* istanbul ignore next */ + private calculatesCustomLimitMaxEthFee( + quote: Quote, + gasFee: + | EthGasPriceEstimate + | GasFeeEstimates + | CustomGasFee + | CustomEthGasPriceEstimate, + gasLimit: string, + ): string { + const { + averageGas, + maxGas, + sourceAmount, + sourceToken, + trade, + gasEstimateWithRefund, + gasEstimate, + gasMultiplier, + approvalNeeded, + } = quote; - set chainId(chainId: Hex) { - if (!this.config.supportedChainIds.includes(chainId)) { - return; - } + const { tradeMaxGasLimit } = calculateGasLimits( + Boolean(approvalNeeded), + gasEstimateWithRefund, + gasEstimate, + averageGas, + maxGas, + gasMultiplier, + gasLimit, + ); - const { chainCache } = this.state; - if (!chainCache?.[chainId]) { - this.update({ - ...INITIAL_CHAIN_DATA, - chainCache: getNewChainCache(chainCache, chainId, INITIAL_CHAIN_DATA), - }); - return; + let gasPrice; + if (isCustomEthGasPriceEstimate(gasFee) || isEthGasPriceEstimate(gasFee)) { + gasPrice = gasFee.gasPrice; + } else if (isCustomGasFee(gasFee)) { + gasPrice = gasFee.maxFeePerGas; + } else { + gasPrice = gasFee.high.suggestedMaxFeePerGas; } - const cachedData = chainCache[chainId]; - this.update({ - ...cachedData, - }); + const maxTotalGasInWei = new BigNumber(tradeMaxGasLimit).times( + gweiDecToWEIBN(gasPrice).toString(16), + 16, + ); + const maxTotalInWei = maxTotalGasInWei.plus(trade.value ?? '0x0', 16); + const maxWeiFee = + sourceToken === NATIVE_SWAPS_TOKEN_ADDRESS + ? maxTotalInWei.minus(sourceAmount, 10) + : maxTotalInWei; + const maxEthFee = calcTokenAmount(maxWeiFee, 18).toFixed(18); + return maxEthFee; } /** @@ -941,12 +856,16 @@ export default class SwapsController extends BaseControllerV1< if (!usedGasEstimate) { return; } - const { topAggId, quoteValues } = this.getBestQuoteAndQuotesValues( + const { topAggId, quoteValues } = this.#getBestQuoteAndQuotesValues( quotes, usedGasEstimate, customGasFee, ); - this.update({ topAggId, quoteValues, usedCustomGas: customGasFee }); + this.update((_state) => { + _state.quoteValues = quoteValues; + _state.topAggId = topAggId; + _state.usedCustomGas = customGasFee; + }); } /** @@ -965,10 +884,28 @@ export default class SwapsController extends BaseControllerV1< usedCustomGas ?? usedGasEstimate, customGasLimit, ); - quoteValues[selectedQuote.aggregator].maxEthFee = maxEthFee; - this.update({ topAggId, quoteValues }); + const clonedQuoteValues = { + ...quoteValues, + [selectedQuote.aggregator]: { + ...quoteValues[selectedQuote.aggregator], + maxEthFee, + }, + }; + + clonedQuoteValues[selectedQuote.aggregator].maxEthFee = maxEthFee; + + this.update((_state) => { + _state.topAggId = topAggId; + _state.quoteValues = clonedQuoteValues; + }); } + /** + * Starts the polling process. + * @param fetchParams - Parameters to fetch quotes. + * @param fetchParamsMetaData - Metadata for the fetchParams. + * @returns Promise resolving when this operation completes. + */ startFetchAndSetQuotes( fetchParams?: APIFetchQuotesParams, fetchParamsMetaData?: APIFetchQuotesMetadata, @@ -980,38 +917,58 @@ export default class SwapsController extends BaseControllerV1< // Every time we get a new request that is not from the polling, // we reset the poll count so we can poll for up to three more sets // of quotes with these new params. - this.pollCount = 0; + this.#pollCount = 0; - this.update({ fetchParams, fetchParamsMetaData }); + this.update((_state) => { + _state.fetchParams = fetchParams; + _state.fetchParamsMetaData = + fetchParamsMetaData ?? _state.fetchParamsMetaData; + }); // ignoring rule since otherwise we need to change the behavior of the function // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.pollForNewQuotesWithThreshold(); + this.#pollForNewQuotesWithThreshold(); } + /** + * Fetches the tokens and updates the state with them. + */ async fetchTokenWithCache() { - const { chainId, clientId, fetchTokensThreshold, supportedChainIds } = - this.config; const { tokens, tokensLastFetched } = this.state; - if (!supportedChainIds.includes(chainId)) { + if (!this.#supportedChainIds.includes(this.#chainId)) { return; } - if (!tokens || fetchTokensThreshold < Date.now() - tokensLastFetched) { - const releaseLock = await this.mutex.acquire(); + if ( + !tokens || + this.#fetchTokensThreshold < Date.now() - tokensLastFetched + ) { + const releaseLock = await this.#mutex.acquire(); try { - const newTokens = await fetchTokens(chainId, clientId); - const data = { tokens: newTokens, tokensLastFetched: Date.now() }; - this.update({ - ...data, - chainCache: getNewChainCache(this.state.chainCache, chainId, data), + const newTokens = await fetchTokens(this.#chainId, this.#clientId); + this.update((_state) => { + _state.tokens = newTokens; + _state.tokensLastFetched = Date.now(); + _state.chainCache = getNewChainCache( + _state.chainCache, + this.#chainId, + { + tokens: newTokens, + tokensLastFetched: Date.now(), + }, + ); }); } catch { - const data = { tokensLastFetched: 0 }; - this.update({ - ...data, - chainCache: getNewChainCache(this.state.chainCache, chainId, data), + this.update((_state) => { + _state.tokensLastFetched = 0; + _state.chainCache = getNewChainCache( + _state.chainCache, + this.#chainId, + { + tokensLastFetched: 0, + }, + ); }); } finally { releaseLock(); @@ -1019,35 +976,48 @@ export default class SwapsController extends BaseControllerV1< } } + /** + * Fetches the top assets and updates the state with them. + */ async fetchTopAssetsWithCache() { - const { chainId, clientId, fetchTopAssetsThreshold, supportedChainIds } = - this.config; const { topAssets, topAssetsLastFetched } = this.state; - if (!supportedChainIds.includes(chainId)) { + if (!this.#supportedChainIds.includes(this.#chainId)) { return; } if ( !topAssets || - fetchTopAssetsThreshold < Date.now() - topAssetsLastFetched + this.#fetchTopAssetsThreshold < Date.now() - topAssetsLastFetched ) { - const releaseLock = await this.mutex.acquire(); + const releaseLock = await this.#mutex.acquire(); try { - const newTopAssets = await fetchTopAssets(chainId, clientId); + const newTopAssets = await fetchTopAssets( + this.#chainId, + this.#clientId, + ); const data = { topAssets: newTopAssets, topAssetsLastFetched: Date.now(), }; - this.update({ - ...data, - chainCache: getNewChainCache(this.state.chainCache, chainId, data), + this.update((_state) => { + _state.topAssets = data.topAssets; + _state.topAssetsLastFetched = data.topAssetsLastFetched; + _state.chainCache = getNewChainCache( + _state.chainCache, + this.#chainId, + data, + ); }); } catch { const data = { topAssetsLastFetched: 0 }; - this.update({ - ...data, - chainCache: getNewChainCache(this.state.chainCache, chainId, data), + this.update((_state) => { + _state.topAssetsLastFetched = data.topAssetsLastFetched; + _state.chainCache = getNewChainCache( + _state.chainCache, + this.#chainId, + data, + ); }); } finally { releaseLock(); @@ -1055,43 +1025,51 @@ export default class SwapsController extends BaseControllerV1< } } + /** + * Fetches the aggregator metadata and updates the state with it. + */ async fetchAggregatorMetadataWithCache() { - const { - chainId, - clientId, - fetchAggregatorMetadataThreshold, - supportedChainIds, - } = this.config; const { aggregatorMetadata, aggregatorMetadataLastFetched } = this.state; - if (!supportedChainIds.includes(chainId)) { + if (!this.#supportedChainIds.includes(this.#chainId)) { return; } if ( !aggregatorMetadata || - fetchAggregatorMetadataThreshold < + this.#fetchAggregatorMetadataThreshold < Date.now() - aggregatorMetadataLastFetched ) { - const releaseLock = await this.mutex.acquire(); + const releaseLock = await this.#mutex.acquire(); try { const newAggregatorMetada = await fetchAggregatorMetadata( - chainId, - clientId, + this.#chainId, + this.#clientId, ); const data = { aggregatorMetadata: newAggregatorMetada, aggregatorMetadataLastFetched: Date.now(), }; - this.update({ - ...data, - chainCache: getNewChainCache(this.state.chainCache, chainId, data), + this.update((_state) => { + _state.aggregatorMetadata = data.aggregatorMetadata; + _state.aggregatorMetadataLastFetched = + data.aggregatorMetadataLastFetched; + _state.chainCache = getNewChainCache( + _state.chainCache, + this.#chainId, + data, + ); }); } catch { const data = { aggregatorMetadataLastFetched: 0 }; - this.update({ - ...data, - chainCache: getNewChainCache(this.state.chainCache, chainId, data), + this.update((_state) => { + _state.aggregatorMetadataLastFetched = + data.aggregatorMetadataLastFetched; + _state.chainCache = getNewChainCache( + _state.chainCache, + this.#chainId, + data, + ); }); } finally { releaseLock(); @@ -1107,27 +1085,135 @@ export default class SwapsController extends BaseControllerV1< */ stopPollingAndResetState( error: { - key: SwapsError | null; + key: null | SwapsError; description: string | null; } = { key: null, description: null, }, ) { - this.abortController && this.abortController.abort(); - this.handle && clearTimeout(this.handle); - this.pollCount = Number(this.config.pollCountLimit) + 1; - this.update({ - ...this.defaultState, - isInPolling: false, - tokensLastFetched: this.state.tokensLastFetched, - topAssetsLastFetched: this.state.topAssetsLastFetched, - aggregatorMetadataLastFetched: this.state.aggregatorMetadataLastFetched, - tokens: this.state.tokens, - topAssets: this.state.topAssets, - aggregatorMetadata: this.state.aggregatorMetadata, - chainCache: this.state.chainCache, - error, + this.#abortController && this.#abortController.abort(); + this.#handle && clearTimeout(this.#handle); + this.#pollCount = Number(this.#pollCountLimit) + 1; + this.update((_state) => { + const currentState = { ..._state }; + const defaultState = getDefaultSwapsControllerState(); + getKnownPropertyNames(defaultState).forEach((key) => { + const typedKey = key; + (_state as any)[typedKey] = defaultState[typedKey]; + }); + _state.isInPolling = false; + _state.tokensLastFetched = currentState.tokensLastFetched; + _state.topAssetsLastFetched = currentState.topAssetsLastFetched; + _state.aggregatorMetadataLastFetched = + currentState.aggregatorMetadataLastFetched; + _state.tokens = currentState.tokens; + _state.topAssets = currentState.topAssets; + _state.aggregatorMetadata = currentState.aggregatorMetadata; + _state.chainCache = currentState.chainCache; + _state.error.key = error.key; + _state.error.description = error.description; }); } + + setChainId = (chainId: Hex): void => { + this.#chainId = chainId; + this.#buildChainCache(chainId); + }; + + setProvider( + provider: Provider, + opts?: { chainId: Hex; pollCountLimit: number }, + ): void { + // @ts-expect-error TODO: align `Web3` with EIP-1193 provider + this.#web3 = new Web3(provider); + this.#ethQuery = new EthQuery(provider); + + if (opts?.chainId) { + this.setChainId(opts.chainId); + } + if (opts?.pollCountLimit) { + this.#pollCountLimit = opts.pollCountLimit; + } + } + + /** + * Updates the state of the controller for testing purposes. + * This method should not be used outside of testing. + * @param newState - The new state to set. + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + __test__updateState = (newState: Partial): void => { + this.update((oldState) => { + return { ...(oldState as SwapsControllerState), ...newState }; + }); + }; + + /** + * Helper method to update the internal class state. + * This method should not be used outside of testing. + * @param key - The key to update in the internal state. + * @param value - The value to set in the internal state. + * @returns The value set in the internal state. + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + __test__updatePrivate = (key: string, value: any) => { + switch (key) { + case '#fetchAggregatorMetadataThreshold': + this.#fetchAggregatorMetadataThreshold = value; + return this.#fetchAggregatorMetadataThreshold; + case '#fetchTokensThreshold': + this.#fetchTokensThreshold = value; + return this.#fetchTokensThreshold; + case '#fetchTopAssetsThreshold': + this.#fetchTopAssetsThreshold = value; + return this.#fetchTopAssetsThreshold; + case '#supportedChainIds': + this.#supportedChainIds = value; + return this.#supportedChainIds; + case '#handle': + this.#handle = value; + return this.#handle; + default: + return undefined; + } + }; + + /** + * Helper method to get the internal class state. + * This method should not be used outside of testing. + * @param key - The key to get from the internal state. + * @returns The value from the internal state. + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + __test__getInternal = (key: string) => { + switch (key) { + case '#fetchAggregatorMetadataThreshold': + return this.#fetchAggregatorMetadataThreshold; + case '#fetchTokensThreshold': + return this.#fetchTokensThreshold; + case '#fetchTopAssetsThreshold': + return this.#fetchTopAssetsThreshold; + case '#pollCountLimit': + return this.#pollCountLimit; + case '#chainId': + return this.#chainId; + case '#supportedChainIds': + return this.#supportedChainIds; + case '#clientId': + return this.#clientId; + case '#web3': + return this.#web3; + case '#ethQuery': + return this.#ethQuery; + case '#handle': + return this.#handle; + case '#fetchGasFeeEstimates': + return this.#fetchGasFeeEstimates; + case '#fetchEstimatedMultiLayerL1Fee': + return this.#fetchEstimatedMultiLayerL1Fee; + default: + return undefined; + } + }; } diff --git a/src/constants.ts b/src/constants.ts index 8ae29925..40b19a28 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,6 +1,15 @@ import { toHex } from '@metamask/controller-utils'; -import type { SwapsToken } from './swapsInterfaces'; +import type { SwapsToken, SwapsControllerState, ChainData } from './types'; + +export const INITIAL_CHAIN_DATA: ChainData = { + aggregatorMetadata: null, + tokens: null, + topAssets: null, + aggregatorMetadataLastFetched: 0, + topAssetsLastFetched: 0, + tokensLastFetched: 0, +}; //* Chain IDs and names @@ -218,3 +227,48 @@ export const DEFAULT_ERC20_APPROVE_GAS = '0x1d4c0'; // The MAX_GAS_LIMIT is a number that is higher than the maximum gas costs we have observed on any aggregator export const MAX_GAS_LIMIT = 2500000; + +export const controllerName = 'SwapsController'; + +export const getDefaultSwapsControllerState = (): SwapsControllerState => ({ + quotes: {}, + quoteValues: {}, + fetchParams: { + slippage: 0, + sourceToken: '', + sourceAmount: 0, + destinationToken: '', + walletAddress: '', + }, + fetchParamsMetaData: { + sourceTokenInfo: { + decimals: 0, + address: '', + symbol: '', + }, + destinationTokenInfo: { + decimals: 0, + address: '', + symbol: '', + }, + }, + topAggSavings: null, + aggregatorMetadata: null, + tokens: null, + topAssets: null, + approvalTransaction: null, + aggregatorMetadataLastFetched: 0, + quotesLastFetched: 0, + topAssetsLastFetched: 0, + error: { key: null, description: null }, + topAggId: null, + tokensLastFetched: 0, + isInPolling: false, + pollingCyclesLeft: 3, + quoteRefreshSeconds: null, + usedGasEstimate: null, + usedCustomGas: null, + chainCache: { + [ETH_CHAIN_ID]: INITIAL_CHAIN_DATA, + }, +}); diff --git a/src/index.ts b/src/index.ts index 94f16e6a..8e827e08 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,21 @@ import SwapsController from './SwapsController'; export * as swapsUtils from './swapsUtil'; +export type { + SwapsControllerState, + SwapsControllerGetStateAction, + SwapsControllerStateChangeEvent, + SwapsControllerActions, + SwapsControllerEvents, + SwapsControllerMessenger, + SwapsControllerOptions, + SwapsControllerUpdateQuotesWithGasPrice, + SwapsControllerUpdateSelectedQuoteWithGasLimit, + SwapsControllerStartFetchAndSetQuotes, + SwapsControllerFetchTokenWithCache, + SwapsControllerFetchTopAssetsWithCache, + SwapsControllerFetchAggregatorMetadataWithCache, + SwapsControllerStopPollingAndResetState, +} from './types'; + export default SwapsController; diff --git a/src/swapsUtil.test.ts b/src/swapsUtil.test.ts index a8bc1d43..4149c9c2 100644 --- a/src/swapsUtil.test.ts +++ b/src/swapsUtil.test.ts @@ -1,12 +1,10 @@ import { BigNumber } from 'bignumber.js'; -import type { QuoteValues, SwapsToken } from './swapsInterfaces'; -import { APIType } from './swapsInterfaces'; -import * as swapsUtil from './swapsUtil'; import { BNToHex, query, toHex } from '@metamask/controller-utils'; -import { BN } from 'bn.js'; -import { TransactionParams } from '@metamask/transaction-controller'; import { add0x } from '@metamask/utils'; +import { BN } from 'bn.js'; +import type { QuoteValues, SwapsToken, TxParams } from './types'; +import * as swapsUtil from './swapsUtil'; /** * Mocks the fetch function for testing purposes. @@ -171,20 +169,30 @@ const FAKE_SWAPS_TOKEN = { describe('SwapsUtil', () => { describe('getBaseApiURL', () => { it('should return expected values', () => { - expect(swapsUtil.getBaseApiURL(APIType.TRADES, '0x1')).toBeDefined(); - expect(swapsUtil.getBaseApiURL(APIType.TOKENS, '0x1')).toBeDefined(); - expect(swapsUtil.getBaseApiURL(APIType.TOKEN, '0x1')).toBeDefined(); - expect(swapsUtil.getBaseApiURL(APIType.TOP_ASSETS, '0x1')).toBeDefined(); expect( - swapsUtil.getBaseApiURL(APIType.FEATURE_FLAG, '0x1'), + swapsUtil.getBaseApiURL(swapsUtil.APIType.TRADES, '0x1'), + ).toBeDefined(); + expect( + swapsUtil.getBaseApiURL(swapsUtil.APIType.TOKENS, '0x1'), + ).toBeDefined(); + expect( + swapsUtil.getBaseApiURL(swapsUtil.APIType.TOKEN, '0x1'), + ).toBeDefined(); + expect( + swapsUtil.getBaseApiURL(swapsUtil.APIType.TOP_ASSETS, '0x1'), + ).toBeDefined(); + expect( + swapsUtil.getBaseApiURL(swapsUtil.APIType.FEATURE_FLAG, '0x1'), ).toBeDefined(); expect( - swapsUtil.getBaseApiURL(APIType.AGGREGATOR_METADATA, '0x1'), + swapsUtil.getBaseApiURL(swapsUtil.APIType.AGGREGATOR_METADATA, '0x1'), + ).toBeDefined(); + expect( + swapsUtil.getBaseApiURL(swapsUtil.APIType.GAS_PRICES, '0x1'), ).toBeDefined(); - expect(swapsUtil.getBaseApiURL(APIType.GAS_PRICES, '0x1')).toBeDefined(); expect(() => - swapsUtil.getBaseApiURL('error value' as APIType, '0x1'), + swapsUtil.getBaseApiURL('error value' as swapsUtil.APIType, '0x1'), ).toThrow(); }); }); @@ -1436,7 +1444,7 @@ describe('SwapsUtil', () => { }); it('should estimate gas correctly for a given transaction', async () => { - const transaction: TransactionParams = { + const transaction: TxParams = { from: '0x1234', to: '0x5678', value: '0x0', @@ -1461,7 +1469,7 @@ describe('SwapsUtil', () => { }); it('should handle transactions without data correctly', async () => { - const transaction: TransactionParams = { + const transaction: TxParams = { from: '0x1234', to: '0x5678', value: '0x0', diff --git a/src/swapsUtil.ts b/src/swapsUtil.ts index a4915139..eac44681 100644 --- a/src/swapsUtil.ts +++ b/src/swapsUtil.ts @@ -1,16 +1,26 @@ import { + BNToHex, convertHexToDecimal, handleFetch, - timeoutFetch, - BNToHex, query, + timeoutFetch, } from '@metamask/controller-utils'; +import type EthQuery from '@metamask/eth-query'; +import type { + EthGasPriceEstimate, + GasFeeState, + GasFeeStateEthGasPrice, + GasFeeStateFeeMarket, + GasFeeStateLegacy, +} from '@metamask/gas-fee-controller'; +import { GAS_ESTIMATE_TYPES } from '@metamask/gas-fee-controller'; import type { TransactionParams } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; -import { add0x } from '@metamask/utils'; +import { add0x, getKnownPropertyNames } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; import { BN } from 'bn.js'; +// eslint-disable-next-line import/order import { ALLOWED_CONTRACT_ADDRESSES, API_BASE_URL, @@ -26,9 +36,14 @@ import { SWAPS_WRAPPED_TOKENS_ADDRESSES, TOKEN_TRANSFER_LOG_TOPIC_HASH, } from './constants'; + import type { APIAggregatorMetadata, APIFetchQuotesParams, + ChainCache, + ChainData, + CustomEthGasPriceEstimate, + CustomGasFee, FeatureFlags, NetworkFeatureFlags, NetworksFeatureStatus, @@ -37,25 +52,27 @@ import type { SwapsAsset, SwapsToken, TransactionReceipt, -} from './swapsInterfaces'; -import { APIType } from './swapsInterfaces'; + TxParams, +} from './types'; // / // / BEGIN: Lifted from now unexported normalizeTransaction in @metamask/transaction-controller@3.0.0 // / -export const TX_NORMALIZERS: { [param in keyof TransactionParams]: any } = { +export const TX_NORMALIZERS = { data: (data: string) => add0x(data), - from: (from: string) => add0x(from).toLowerCase(), + from: (from: string) => add0x(from).toLowerCase() as Hex, gas: (gas: string) => add0x(gas), gasPrice: (gasPrice: string) => add0x(gasPrice), nonce: (nonce: string) => add0x(nonce), - to: (to: string) => add0x(to).toLowerCase(), + to: (to: string) => add0x(to).toLowerCase() as Hex, value: (value: string) => add0x(value), maxFeePerGas: (maxFeePerGas: string) => add0x(maxFeePerGas), maxPriorityFeePerGas: (maxPriorityFeePerGas: string) => add0x(maxPriorityFeePerGas), estimatedBaseFee: (maxPriorityFeePerGas: string) => add0x(maxPriorityFeePerGas), +} satisfies { + [param in keyof TransactionParams]: (arg: string) => Hex; }; /** @@ -63,16 +80,17 @@ export const TX_NORMALIZERS: { [param in keyof TransactionParams]: any } = { * @param transaction - Transaction object to normalize. * @returns Normalized Transaction object. */ -export function normalizeTransaction(transaction: TransactionParams) { +export function normalizeTransaction( + transaction: TransactionParams, +): Pick { const normalizedTransaction: TransactionParams = { from: '' }; - let key: keyof TransactionParams; - for (key in TX_NORMALIZERS) { - if (transaction[key]) { + getKnownPropertyNames(TX_NORMALIZERS).forEach((key) => { + if (key in transaction && transaction[key]) { normalizedTransaction[key] = TX_NORMALIZERS[key]( - transaction[key], - ) as never; + transaction[key] as NonNullable, + ); } - } + }); return normalizedTransaction; } @@ -93,6 +111,17 @@ export enum SwapsError { SWAPS_ALLOWANCE_TIMEOUT = 'swaps-allowance-timeout', SWAPS_ALLOWANCE_ERROR = 'swaps-allowance-error', } + +export enum APIType { + TRADES = 'TRADES', + TOKENS = 'TOKENS', + TOP_ASSETS = 'TOP_ASSETS', + FEATURE_FLAG = 'FEATURE_FLAG', + AGGREGATOR_METADATA = 'AGGREGATOR_METADATA', + TOKEN = 'TOKEN', + GAS_PRICES = 'GAS_PRICES', +} + // Functions /** * Returns the client ID header. @@ -262,7 +291,11 @@ export async function fetchTradesInfo( const tradeURL = `${getBaseApiURL( APIType.TRADES, chainId, - )}?${new URLSearchParams(urlParams as Record).toString()}`; + )}?${new URLSearchParams( + Object.fromEntries( + Object.entries(urlParams).map(([key, value]) => [key, String(value)]), + ), + ).toString()}`; const tradesResponse = await timeoutFetch( tradeURL, @@ -291,14 +324,13 @@ export async function fetchTradesInfo( : BNToHex(new BN(MAX_GAS_LIMIT)), }); - return { - ...aggIdTradeMap, + return Object.assign(aggIdTradeMap, { [quote.aggregator]: { ...quote, slippage, trade: constructedTrade, }, - }; + }); } return aggIdTradeMap; @@ -711,8 +743,8 @@ export function calcTokenAmount(value: number | BigNumber, decimals: number) { * @returns Promise resolving to an object containing gas and gasPrice. */ export async function estimateGas( - transaction: TransactionParams, - ethQuery: any, + transaction: Omit & Partial>, + ethQuery: EthQuery, ) { const estimatedTransaction = { ...transaction }; const { value, data } = estimatedTransaction; @@ -760,7 +792,7 @@ export function constructTxParams({ gas?: string; gasPrice?: string; amount?: string; -}): any { +}): Pick { const txParams: TransactionParams = { data, from, @@ -775,3 +807,97 @@ export function constructTxParams({ } return normalizeTransaction(txParams); } + +// Functions to determine type of the return value from GasFeeController + +/** + * Checks if the given object is of type GasFeeStateEthGasPrice. + * @param object - The gas fee state to be checked. + * @returns Whether the given object is of type GasFeeStateEthGasPrice. + */ +export function isGasFeeStateEthGasPrice( + object: GasFeeState, +): object is GasFeeStateEthGasPrice { + return object.gasEstimateType === GAS_ESTIMATE_TYPES.ETH_GASPRICE; +} + +/** + * Determines if the given object is of type GasFeeStateFeeMarket based on its 'gasEstimateType'. + * @param object - The gas fee state to be evaluated. + * @returns Whether the object is of type GasFeeStateFeeMarket. + */ +export function isGasFeeStateFeeMarket( + object: GasFeeState, +): object is GasFeeStateFeeMarket { + return object.gasEstimateType === GAS_ESTIMATE_TYPES.FEE_MARKET; +} + +/** + * Determines if the given object is of type GasFeeStateLegacy based on its 'gasEstimateType'. + * @param object - The gas fee state to be evaluated. + * @returns Whether the object is of type GasFeeStateLegacy. + */ +export function isGasFeeStateLegacy( + object: GasFeeState, +): object is GasFeeStateLegacy { + return object.gasEstimateType === GAS_ESTIMATE_TYPES.LEGACY; +} + +/** + * Determines if the given object is of type EthGasPriceEstimate. + * @param object - The object to be evaluated. + * @returns Whether the object is of type EthGasPriceEstimate. + */ +export function isEthGasPriceEstimate( + object: Record | undefined, +): object is EthGasPriceEstimate { + return Boolean(object) && object?.gasPrice !== undefined; +} + +/** + * Determines if the given object is of type CustomEthGasPriceEstimate. + * @param object - The object to be evaluated. + * @returns Whether the object is of type CustomEthGasPriceEstimate. + */ +export function isCustomEthGasPriceEstimate( + object: Record | undefined, +): object is CustomEthGasPriceEstimate { + return Boolean(object) && object?.gasPrice !== undefined; +} + +/** + * Determines if the given object is of type CustomGasFee. + * @param object - The object to be evaluated. + * @returns Whether the object is of type CustomGasFee. + */ +export function isCustomGasFee( + object: Record | undefined, +): object is CustomGasFee { + return ( + object !== undefined && + Boolean(object) && + 'maxFeePerGas' in object && + 'maxPriorityFeePerGas' in object + ); +} + +/** + * Gets a new chainCache for a chainId with updated data. + * @param chainCache - Current chainCache from state. + * @param chainId - Current chainId from the config. + * @param data - Data to be updated. + * @returns The new chainCache. + */ +export function getNewChainCache( + chainCache: ChainCache, + chainId: Hex, + data: Partial, +): ChainCache { + return { + ...chainCache, + [chainId]: { + ...chainCache?.[chainId], + ...data, + }, + }; +} diff --git a/src/swapsInterfaces.ts b/src/types.ts similarity index 56% rename from src/swapsInterfaces.ts rename to src/types.ts index 03f77313..1600066b 100644 --- a/src/swapsInterfaces.ts +++ b/src/types.ts @@ -1,15 +1,18 @@ -import type { TransactionParams } from '@metamask/transaction-controller'; -import type { BigNumber } from 'bignumber.js'; - -export enum APIType { - TRADES = 'TRADES', - TOKENS = 'TOKENS', - TOP_ASSETS = 'TOP_ASSETS', - FEATURE_FLAG = 'FEATURE_FLAG', - AGGREGATOR_METADATA = 'AGGREGATOR_METADATA', - TOKEN = 'TOKEN', - GAS_PRICES = 'GAS_PRICES', -} +import type { + RestrictedControllerMessenger, + ControllerStateChangeEvent, + ControllerGetStateAction, +} from '@metamask/base-controller'; +import type EthQuery from '@metamask/eth-query'; +import type { + EthGasPriceEstimate, + GasFeeEstimates, + GasFeeState, +} from '@metamask/gas-fee-controller'; +import type { Hex, JsonRpcError } from '@metamask/utils'; + +import type SwapsController from './SwapsController'; +import type { controllerName, SwapsError } from './swapsUtil'; export type SwapsAsset = { address: string; @@ -24,8 +27,11 @@ export type SwapsToken = { } & SwapsAsset; export type NetworkFeatureFlags = { + // eslint-disable-next-line @typescript-eslint/naming-convention mobile_active: boolean; + // eslint-disable-next-line @typescript-eslint/naming-convention extension_active: boolean; + // eslint-disable-next-line @typescript-eslint/naming-convention fallback_to_v1?: boolean; }; @@ -34,8 +40,11 @@ export type NetworksFeatureStatus = { }; export type NetworkFeatureFlagsAll = { + // eslint-disable-next-line @typescript-eslint/naming-convention mobile_active: boolean; + // eslint-disable-next-line @typescript-eslint/naming-convention extension_active: boolean; + // eslint-disable-next-line @typescript-eslint/naming-convention fallback_to_v1?: boolean; fallbackToV1: boolean; mobileActive: boolean; @@ -55,8 +64,11 @@ export type NetworksFeatureStatusAll = { }; export type GlobalFeatureFlags = { + // eslint-disable-next-line @typescript-eslint/naming-convention smart_transactions: { + // eslint-disable-next-line @typescript-eslint/naming-convention mobile_active: boolean; + // eslint-disable-next-line @typescript-eslint/naming-convention extension_active: boolean; }; smartTransactions: { @@ -116,17 +128,17 @@ export type APIAggregatorMetadata = { type QuoteTransaction = { value: string; -} & TransactionParams; +} & TxParams; /** * Savings of a quote * @interface QuoteSavings */ export type QuoteSavings = { - total: BigNumber; - performance: BigNumber; - fee: BigNumber; - medianMetaMaskFee: BigNumber; + total: string; + performance: string; + fee: string; + medianMetaMaskFee: string; }; /** @@ -158,15 +170,10 @@ export type QuoteSavings = { */ export type Quote = { trade: QuoteTransaction; - approvalNeeded: null | { - data: string; - to: string; - from: string; - gas: string; - }; + approvalNeeded: TxParams | null; sourceAmount: string; destinationAmount: number; - error: null | Error; + error: JsonRpcError | null; sourceToken: string; destinationToken: string; maxGas: number; @@ -183,7 +190,7 @@ export type Quote = { gasEstimateWithRefund: string | null; destinationTokenRate: number | null; sourceTokenRate: number | null; - multiLayerL1TradeFeeTotal: string | undefined; + multiLayerL1TradeFeeTotal: string | null; }; /** @@ -238,6 +245,16 @@ export type TransactionReceipt = { status: string; }; +export type TxParams = { + from: string; + to: string; + value?: string; + data?: string; + gas: string; + gasPrice?: string; + nonce?: string; +}; + export type ChainData = { aggregatorMetadata: null | { [key: string]: APIAggregatorMetadata }; tokens: null | SwapsToken[]; @@ -250,3 +267,167 @@ export type ChainData = { export type ChainCache = { [key: string]: ChainData; }; + +// Custom types for custom gas values +export type CustomEthGasPriceEstimate = { + gasPrice: string; // a GWEI dec string + selected?: 'low' | 'medium' | 'high'; +}; + +export type CustomGasFee = { + maxFeePerGas: string; // a GWEI dec string + maxPriorityFeePerGas: string; // a GWEI dec string + estimatedBaseFee?: string; // a GWEI dec string + selected?: 'low' | 'medium' | 'high'; +}; + +export type SwapsControllerState = { + quotes: { [key: string]: Quote }; + fetchParams: APIFetchQuotesParams; + fetchParamsMetaData: APIFetchQuotesMetadata; + topAggSavings: QuoteSavings | null; + quotesLastFetched: null | number; + error: { key: null | SwapsError; description: null | string }; + topAggId: null | string; + isInPolling: boolean; + pollingCyclesLeft: number; + approvalTransaction: TxParams | null; + quoteValues: { [key: string]: QuoteValues } | null; + quoteRefreshSeconds: number | null; + usedGasEstimate: EthGasPriceEstimate | GasFeeEstimates | null; + usedCustomGas: CustomEthGasPriceEstimate | CustomGasFee | null; + aggregatorMetadata: null | { [key: string]: APIAggregatorMetadata }; + aggregatorMetadataLastFetched: number; + tokens: null | SwapsToken[]; + tokensLastFetched: number; + topAssets: null | SwapsAsset[]; + topAssetsLastFetched: number; + chainCache: ChainCache; +}; + +/** + * The action that fetches the state of the {@link SwapsController}. + */ +export type SwapsControllerGetStateAction = ControllerGetStateAction< + typeof controllerName, + SwapsControllerState +>; + +/** + * The event that {@link SwapsController} can emit. + */ +export type SwapsControllerStateChangeEvent = ControllerStateChangeEvent< + typeof controllerName, + SwapsControllerState +>; + +/** + * The external actions available to the {@link SwapsController}. + * TODO: Add GasFeeControllerFetchGasFeeEstimates once GasFeeController exports this action type + */ +export type AllowedActions = never; + +/** + * The internal actions available to the SwapsController. + */ +export type SwapsControllerActions = + | SwapsControllerGetStateAction + | SwapsControllerUpdateQuotesWithGasPrice + | SwapsControllerUpdateSelectedQuoteWithGasLimit + | SwapsControllerStartFetchAndSetQuotes + | SwapsControllerFetchTokenWithCache + | SwapsControllerFetchTopAssetsWithCache + | SwapsControllerFetchAggregatorMetadataWithCache + | SwapsControllerStopPollingAndResetState; + +/** + * The events that the SwapsController can emit. + */ +export type SwapsControllerEvents = SwapsControllerStateChangeEvent; + +/** + * The messenger for the SwapsController. + */ +export type SwapsControllerMessenger = RestrictedControllerMessenger< + typeof controllerName, + SwapsControllerActions | AllowedActions, + SwapsControllerEvents, + AllowedActions['type'], + never +>; + +export type SwapsControllerOptions = { + clientId?: string; + pollCountLimit?: number; + fetchAggregatorMetadataThreshold?: number; + fetchTokensThreshold?: number; + fetchTopAssetsThreshold?: number; + chainId?: Hex; + supportedChainIds?: Hex[]; + // TODO: Remove once GasFeeController exports this action type + fetchGasFeeEstimates?: () => Promise; + fetchEstimatedMultiLayerL1Fee?: ( + eth: EthQuery, + options: { + txParams: TxParams; + chainId: Hex; + }, + ) => Promise; + messenger: SwapsControllerMessenger; +}; + +/** + * The action that updates quotes with gas price {@link SwapsController}. + */ +export type SwapsControllerUpdateQuotesWithGasPrice = { + type: `SwapsController:updateQuotesWithGasPrice`; + handler: SwapsController['updateQuotesWithGasPrice']; +}; + +/** + * The action that updates the selected quote with gas limit {@link SwapsController}. + */ +export type SwapsControllerUpdateSelectedQuoteWithGasLimit = { + type: `SwapsController:updateSelectedQuoteWithGasLimit`; + handler: SwapsController['updateSelectedQuoteWithGasLimit']; +}; + +/** + * The action that starts fetching and setting quotes {@link SwapsController}. + */ +export type SwapsControllerStartFetchAndSetQuotes = { + type: `SwapsController:startFetchAndSetQuotes`; + handler: SwapsController['startFetchAndSetQuotes']; +}; + +/** + * The action that fetches a token with cache {@link SwapsController}. + */ +export type SwapsControllerFetchTokenWithCache = { + type: `SwapsController:fetchTokenWithCache`; + handler: SwapsController['fetchTokenWithCache']; +}; + +/** + * The action that fetches top assets with cache {@link SwapsController}. + */ +export type SwapsControllerFetchTopAssetsWithCache = { + type: `SwapsController:fetchTopAssetsWithCache`; + handler: SwapsController['fetchTopAssetsWithCache']; +}; + +/** + * The action that fetches aggregator metadata with cache {@link SwapsController}. + */ +export type SwapsControllerFetchAggregatorMetadataWithCache = { + type: `SwapsController:fetchAggregatorMetadataWithCache`; + handler: SwapsController['fetchAggregatorMetadataWithCache']; +}; + +/** + * The action that stops polling and resets state {@link SwapsController}. + */ +export type SwapsControllerStopPollingAndResetState = { + type: `SwapsController:stopPollingAndResetState`; + handler: SwapsController['stopPollingAndResetState']; +};