diff --git a/app/core/gasPolling.test.ts b/app/core/gasPolling.test.ts new file mode 100644 index 00000000000..070b8abd711 --- /dev/null +++ b/app/core/gasPolling.test.ts @@ -0,0 +1,222 @@ +jest.useFakeTimers(); + +import Engine from './Engine'; +import { + startGasPolling, + getEIP1559TransactionData, + stopGasPolling, + useDataStore, +} from './gasPolling'; +import { parseTransactionEIP1559 } from '../util/transactions'; +jest.mock('../util/transactions'); +const mockedParseTransactionEIP1559 = + parseTransactionEIP1559 as jest.MockedFunction< + typeof parseTransactionEIP1559 + >; + +const tokenValue = 'fba4a030-e1f5-11ec-a660-87ece4ac6cf7'; + +jest.mock('./Engine', () => ({ + context: { + GasFeeController: { + gasFeeEstimates: {}, + gasEstimateType: '', + getGasFeeEstimatesAndStartPolling: jest.fn(() => tokenValue), + stopPolling: jest.fn(), + }, + }, +})); + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(() => ({ + engine: { + backgroundState: { + GasFeeController: { + gasEstimateType: '', + gasFeeEstimates: [], + }, + TokenRatesController: { + constractExchangeRates: [], + }, + CurrencyRateController: { + conversionRate: 1, + currentCurrency: 'ETH', + nativeCurrency: 'WEI', + }, + AccountTrackerController: { + accounts: [], + }, + TokenBalancesController: { + contractBalances: [], + }, + }, + }, + transaction: { + selectedAsset: '', + }, + })), +})); + +const suggestedGasLimit = '0x123'; +const gas = { + maxWaitTimeEstimate: 45000, + minWaitTimeEstimate: 15000, + suggestedMaxFeePerGas: '1.500000018', + suggestedMaxPriorityFeePerGas: '1.5', +}; +const selectedOption = 'medium'; +const gasFeeEstimates = { + baseFeeTrend: 'down', + estimatedBaseFee: '0.000000013', + high: { + maxWaitTimeEstimate: 60000, + minWaitTimeEstimate: 15000, + suggestedMaxFeePerGas: '2.450000023', + suggestedMaxPriorityFeePerGas: '2.45', + }, + historicalBaseFeeRange: ['0.000000009', '0.000000014'], + historicalPriorityFeeRange: ['1', '96'], + latestPriorityFeeRange: ['1.5', '2.999999783'], + low: { + maxWaitTimeEstimate: 30000, + minWaitTimeEstimate: 15000, + suggestedMaxFeePerGas: '1.410000013', + suggestedMaxPriorityFeePerGas: '1.41', + }, + medium: { + maxWaitTimeEstimate: 45000, + minWaitTimeEstimate: 15000, + suggestedMaxFeePerGas: '1.500000018', + suggestedMaxPriorityFeePerGas: '1.5', + }, + networkCongestion: 0.4713, + priorityFeeTrend: 'level', +}; +const contractExchangeRates = {}; +const conversionRate = 1844.31; +const currentCurrency = 'USD'; +const nativeCurrency = 'ETH'; +const transactionState = { + selectedAsset: { + address: '', + isETH: true, + logo: '../images/eth-logo.png', + name: 'Ether', + symbol: 'ETH', + }, + transaction: { + value: '0xde0b6b3a7640000', + data: undefined, + }, +}; + +describe('GasPolling', () => { + const token = undefined; + const { GasFeeController }: any = Engine.context; + it('should call the start gas polling controller', async () => { + await startGasPolling(token); + expect( + GasFeeController.getGasFeeEstimatesAndStartPolling, + ).toHaveBeenCalled(); + }); + + it('should return a token value when called', async () => { + const pollToken = await startGasPolling(token); + expect(pollToken).toEqual(tokenValue); + }); + + it('should stop polling when stopGasPolling is called', async () => { + await stopGasPolling(); + expect(GasFeeController.stopPolling).toHaveBeenCalled(); + }); +}); + +describe('GetEIP1559TransactionData', () => { + const transactionData = { + suggestedGasLimit, + gas, + selectedOption, + gasFeeEstimates, + transactionState, + contractExchangeRates, + conversionRate, + currentCurrency, + nativeCurrency, + }; + + it('should fail when incomplete props is passed for ', async () => { + const incompleteTransactionData = { + suggestedGasLimit, + gas, + selectedOption, + gasFeeEstimates, + transactionState, + contractExchangeRates, + conversionRate, + currentCurrency, + }; + + try { + const result = getEIP1559TransactionData( + incompleteTransactionData as any, + ); + expect(result).toEqual('Incomplete data for EIP1559 transaction'); + expect(mockedParseTransactionEIP1559).not.toHaveBeenCalled(); + } catch (error) { + return expect(error).toBeTruthy(); + } + }); + + it('should get the transaction data for EIP1559', () => { + const expected = { + estimatedBaseFee: '0.000000013', + estimatedBaseFeeHex: 'd', + gasFeeMaxConversion: '0.09', + gasFeeMaxNative: '0.00005', + gasFeeMinConversion: '0.09', + gasFeeMinNative: '0.00005', + gasLimitHex: '0x8163', + maxPriorityFeeConversion: '0.09', + maxPriorityFeeNative: '0.00005', + renderableGasFeeMaxConversion: '$0.09', + renderableGasFeeMaxNative: '0.00005 ETH', + renderableGasFeeMinConversion: '$0.09', + renderableGasFeeMinNative: '0.00005 ETH', + renderableMaxFeePerGasConversion: '$0.09', + renderableMaxFeePerGasNative: '0.00005 ETH', + renderableMaxPriorityFeeConversion: '$0.09', + renderableMaxPriorityFeeNative: '0.00005 ETH', + renderableTotalMaxConversion: '$1,844.40', + renderableTotalMaxNative: '1.00005 ETH', + renderableTotalMinConversion: '$1,844.40', + renderableTotalMinNative: '1.00005 ETH', + suggestedGasLimit: '0x123', + suggestedMaxFeePerGas: '1.500000018', + suggestedMaxFeePerGasHex: '59682f12', + suggestedMaxPriorityFeePerGas: '1.5', + suggestedMaxPriorityFeePerGasHex: '59682f00', + timeEstimate: 'Likely in < 30 seconds', + timeEstimateColor: 'green', + timeEstimateId: 'likely', + totalMaxConversion: '1844.4', + totalMaxHex: 'de0e3e3ba6645f6', + totalMaxNative: '1.00005', + totalMinConversion: '1844.4', + totalMinHex: 'de0e3e3ba63bf07', + totalMinNative: '1.00006', + }; + + mockedParseTransactionEIP1559.mockReturnValue(expected); + + const result = getEIP1559TransactionData(transactionData); + expect(mockedParseTransactionEIP1559).toHaveBeenCalled(); + expect(result).toEqual(expected); + }); +}); + +describe('useDataStore', () => { + it('should return the data store', () => { + const result = useDataStore(); + expect(result.conversionRate).toEqual(1); + }); +}); diff --git a/app/core/gasPolling.ts b/app/core/gasPolling.ts new file mode 100644 index 00000000000..577f56279fe --- /dev/null +++ b/app/core/gasPolling.ts @@ -0,0 +1,277 @@ +import { useEffect, useState } from 'react'; +import { useSelector } from 'react-redux'; +import Engine from './Engine'; +import AppConstants from './AppConstants'; +import { GAS_ESTIMATE_TYPES } from '@metamask/controllers'; + +import { fromWei } from '../util/number'; +import { + parseTransactionEIP1559, + parseTransactionLegacy, +} from '../util/transactions'; + +/** + * + * @param {string} token Expects a token and when it is not provided, a random token is generated. + * @returns the token that is used to identify the gas polling. + */ +export const startGasPolling = async (token?: string) => { + const { GasFeeController }: any = Engine.context; + const pollToken = await GasFeeController.getGasFeeEstimatesAndStartPolling( + token, + ); + return pollToken; +}; + +/** + * + * @returns clears the token array state in the GasFeeController. + */ +export const stopGasPolling = () => { + const { GasFeeController }: any = Engine.context; + return GasFeeController.stopPolling(); +}; + +export const useDataStore = () => { + const { + engine: { + backgroundState: { + GasFeeController: { gasEstimateType, gasFeeEstimates }, + TokenRatesController: { contractExchangeRates }, + CurrencyRateController: { + conversionRate, + currentCurrency, + nativeCurrency, + }, + AccountTrackerController: { accounts }, + TokenBalancesController: { contractBalances }, + }, + }, + transaction, + } = useSelector( + (state: any) => + state.engine.backgroundState.GasFeeController.gasFeeEstimates, + ); + const selectedAsset = transaction.selectedAsset; + + return { + gasFeeEstimates, + transactionState: transaction, + gasEstimateType, + contractExchangeRates, + conversionRate, + currentCurrency, + nativeCurrency, + accounts, + contractBalances, + selectedAsset, + }; +}; + +interface GetEIP1559TransactionDataProps { + gas: { + maxWaitTimeEstimate: number; + minWaitTimeEstimate: number; + suggestedMaxFeePerGas: string; + suggestedMaxPriorityFeePerGas: string; + }; + selectedOption: string; + gasFeeEstimates: { + baseFeeTrend: string; + estimatedBaseFee: string; + high: { + maxWaitTimeEstimate: number; + minWaitTimeEstimate: number; + suggestedMaxFeePerGas: string; + suggestedMaxPriorityFeePerGas: string; + }; + historicalBaseFeeRange: string[]; + historicalPriorityFeeRange: string[]; + latestPriorityFeeRange: string[]; + low: { + maxWaitTimeEstimate: number; + minWaitTimeEstimate: number; + suggestedMaxFeePerGas: string; + suggestedMaxPriorityFeePerGas: string; + }; + medium: { + maxWaitTimeEstimate: number; + minWaitTimeEstimate: number; + suggestedMaxFeePerGas: string; + suggestedMaxPriorityFeePerGas: string; + }; + networkCongestion: number; + priorityFeeTrend: string; + }; + + transactionState: any; + contractExchangeRates: any; + conversionRate: number; + currentCurrency: string; + nativeCurrency: string; + suggestedGasLimit: string; + onlyGas?: boolean; +} + +interface LegacyProps { + contractExchangeRates: any; + conversionRate: number; + currentCurrency: string; + transactionState: any; + ticker: string; + suggestedGasPrice: any; + suggestedGasLimit: string; +} + +/** + * + * @param {GetEIP1559TransactionDataProps} props + * @returns parsed transaction data for EIP1559 transactions. + */ +export const getEIP1559TransactionData = ({ + gas, + selectedOption, + gasFeeEstimates, + transactionState, + contractExchangeRates, + conversionRate, + currentCurrency, + nativeCurrency, + suggestedGasLimit, + onlyGas, +}: GetEIP1559TransactionDataProps) => { + try { + if ( + !gas || + !selectedOption || + !gasFeeEstimates || + !transactionState || + !contractExchangeRates || + !conversionRate || + !currentCurrency || + !nativeCurrency || + !suggestedGasLimit + ) { + return 'Incomplete data for EIP1559 transaction'; + } + + const parsedTransactionEIP1559 = parseTransactionEIP1559( + { + contractExchangeRates, + conversionRate, + currentCurrency, + nativeCurrency, + transactionState, + gasFeeEstimates, + swapsParams: undefined, + selectedGasFee: { + ...gas, + suggestedGasLimit, + selectedOption, + estimatedBaseFee: gasFeeEstimates.estimatedBaseFee, + }, + }, + { onlyGas }, + ); + return parsedTransactionEIP1559; + } catch (error) { + return 'Error parsing transaction data'; + } +}; + +/** + * + * @param {LegacyProps} props + * @returns parsed transaction data for legacy transactions. + */ +export const getLegacyTransactionData = ({ + contractExchangeRates, + conversionRate, + currentCurrency, + transactionState, + ticker, + suggestedGasPrice, + suggestedGasLimit, +}: LegacyProps) => { + const parsedTransationData = parseTransactionLegacy({ + contractExchangeRates, + conversionRate, + currentCurrency, + transactionState, + ticker, + selectedGasFee: { suggestedGasLimit, suggestedGasPrice }, + }); + return parsedTransationData; +}; + +/** + * + * @returns {Object} the transaction data for the current transaction. + */ +export const useGasFeeEstimates = () => { + const [gasEstimateTypeChange, updateGasEstimateTypeChange] = + useState(''); + + const { + gasFeeEstimates, + transactionState, + gasEstimateType, + contractExchangeRates, + conversionRate, + currentCurrency, + nativeCurrency, + } = useDataStore(); + + useEffect(() => { + if (gasEstimateType !== gasEstimateTypeChange) { + updateGasEstimateTypeChange(gasEstimateType); + } + }, [gasEstimateType, gasEstimateTypeChange]); + + const gasSelected = gasEstimateTypeChange + ? AppConstants.GAS_OPTIONS.MEDIUM + : AppConstants.GAS_OPTIONS.MEDIUM; + + const { + transaction: { gas: transactionGas }, + } = transactionState; + + let transactionData; + + if (gasEstimateTypeChange) { + if (gasEstimateType === GAS_ESTIMATE_TYPES.FEE_MARKET) { + const suggestedGasLimit = fromWei(transactionGas, 'wei'); + const EIP1559TransactionData = getEIP1559TransactionData({ + gas: gasFeeEstimates[gasSelected], + selectedOption: gasSelected, + gasFeeEstimates, + transactionState, + contractExchangeRates, + conversionRate, + currentCurrency, + nativeCurrency, + suggestedGasLimit, + }); + transactionData = EIP1559TransactionData; + } else if (gasEstimateType !== GAS_ESTIMATE_TYPES.NONE) { + const suggestedGasLimit = fromWei(transactionGas, 'wei'); + const LegacyTransactionData = getLegacyTransactionData({ + contractExchangeRates, + conversionRate, + currentCurrency, + transactionState, + ticker: 'ETH', + suggestedGasPrice: + gasEstimateType === GAS_ESTIMATE_TYPES.LEGACY + ? gasFeeEstimates[gasSelected] + : gasFeeEstimates.gasPrice, + suggestedGasLimit, + }); + transactionData = LegacyTransactionData; + } + } else { + return null; + } + + return transactionData; +};