diff --git a/ui/hooks/useGasFeeEstimates.js b/ui/hooks/useGasFeeEstimates.js new file mode 100644 index 000000000..82ae44f7b --- /dev/null +++ b/ui/hooks/useGasFeeEstimates.js @@ -0,0 +1,73 @@ +import { useEffect } from 'react'; +import { useSelector } from 'react-redux'; +import { GAS_ESTIMATE_TYPES } from '../../shared/constants/gas'; +import { + getEstimatedGasFeeTimeBounds, + getGasEstimateType, + getGasFeeEstimates, + isEIP1559Network, +} from '../ducks/metamask/metamask'; +import { + disconnectGasFeeEstimatePoller, + getGasFeeEstimatesAndStartPolling, +} from '../store/actions'; + +/** + * @typedef {keyof typeof GAS_ESTIMATE_TYPES} GasEstimateTypes + */ + +/** + * @typedef {object} GasEstimates + * @property {GasEstimateTypes} gasEstimateType - The type of estimate provided + * @property {import( + * '@metamask/controllers' + * ).GasFeeState['gasFeeEstimates']} gasFeeEstimates - The estimate object + * @property {import( + * '@metamask/controllers' + * ).GasFeeState['estimatedGasFeeTimeBounds']} [estimatedGasFeeTimeBounds] - + * estimated time boundaries for fee-market type estimates + * @property {boolean} isGasEstimateLoading - indicates whether the gas + * estimates are currently loading. + */ + +/** + * Gets the current gasFeeEstimates from state and begins polling for new + * estimates. When this hook is removed from the tree it will signal to the + * GasFeeController that it is done requiring new gas estimates. Also checks + * the returned gas estimate for validity on the current network. + * + * @returns {GasFeeEstimates} - GasFeeEstimates object + */ +export function useGasFeeEstimates() { + const supportsEIP1559 = useSelector(isEIP1559Network); + const gasEstimateType = useSelector(getGasEstimateType); + const gasFeeEstimates = useSelector(getGasFeeEstimates); + const estimatedGasFeeTimeBounds = useSelector(getEstimatedGasFeeTimeBounds); + useEffect(() => { + let pollToken; + getGasFeeEstimatesAndStartPolling().then((newPollToken) => { + pollToken = newPollToken; + }); + return () => { + if (pollToken) { + disconnectGasFeeEstimatePoller(pollToken); + } + }; + }, []); + + // We consider the gas estimate to be loading if the gasEstimateType is + // 'NONE' or if the current gasEstimateType does not match the type we expect + // for the current network. e.g, a ETH_GASPRICE estimate when on a network + // supporting EIP-1559. + const isGasEstimatesLoading = + gasEstimateType === GAS_ESTIMATE_TYPES.NONE || + (supportsEIP1559 && gasEstimateType !== GAS_ESTIMATE_TYPES.FEE_MARKET) || + (!supportsEIP1559 && gasEstimateType === GAS_ESTIMATE_TYPES.FEE_MARKET); + + return { + gasFeeEstimates, + gasEstimateType, + estimatedGasFeeTimeBounds, + isGasEstimatesLoading, + }; +} diff --git a/ui/hooks/useGasFeeEstimates.test.js b/ui/hooks/useGasFeeEstimates.test.js new file mode 100644 index 000000000..d30127759 --- /dev/null +++ b/ui/hooks/useGasFeeEstimates.test.js @@ -0,0 +1,238 @@ +import { cleanup, renderHook } from '@testing-library/react-hooks'; +import { useSelector } from 'react-redux'; +import { GAS_ESTIMATE_TYPES } from '../../shared/constants/gas'; +import createRandomId from '../../shared/modules/random-id'; +import { + getGasEstimateType, + getGasFeeEstimates, + isEIP1559Network, +} from '../ducks/metamask/metamask'; +import { + disconnectGasFeeEstimatePoller, + getGasFeeEstimatesAndStartPolling, +} from '../store/actions'; +import { useGasFeeEstimates } from './useGasFeeEstimates'; + +jest.mock('../store/actions', () => ({ + disconnectGasFeeEstimatePoller: jest.fn(), + getGasFeeEstimatesAndStartPolling: jest.fn(), +})); + +jest.mock('react-redux', () => { + const actual = jest.requireActual('react-redux'); + + return { + ...actual, + useSelector: jest.fn(), + }; +}); + +const DEFAULT_OPTS = { + isEIP1559Network: false, + gasEstimateType: GAS_ESTIMATE_TYPES.LEGACY, + gasFeeEstimates: { + low: '10', + medium: '20', + high: '30', + }, +}; + +const generateUseSelectorRouter = (opts = DEFAULT_OPTS) => (selector) => { + if (selector === isEIP1559Network) { + return opts.isEIP1559Network ?? DEFAULT_OPTS.isEIP1559Network; + } + if (selector === getGasEstimateType) { + return opts.gasEstimateType ?? DEFAULT_OPTS.gasEstimateType; + } + if (selector === getGasFeeEstimates) { + return opts.gasFeeEstimates ?? DEFAULT_OPTS.gasFeeEstimates; + } + return undefined; +}; + +describe('useGasFeeEstimates', () => { + let tokens = []; + beforeEach(() => { + jest.clearAllMocks(); + tokens = []; + getGasFeeEstimatesAndStartPolling.mockImplementation(() => { + const token = createRandomId(); + tokens.push(token); + return Promise.resolve(token); + }); + disconnectGasFeeEstimatePoller.mockImplementation((token) => { + tokens = tokens.filter((tkn) => tkn !== token); + }); + useSelector.mockImplementation(generateUseSelectorRouter()); + }); + + it('registers with the controller', () => { + renderHook(() => useGasFeeEstimates()); + expect(tokens).toHaveLength(1); + }); + + it('clears token with the controller on unmount', async () => { + renderHook(() => useGasFeeEstimates()); + expect(tokens).toHaveLength(1); + const expectedToken = tokens[0]; + await cleanup(); + expect(getGasFeeEstimatesAndStartPolling).toHaveBeenCalledTimes(1); + expect(disconnectGasFeeEstimatePoller).toHaveBeenCalledWith(expectedToken); + expect(tokens).toHaveLength(0); + }); + + it('works with LEGACY gas prices', () => { + const { + result: { current }, + } = renderHook(() => useGasFeeEstimates()); + expect(current).toMatchObject({ + gasFeeEstimates: DEFAULT_OPTS.gasFeeEstimates, + gasEstimateType: GAS_ESTIMATE_TYPES.LEGACY, + estimatedGasFeeTimeBounds: undefined, + isGasEstimatesLoading: false, + }); + }); + + it('works with ETH_GASPRICE gas prices', () => { + const gasFeeEstimates = { gasPrice: '10' }; + useSelector.mockImplementation( + generateUseSelectorRouter({ + gasEstimateType: GAS_ESTIMATE_TYPES.ETH_GASPRICE, + gasFeeEstimates, + }), + ); + + const { + result: { current }, + } = renderHook(() => useGasFeeEstimates()); + expect(current).toMatchObject({ + gasFeeEstimates, + gasEstimateType: GAS_ESTIMATE_TYPES.ETH_GASPRICE, + estimatedGasFeeTimeBounds: undefined, + isGasEstimatesLoading: false, + }); + }); + + it('works with FEE_MARKET gas prices', () => { + const gasFeeEstimates = { + low: { + minWaitTimeEstimate: 180000, + maxWaitTimeEstimate: 300000, + suggestedMaxPriorityFeePerGas: '3', + suggestedMaxFeePerGas: '53', + }, + medium: { + minWaitTimeEstimate: 15000, + maxWaitTimeEstimate: 60000, + suggestedMaxPriorityFeePerGas: '7', + suggestedMaxFeePerGas: '70', + }, + high: { + minWaitTimeEstimate: 0, + maxWaitTimeEstimate: 15000, + suggestedMaxPriorityFeePerGas: '10', + suggestedMaxFeePerGas: '100', + }, + estimatedBaseFee: '50', + }; + useSelector.mockImplementation( + generateUseSelectorRouter({ + isEIP1559Network: true, + gasEstimateType: GAS_ESTIMATE_TYPES.FEE_MARKET, + gasFeeEstimates, + }), + ); + + const { + result: { current }, + } = renderHook(() => useGasFeeEstimates()); + expect(current).toMatchObject({ + gasFeeEstimates, + gasEstimateType: GAS_ESTIMATE_TYPES.FEE_MARKET, + estimatedGasFeeTimeBounds: undefined, + isGasEstimatesLoading: false, + }); + }); + + it('indicates that gas estimates are loading when gasEstimateType is NONE', () => { + useSelector.mockImplementation( + generateUseSelectorRouter({ + gasEstimateType: GAS_ESTIMATE_TYPES.NONE, + gasFeeEstimates: {}, + }), + ); + + const { + result: { current }, + } = renderHook(() => useGasFeeEstimates()); + expect(current).toMatchObject({ + gasFeeEstimates: {}, + gasEstimateType: GAS_ESTIMATE_TYPES.NONE, + estimatedGasFeeTimeBounds: undefined, + isGasEstimatesLoading: true, + }); + }); + + it('indicates that gas estimates are loading when gasEstimateType is not FEE_MARKET but network supports EIP-1559', () => { + useSelector.mockImplementation( + generateUseSelectorRouter({ + isEIP1559Network: true, + gasEstimateType: GAS_ESTIMATE_TYPES.ETH_GASPRICE, + gasFeeEstimates: { + gasPrice: '10', + }, + }), + ); + + const { + result: { current }, + } = renderHook(() => useGasFeeEstimates()); + expect(current).toMatchObject({ + gasFeeEstimates: { gasPrice: '10' }, + gasEstimateType: GAS_ESTIMATE_TYPES.ETH_GASPRICE, + estimatedGasFeeTimeBounds: undefined, + isGasEstimatesLoading: true, + }); + }); + + it('indicates that gas estimates are loading when gasEstimateType is FEE_MARKET but network does not support EIP-1559', () => { + const gasFeeEstimates = { + low: { + minWaitTimeEstimate: 180000, + maxWaitTimeEstimate: 300000, + suggestedMaxPriorityFeePerGas: '3', + suggestedMaxFeePerGas: '53', + }, + medium: { + minWaitTimeEstimate: 15000, + maxWaitTimeEstimate: 60000, + suggestedMaxPriorityFeePerGas: '7', + suggestedMaxFeePerGas: '70', + }, + high: { + minWaitTimeEstimate: 0, + maxWaitTimeEstimate: 15000, + suggestedMaxPriorityFeePerGas: '10', + suggestedMaxFeePerGas: '100', + }, + estimatedBaseFee: '50', + }; + useSelector.mockImplementation( + generateUseSelectorRouter({ + isEIP1559Network: false, + gasEstimateType: GAS_ESTIMATE_TYPES.FEE_MARKET, + gasFeeEstimates, + }), + ); + + const { + result: { current }, + } = renderHook(() => useGasFeeEstimates()); + expect(current).toMatchObject({ + gasFeeEstimates, + gasEstimateType: GAS_ESTIMATE_TYPES.FEE_MARKET, + estimatedGasFeeTimeBounds: undefined, + isGasEstimatesLoading: true, + }); + }); +});