import { addHexPrefix } from 'ethereumjs-util'; import { useCallback, useState } from 'react'; import { useSelector } from 'react-redux'; import { findKey, isEqual } from 'lodash'; import { GAS_ESTIMATE_TYPES, EDIT_GAS_MODES, GAS_LIMITS, } from '../../shared/constants/gas'; import { multiplyCurrencies, conversionLessThan, conversionGreaterThan, } from '../../shared/modules/conversion.utils'; import { getMaximumGasTotalInHexWei, getMinimumGasTotalInHexWei, } from '../../shared/modules/gas.utils'; import { PRIMARY, SECONDARY } from '../helpers/constants/common'; import { checkNetworkAndAccountSupports1559, getShouldShowFiat, getSelectedAccount, getAdvancedInlineGasShown, } from '../selectors'; import { hexWEIToDecGWEI, decGWEIToHexWEI, decimalToHex, hexToDecimal, addHexes, } from '../helpers/utils/conversions.util'; import { bnGreaterThan, bnLessThan, bnGreaterThanEqualTo, bnLessThanEqualTo, } from '../helpers/utils/util'; import { GAS_FORM_ERRORS } from '../helpers/constants/gas'; import { useCurrencyDisplay } from './useCurrencyDisplay'; import { useGasFeeEstimates } from './useGasFeeEstimates'; import { useUserPreferencedCurrency } from './useUserPreferencedCurrency'; const HIGH_FEE_WARNING_MULTIPLIER = 1.5; /** * Opaque string type representing a decimal (base 10) number in GWEI * @typedef {`${number}`} DecGweiString */ /** * String value representing the active estimate level to use * @typedef {'low' | 'medium' | 'high'} EstimateLevel */ /** * Pulls out gasPrice estimate from either of the two gasPrice estimation * sources, based on the gasEstimateType and current estimateToUse. * @param {{import( * '@metamask/controllers' * ).GasFeeState['gasFeeEstimates']}} gasFeeEstimates - estimates returned from * the controller * @param {import( * './useGasFeeEstimates' * ).GasEstimates} gasEstimateType - type of estimate returned from controller * @param {EstimateLevel} estimateToUse - current estimate level to use * @returns {[DecGweiString]} - gasPrice estimate to use or null */ function getGasPriceEstimate(gasFeeEstimates, gasEstimateType, estimateToUse) { if (gasEstimateType === GAS_ESTIMATE_TYPES.LEGACY) { return gasFeeEstimates?.[estimateToUse] ?? '0'; } else if (gasEstimateType === GAS_ESTIMATE_TYPES.ETH_GASPRICE) { return gasFeeEstimates?.gasPrice ?? '0'; } return '0'; } /** * Pulls out gas fee estimate from the estimates returned from controller, * based on the gasEstimateType and current estimateToUse. * @param {'maxFeePerGas' | 'maxPriorityFeePerGas'} field - field to select * @param {{import( * '@metamask/controllers' * ).GasFeeState['gasFeeEstimates']}} gasFeeEstimates - estimates returned from * the controller * @param {import( * './useGasFeeEstimates' * ).GasEstimates} gasEstimateType - type of estimate returned from controller * @param {EstimateLevel} estimateToUse - current estimate level to use * @returns {[DecGweiString]} - gas fee estimate to use or null */ function getGasFeeEstimate( field, gasFeeEstimates, gasEstimateType, estimateToUse, fallback = '0', ) { if (gasEstimateType === GAS_ESTIMATE_TYPES.FEE_MARKET) { return gasFeeEstimates?.[estimateToUse]?.[field] ?? String(fallback); } return String(fallback); } /** * This method tries to determine if any estimate level matches the * current maxFeePerGas and maxPriorityFeePerGas values. If we find * a match, we can pre-select a radio button in the RadioGroup */ function getMatchingEstimateFromGasFees( gasFeeEstimates, maxFeePerGas, maxPriorityFeePerGas, gasPrice, networkAndAccountSupports1559, ) { return ( findKey(gasFeeEstimates, (estimate) => { if (networkAndAccountSupports1559) { return ( Number(estimate?.suggestedMaxPriorityFeePerGas) === Number(maxPriorityFeePerGas) && Number(estimate?.suggestedMaxFeePerGas) === Number(maxFeePerGas) ); } return estimate?.gasPrice === gasPrice; }) || null ); } /** * @typedef {Object} GasFeeInputReturnType * @property {DecGweiString} [maxFeePerGas] - the maxFeePerGas input value. * @property {string} [maxFeePerGasFiat] - the maxFeePerGas converted to the * user's preferred currency. * @property {(DecGweiString) => void} setMaxFeePerGas - state setter method to * update the maxFeePerGas. * @property {DecGweiString} [maxPriorityFeePerGas] - the maxPriorityFeePerGas * input value. * @property {string} [maxPriorityFeePerGasFiat] - the maxPriorityFeePerGas * converted to the user's preferred currency. * @property {(DecGweiString) => void} setMaxPriorityFeePerGas - state setter * method to update the maxPriorityFeePerGas. * @property {DecGweiString} [gasPrice] - the gasPrice input value. * @property {(DecGweiString) => void} setGasPrice - state setter method to * update the gasPrice. * @property {DecGweiString} gasLimit - the gasLimit input value. * @property {(DecGweiString) => void} setGasLimit - state setter method to * update the gasLimit. * @property {EstimateLevel} [estimateToUse] - the estimate level currently * selected. This will be null if the user has ejected from using the * estimates. * @property {([EstimateLevel]) => void} setEstimateToUse - Setter method for * choosing which EstimateLevel to use. * @property {string} [estimatedMinimumFiat] - The amount estimated to be paid * based on current network conditions. Expressed in user's preferred * currency. * @property {string} [estimatedMaximumFiat] - the maximum amount estimated to be * paid if current network transaction volume increases. Expressed in user's * preferred currency. * @property {string} [estimatedMaximumNative] - the maximum amount estimated to * be paid if the current network transaction volume increases. Expressed in * the network's native currency. */ /** * Uses gasFeeEstimates and state to keep track of user gas fee inputs. * Will update the gas fee state when estimates update if the user has not yet * modified the fields. * @param {EstimateLevel} defaultEstimateToUse - which estimate * level to default the 'estimateToUse' state variable to. * @returns {GasFeeInputReturnType & import( * './useGasFeeEstimates' * ).GasEstimates} - gas fee input state and the GasFeeEstimates object */ export function useGasFeeInputs( defaultEstimateToUse = 'medium', transaction, minimumGasLimit = '0x5208', editGasMode, ) { const { balance: ethBalance } = useSelector(getSelectedAccount); const networkAndAccountSupports1559 = useSelector( checkNetworkAndAccountSupports1559, ); // We need to know whether to show fiat conversions or not, so that we can // default our fiat values to empty strings if showing fiat is not wanted or // possible. const showFiat = useSelector(getShouldShowFiat); // We need to know the current network's currency and its decimal precision // to calculate the amount to display to the user. const { currency: primaryCurrency, numberOfDecimals: primaryNumberOfDecimals, } = useUserPreferencedCurrency(PRIMARY); // For calculating the value of gas fees in the user's preferred currency we // first have to know what that currency is and its decimal precision const { currency: fiatCurrency, numberOfDecimals: fiatNumberOfDecimals, } = useUserPreferencedCurrency(SECONDARY); // We need the gas estimates from the GasFeeController in the background. // Calling this hooks initiates polling for new gas estimates and returns the // current estimate. const { gasEstimateType, gasFeeEstimates, isGasEstimatesLoading, estimatedGasFeeTimeBounds, } = useGasFeeEstimates(); const [initialMaxFeePerGas] = useState( networkAndAccountSupports1559 && !transaction?.txParams?.maxFeePerGas ? Number(hexWEIToDecGWEI(transaction?.txParams?.gasPrice)) : Number(hexWEIToDecGWEI(transaction?.txParams?.maxFeePerGas)), ); const [initialMaxPriorityFeePerGas] = useState( networkAndAccountSupports1559 && !transaction?.txParams?.maxPriorityFeePerGas ? initialMaxFeePerGas : Number(hexWEIToDecGWEI(transaction?.txParams?.maxPriorityFeePerGas)), ); const [initialGasPrice] = useState( Number(hexWEIToDecGWEI(transaction?.txParams?.gasPrice)), ); const [initialMatchingEstimateLevel] = useState( getMatchingEstimateFromGasFees( gasFeeEstimates, initialMaxFeePerGas, initialMaxPriorityFeePerGas, initialGasPrice, networkAndAccountSupports1559, ), ); // This hook keeps track of a few pieces of transitional state. It is // transitional because it is only used to modify a transaction in the // metamask (background) state tree. const [maxFeePerGas, setMaxFeePerGas] = useState( initialMaxFeePerGas && !initialMatchingEstimateLevel ? initialMaxFeePerGas : null, ); const [maxPriorityFeePerGas, setMaxPriorityFeePerGas] = useState( initialMaxPriorityFeePerGas && !initialMatchingEstimateLevel ? initialMaxPriorityFeePerGas : null, ); const [gasPriceHasBeenManuallySet, setGasPriceHasBeenManuallySet] = useState( false, ); const [gasPrice, setGasPrice] = useState( initialGasPrice && !initialMatchingEstimateLevel ? initialGasPrice : null, ); const [gasLimit, setGasLimit] = useState( Number(hexToDecimal(transaction?.txParams?.gas ?? minimumGasLimit)), ); const userPrefersAdvancedGas = useSelector(getAdvancedInlineGasShown); const dontDefaultToAnEstimateLevel = userPrefersAdvancedGas && transaction?.txParams?.maxPriorityFeePerGas && transaction?.txParams?.gasPrice; const initialEstimateToUse = transaction ? initialMatchingEstimateLevel : defaultEstimateToUse; const [estimateToUse, setInternalEstimateToUse] = useState( dontDefaultToAnEstimateLevel ? null : initialEstimateToUse, ); // We specify whether to use the estimate value by checking if the state // value has been set. The state value is only set by user input and is wiped // when the user selects an estimate. Default here is '0' to avoid bignumber // errors in later calculations for nullish values. const maxFeePerGasToUse = maxFeePerGas ?? getGasFeeEstimate( 'suggestedMaxFeePerGas', gasFeeEstimates, gasEstimateType, estimateToUse, initialMaxFeePerGas, ); const maxPriorityFeePerGasToUse = maxPriorityFeePerGas ?? getGasFeeEstimate( 'suggestedMaxPriorityFeePerGas', gasFeeEstimates, gasEstimateType, estimateToUse, initialMaxPriorityFeePerGas, ); const [initialGasPriceEstimates] = useState(gasFeeEstimates); const gasPriceEstimatesHaveNotChanged = isEqual( initialGasPriceEstimates, gasFeeEstimates, ); const gasPriceToUse = gasPrice !== null && (gasPriceHasBeenManuallySet || gasPriceEstimatesHaveNotChanged) ? gasPrice : getGasPriceEstimate( gasFeeEstimates, gasEstimateType, estimateToUse || defaultEstimateToUse, ); // We have two helper methods that take an object that can have either // gasPrice OR the EIP-1559 fields on it, plus gasLimit. This object is // conditionally set to the appropriate fields to compute the minimum // and maximum cost of a transaction given the current estimates or selected // gas fees. const gasSettings = { gasLimit: decimalToHex(gasLimit), }; if (networkAndAccountSupports1559) { gasSettings.maxFeePerGas = maxFeePerGasToUse ? decGWEIToHexWEI(maxFeePerGasToUse) : decGWEIToHexWEI(gasPriceToUse || '0'); gasSettings.maxPriorityFeePerGas = maxPriorityFeePerGasToUse ? decGWEIToHexWEI(maxPriorityFeePerGasToUse) : gasSettings.maxFeePerGas; gasSettings.baseFeePerGas = decGWEIToHexWEI( gasFeeEstimates.estimatedBaseFee ?? '0', ); } else if (gasEstimateType === GAS_ESTIMATE_TYPES.NONE) { gasSettings.gasPrice = '0x0'; } else { gasSettings.gasPrice = decGWEIToHexWEI(gasPriceToUse); } // The maximum amount this transaction will cost const maximumCostInHexWei = getMaximumGasTotalInHexWei(gasSettings); // If in swaps, we want to calculate the minimum gas fee differently than the max const minGasSettings = {}; if (editGasMode === EDIT_GAS_MODES.SWAPS) { minGasSettings.gasLimit = decimalToHex(minimumGasLimit); } // The minimum amount this transaction will cost's const minimumCostInHexWei = getMinimumGasTotalInHexWei({ ...gasSettings, ...minGasSettings, }); // We need to display the estimated fiat currency impact of the // maxPriorityFeePerGas field to the user. This hook calculates that amount. const [, { value: maxPriorityFeePerGasFiat }] = useCurrencyDisplay( addHexPrefix( multiplyCurrencies(maxPriorityFeePerGasToUse, gasLimit, { toNumericBase: 'hex', fromDenomination: 'GWEI', toDenomination: 'WEI', multiplicandBase: 10, multiplierBase: 10, }), ), { numberOfDecimals: fiatNumberOfDecimals, currency: fiatCurrency, }, ); // We need to display thee estimated fiat currency impact of the maxFeePerGas // field to the user. This hook calculates that amount. This also works for // the gasPrice amount because in legacy transactions cost is always gasPrice // * gasLimit. const [, { value: maxFeePerGasFiat }] = useCurrencyDisplay( maximumCostInHexWei, { numberOfDecimals: fiatNumberOfDecimals, currency: fiatCurrency, }, ); // We need to display the total amount of native currency will be expended // given the selected gas fees. const [estimatedMaximumNative] = useCurrencyDisplay(maximumCostInHexWei, { numberOfDecimals: primaryNumberOfDecimals, currency: primaryCurrency, }); const [estimatedMinimumNative] = useCurrencyDisplay(minimumCostInHexWei, { numberOfDecimals: primaryNumberOfDecimals, currency: primaryCurrency, }); // We also need to display our closest estimate of the low end of estimation // in fiat. const [, { value: estimatedMinimumFiat }] = useCurrencyDisplay( minimumCostInHexWei, { numberOfDecimals: fiatNumberOfDecimals, currency: fiatCurrency, }, ); let estimatesUnavailableWarning = null; // Separating errors from warnings so we can know which value problems // are blocking or simply useful information for the users const gasErrors = {}; const gasWarnings = {}; const gasLimitTooLow = conversionLessThan( { value: gasLimit, fromNumericBase: 'dec' }, { value: minimumGasLimit || GAS_LIMITS.SIMPLE, fromNumericBase: 'hex' }, ); if (gasLimitTooLow) { gasErrors.gasLimit = GAS_FORM_ERRORS.GAS_LIMIT_OUT_OF_BOUNDS; } // This ensures these are applied when the api fails to return a fee market type // It is okay if these errors get overwritten below, as those overwrites can only // happen when the estimate api is live. if (networkAndAccountSupports1559) { if (bnLessThanEqualTo(maxPriorityFeePerGasToUse, 0)) { gasErrors.maxPriorityFee = GAS_FORM_ERRORS.MAX_PRIORITY_FEE_BELOW_MINIMUM; } else if ( bnGreaterThanEqualTo(maxPriorityFeePerGasToUse, maxFeePerGasToUse) ) { gasErrors.maxFee = GAS_FORM_ERRORS.MAX_FEE_IMBALANCE; } } switch (gasEstimateType) { case GAS_ESTIMATE_TYPES.FEE_MARKET: if (bnLessThanEqualTo(maxPriorityFeePerGasToUse, 0)) { gasErrors.maxPriorityFee = GAS_FORM_ERRORS.MAX_PRIORITY_FEE_BELOW_MINIMUM; } else if ( !isGasEstimatesLoading && bnLessThan( maxPriorityFeePerGasToUse, gasFeeEstimates?.low?.suggestedMaxPriorityFeePerGas, ) ) { gasWarnings.maxPriorityFee = GAS_FORM_ERRORS.MAX_PRIORITY_FEE_TOO_LOW; } else if ( bnGreaterThanEqualTo(maxPriorityFeePerGasToUse, maxFeePerGasToUse) ) { gasErrors.maxFee = GAS_FORM_ERRORS.MAX_FEE_IMBALANCE; } else if ( gasFeeEstimates?.high && bnGreaterThan( maxPriorityFeePerGasToUse, gasFeeEstimates.high.suggestedMaxPriorityFeePerGas * HIGH_FEE_WARNING_MULTIPLIER, ) ) { gasWarnings.maxPriorityFee = GAS_FORM_ERRORS.MAX_PRIORITY_FEE_HIGH_WARNING; } if ( !isGasEstimatesLoading && bnLessThan( maxFeePerGasToUse, gasFeeEstimates?.low?.suggestedMaxFeePerGas, ) ) { gasErrors.maxFee = GAS_FORM_ERRORS.MAX_FEE_TOO_LOW; } else if ( gasFeeEstimates?.high && bnGreaterThan( maxFeePerGasToUse, gasFeeEstimates.high.suggestedMaxFeePerGas * HIGH_FEE_WARNING_MULTIPLIER, ) ) { gasWarnings.maxFee = GAS_FORM_ERRORS.MAX_FEE_HIGH_WARNING; } break; case GAS_ESTIMATE_TYPES.LEGACY: case GAS_ESTIMATE_TYPES.ETH_GASPRICE: case GAS_ESTIMATE_TYPES.NONE: if (networkAndAccountSupports1559) { estimatesUnavailableWarning = true; } if (bnLessThanEqualTo(gasPriceToUse, 0)) { gasErrors.gasPrice = GAS_FORM_ERRORS.GAS_PRICE_TOO_LOW; } break; default: break; } // Determine if we have any errors which should block submission const hasBlockingGasErrors = Boolean(Object.keys(gasErrors).length); // Now that we've determined errors that block submission, we can pool the warnings // and errors into one object for easier use within the UI. This object should have // no effect on whether or not the user can submit the form const errorsAndWarnings = { ...gasWarnings, ...gasErrors, }; const minimumTxCostInHexWei = addHexes( minimumCostInHexWei, transaction?.txParams?.value || '0x0', ); const balanceError = conversionGreaterThan( { value: minimumTxCostInHexWei, fromNumericBase: 'hex' }, { value: ethBalance, fromNumericBase: 'hex' }, ); // When a user selects an estimate level, it will wipe out what they have // previously put in the inputs. This returns the inputs to the estimated // values at the level specified. const setEstimateToUse = useCallback( (estimateLevel) => { setInternalEstimateToUse(estimateLevel); if (gasErrors.gasLimit === GAS_FORM_ERRORS.GAS_LIMIT_OUT_OF_BOUNDS) { const transactionGasLimit = hexToDecimal(transaction?.txParams?.gas); const minimumGasLimitDec = hexToDecimal(minimumGasLimit); setGasLimit( transactionGasLimit > minimumGasLimitDec ? transactionGasLimit : minimumGasLimitDec, ); } setMaxFeePerGas(null); setMaxPriorityFeePerGas(null); setGasPrice(null); setGasPriceHasBeenManuallySet(false); }, [minimumGasLimit, gasErrors.gasLimit, transaction], ); return { maxFeePerGas: maxFeePerGasToUse, maxFeePerGasFiat: showFiat ? maxFeePerGasFiat : '', setMaxFeePerGas, maxPriorityFeePerGas: maxPriorityFeePerGasToUse, maxPriorityFeePerGasFiat: showFiat ? maxPriorityFeePerGasFiat : '', setMaxPriorityFeePerGas, gasPrice: gasPriceToUse, setGasPrice, gasLimit, setGasLimit, estimateToUse, setEstimateToUse, estimatedMinimumFiat: showFiat ? estimatedMinimumFiat : '', estimatedMaximumFiat: showFiat ? maxFeePerGasFiat : '', estimatedMaximumNative, estimatedMinimumNative, isGasEstimatesLoading, gasFeeEstimates, gasEstimateType, estimatedGasFeeTimeBounds, gasErrors: errorsAndWarnings, hasGasErrors: hasBlockingGasErrors, onManualChange: () => { setEstimateToUse(null); // Restore existing values setGasPrice(gasPriceToUse); setGasLimit(gasLimit); setMaxFeePerGas(maxFeePerGasToUse); setMaxPriorityFeePerGas(maxPriorityFeePerGasToUse); setGasPriceHasBeenManuallySet(true); }, balanceError, estimatesUnavailableWarning, }; }