import React, { useState, useContext, useMemo, useEffect, useRef } from 'react'; import { useSelector, useDispatch } from 'react-redux'; import { useHistory } from 'react-router-dom'; import BigNumber from 'bignumber.js'; import { isEqual } from 'lodash'; import classnames from 'classnames'; import { I18nContext } from '../../../contexts/i18n'; import SelectQuotePopover from '../select-quote-popover'; import { useEthFiatAmount } from '../../../hooks/useEthFiatAmount'; import { useEqualityCheck } from '../../../hooks/useEqualityCheck'; import { useNewMetricEvent } from '../../../hooks/useMetricEvent'; import { usePrevious } from '../../../hooks/usePrevious'; import { useGasFeeInputs } from '../../../hooks/useGasFeeInputs'; import { MetaMetricsContext } from '../../../contexts/metametrics.new'; import FeeCard from '../fee-card'; import EditGasPopover from '../../../components/app/edit-gas-popover/edit-gas-popover.component'; import { FALLBACK_GAS_MULTIPLIER, getQuotes, getSelectedQuote, getApproveTxParams, getFetchParams, setBalanceError, getQuotesLastFetched, getBalanceError, getCustomSwapsGas, // Gas limit. getCustomMaxFeePerGas, getCustomMaxPriorityFeePerGas, getSwapsUserFeeLevel, getDestinationTokenInfo, getUsedSwapsGasPrice, getTopQuote, navigateBackToBuildQuote, signAndSendTransactions, getBackgroundSwapRouteState, swapsQuoteSelected, getSwapsQuoteRefreshTime, getReviewSwapClickedTimestamp, } from '../../../ducks/swaps/swaps'; import { conversionRateSelector, getSelectedAccount, getCurrentCurrency, getTokenExchangeRates, getSwapsDefaultToken, getCurrentChainId, isHardwareWallet, getHardwareWalletType, checkNetworkAndAccountSupports1559, } from '../../../selectors'; import { getNativeCurrency, getTokens } from '../../../ducks/metamask/metamask'; import { toPrecisionWithoutTrailingZeros, isEqualCaseInsensitive, } from '../../../helpers/utils/util'; import { safeRefetchQuotes, setCustomApproveTxData, setSwapsErrorKey, showModal, setSwapsQuotesPollingLimitEnabled, } from '../../../store/actions'; import { ASSET_ROUTE, BUILD_QUOTE_ROUTE, DEFAULT_ROUTE, SWAPS_ERROR_ROUTE, AWAITING_SWAP_ROUTE, } from '../../../helpers/constants/routes'; import { getTokenData } from '../../../helpers/utils/transactions.util'; import { calcTokenAmount, calcTokenValue, getTokenValueParam, } from '../../../helpers/utils/token-util'; import { decimalToHex, hexToDecimal, getValueFromWeiHex, decGWEIToHexWEI, hexWEIToDecGWEI, addHexes, } from '../../../helpers/utils/conversions.util'; import MainQuoteSummary from '../main-quote-summary'; import { calcGasTotal } from '../../send/send.utils'; import { getCustomTxParamsData } from '../../confirm-approve/confirm-approve.util'; import ActionableMessage from '../../../components/ui/actionable-message/actionable-message'; import { quotesToRenderableData, getRenderableNetworkFeesForQuote, } from '../swaps.util'; import { useTokenTracker } from '../../../hooks/useTokenTracker'; import { QUOTES_EXPIRED_ERROR } from '../../../../shared/constants/swaps'; import { EDIT_GAS_MODES } from '../../../../shared/constants/gas'; import CountdownTimer from '../countdown-timer'; import SwapsFooter from '../swaps-footer'; import ViewQuotePriceDifference from './view-quote-price-difference'; export default function ViewQuote() { const history = useHistory(); const dispatch = useDispatch(); const t = useContext(I18nContext); const metaMetricsEvent = useContext(MetaMetricsContext); const [dispatchedSafeRefetch, setDispatchedSafeRefetch] = useState(false); const [submitClicked, setSubmitClicked] = useState(false); const [selectQuotePopoverShown, setSelectQuotePopoverShown] = useState(false); const [warningHidden, setWarningHidden] = useState(false); const [originalApproveAmount, setOriginalApproveAmount] = useState(null); const [showEditGasPopover, setShowEditGasPopover] = useState(false); // We need to have currentTimestamp in state, otherwise it would change with each rerender. const [currentTimestamp] = useState(Date.now()); const [ acknowledgedPriceDifference, setAcknowledgedPriceDifference, ] = useState(false); const priceDifferenceRiskyBuckets = ['high', 'medium']; const routeState = useSelector(getBackgroundSwapRouteState); const quotes = useSelector(getQuotes, isEqual); useEffect(() => { if (!Object.values(quotes).length) { history.push(BUILD_QUOTE_ROUTE); } else if (routeState === 'awaiting') { history.push(AWAITING_SWAP_ROUTE); } }, [history, quotes, routeState]); const quotesLastFetched = useSelector(getQuotesLastFetched); // Select necessary data const gasPrice = useSelector(getUsedSwapsGasPrice); const customMaxGas = useSelector(getCustomSwapsGas); const customMaxFeePerGas = useSelector(getCustomMaxFeePerGas); const customMaxPriorityFeePerGas = useSelector(getCustomMaxPriorityFeePerGas); const swapsUserFeeLevel = useSelector(getSwapsUserFeeLevel); const tokenConversionRates = useSelector(getTokenExchangeRates); const memoizedTokenConversionRates = useEqualityCheck(tokenConversionRates); const { balance: ethBalance } = useSelector(getSelectedAccount); const conversionRate = useSelector(conversionRateSelector); const currentCurrency = useSelector(getCurrentCurrency); const swapsTokens = useSelector(getTokens); const networkAndAccountSupports1559 = useSelector( checkNetworkAndAccountSupports1559, ); const balanceError = useSelector(getBalanceError); const fetchParams = useSelector(getFetchParams); const approveTxParams = useSelector(getApproveTxParams); const selectedQuote = useSelector(getSelectedQuote); const topQuote = useSelector(getTopQuote); const usedQuote = selectedQuote || topQuote; const tradeValue = usedQuote?.trade?.value ?? '0x0'; const swapsQuoteRefreshTime = useSelector(getSwapsQuoteRefreshTime); const defaultSwapsToken = useSelector(getSwapsDefaultToken); const chainId = useSelector(getCurrentChainId); const nativeCurrencySymbol = useSelector(getNativeCurrency); const reviewSwapClickedTimestamp = useSelector(getReviewSwapClickedTimestamp); let gasFeeInputs; if (networkAndAccountSupports1559) { // For Swaps we want to get 'high' estimations by default. // eslint-disable-next-line react-hooks/rules-of-hooks gasFeeInputs = useGasFeeInputs('high', { userFeeLevel: swapsUserFeeLevel || 'high', }); } const { isBestQuote } = usedQuote; const fetchParamsSourceToken = fetchParams?.sourceToken; const usedGasLimit = usedQuote?.gasEstimateWithRefund || `0x${decimalToHex(usedQuote?.averageGas || 0)}`; const gasLimitForMax = usedQuote?.gasEstimate || `0x0`; const usedGasLimitWithMultiplier = new BigNumber(gasLimitForMax, 16) .times(usedQuote?.gasMultiplier || FALLBACK_GAS_MULTIPLIER, 10) .round(0) .toString(16); const nonCustomMaxGasLimit = usedQuote?.gasEstimate ? usedGasLimitWithMultiplier : `0x${decimalToHex(usedQuote?.maxGas || 0)}`; const maxGasLimit = customMaxGas || nonCustomMaxGasLimit; let maxFeePerGas; let maxPriorityFeePerGas; let baseAndPriorityFeePerGas; if (networkAndAccountSupports1559) { const { maxFeePerGas: suggestedMaxFeePerGas, maxPriorityFeePerGas: suggestedMaxPriorityFeePerGas, gasFeeEstimates: { estimatedBaseFee = '0' }, } = gasFeeInputs; maxFeePerGas = customMaxFeePerGas || decGWEIToHexWEI(suggestedMaxFeePerGas); maxPriorityFeePerGas = customMaxPriorityFeePerGas || decGWEIToHexWEI(suggestedMaxPriorityFeePerGas); baseAndPriorityFeePerGas = addHexes( decGWEIToHexWEI(estimatedBaseFee), maxPriorityFeePerGas, ); } const gasTotalInWeiHex = calcGasTotal( maxGasLimit, networkAndAccountSupports1559 ? maxFeePerGas : gasPrice, ); const { tokensWithBalances } = useTokenTracker(swapsTokens, true); const balanceToken = fetchParamsSourceToken === defaultSwapsToken.address ? defaultSwapsToken : tokensWithBalances.find(({ address }) => isEqualCaseInsensitive(address, fetchParamsSourceToken), ); const selectedFromToken = balanceToken || usedQuote.sourceTokenInfo; const tokenBalance = tokensWithBalances?.length && calcTokenAmount( selectedFromToken.balance || '0x0', selectedFromToken.decimals, ).toFixed(9); const tokenBalanceUnavailable = tokensWithBalances && balanceToken === undefined; const approveData = getTokenData(approveTxParams?.data); const approveValue = approveData && getTokenValueParam(approveData); const approveAmount = approveValue && selectedFromToken?.decimals !== undefined && calcTokenAmount(approveValue, selectedFromToken.decimals).toFixed(9); const approveGas = approveTxParams?.gas; const renderablePopoverData = useMemo(() => { return quotesToRenderableData( quotes, networkAndAccountSupports1559 ? baseAndPriorityFeePerGas : gasPrice, conversionRate, currentCurrency, approveGas, memoizedTokenConversionRates, chainId, ); }, [ quotes, gasPrice, baseAndPriorityFeePerGas, networkAndAccountSupports1559, conversionRate, currentCurrency, approveGas, memoizedTokenConversionRates, chainId, ]); const renderableDataForUsedQuote = renderablePopoverData.find( (renderablePopoverDatum) => renderablePopoverDatum.aggId === usedQuote.aggregator, ); const { destinationTokenDecimals, destinationTokenSymbol, destinationTokenValue, destinationIconUrl, sourceTokenDecimals, sourceTokenSymbol, sourceTokenValue, sourceTokenIconUrl, } = renderableDataForUsedQuote; const { feeInFiat, feeInEth } = getRenderableNetworkFeesForQuote({ tradeGas: usedGasLimit, approveGas, gasPrice: networkAndAccountSupports1559 ? baseAndPriorityFeePerGas : gasPrice, currentCurrency, conversionRate, tradeValue, sourceSymbol: sourceTokenSymbol, sourceAmount: usedQuote.sourceAmount, chainId, nativeCurrencySymbol, }); const { feeInFiat: maxFeeInFiat, feeInEth: maxFeeInEth, nonGasFee, } = getRenderableNetworkFeesForQuote({ tradeGas: maxGasLimit, approveGas, gasPrice: networkAndAccountSupports1559 ? maxFeePerGas : gasPrice, currentCurrency, conversionRate, tradeValue, sourceSymbol: sourceTokenSymbol, sourceAmount: usedQuote.sourceAmount, chainId, nativeCurrencySymbol, }); const tokenCost = new BigNumber(usedQuote.sourceAmount); const ethCost = new BigNumber(usedQuote.trade.value || 0, 10).plus( new BigNumber(gasTotalInWeiHex, 16), ); const insufficientTokens = (tokensWithBalances?.length || balanceError) && tokenCost.gt(new BigNumber(selectedFromToken.balance || '0x0')); const insufficientEth = ethCost.gt(new BigNumber(ethBalance || '0x0')); const tokenBalanceNeeded = insufficientTokens ? toPrecisionWithoutTrailingZeros( calcTokenAmount(tokenCost, selectedFromToken.decimals) .minus(tokenBalance) .toString(10), 6, ) : null; const ethBalanceNeeded = insufficientEth ? toPrecisionWithoutTrailingZeros( ethCost .minus(ethBalance, 16) .div('1000000000000000000', 10) .toString(10), 6, ) : null; const destinationToken = useSelector(getDestinationTokenInfo); useEffect(() => { if (insufficientTokens || insufficientEth) { dispatch(setBalanceError(true)); } else if (balanceError && !insufficientTokens && !insufficientEth) { dispatch(setBalanceError(false)); } }, [insufficientTokens, insufficientEth, balanceError, dispatch]); useEffect(() => { const currentTime = Date.now(); const timeSinceLastFetched = currentTime - quotesLastFetched; if ( timeSinceLastFetched > swapsQuoteRefreshTime && !dispatchedSafeRefetch ) { setDispatchedSafeRefetch(true); dispatch(safeRefetchQuotes()); } else if (timeSinceLastFetched > swapsQuoteRefreshTime) { dispatch(setSwapsErrorKey(QUOTES_EXPIRED_ERROR)); history.push(SWAPS_ERROR_ROUTE); } }, [ quotesLastFetched, dispatchedSafeRefetch, dispatch, history, swapsQuoteRefreshTime, ]); useEffect(() => { if (!originalApproveAmount && approveAmount) { setOriginalApproveAmount(approveAmount); } }, [originalApproveAmount, approveAmount]); const showInsufficientWarning = (balanceError || tokenBalanceNeeded || ethBalanceNeeded) && !warningHidden; const hardwareWalletUsed = useSelector(isHardwareWallet); const hardwareWalletType = useSelector(getHardwareWalletType); const numberOfQuotes = Object.values(quotes).length; const bestQuoteReviewedEventSent = useRef(); const eventObjectBase = { token_from: sourceTokenSymbol, token_from_amount: sourceTokenValue, token_to: destinationTokenSymbol, token_to_amount: destinationTokenValue, request_type: fetchParams?.balanceError, slippage: fetchParams?.slippage, custom_slippage: fetchParams?.slippage !== 2, response_time: fetchParams?.responseTime, best_quote_source: topQuote?.aggregator, available_quotes: numberOfQuotes, is_hardware_wallet: hardwareWalletUsed, hardware_wallet_type: hardwareWalletType, }; const allAvailableQuotesOpened = useNewMetricEvent({ event: 'All Available Quotes Opened', category: 'swaps', sensitiveProperties: { ...eventObjectBase, other_quote_selected: usedQuote?.aggregator !== topQuote?.aggregator, other_quote_selected_source: usedQuote?.aggregator === topQuote?.aggregator ? null : usedQuote?.aggregator, }, }); const quoteDetailsOpened = useNewMetricEvent({ event: 'Quote Details Opened', category: 'swaps', sensitiveProperties: { ...eventObjectBase, other_quote_selected: usedQuote?.aggregator !== topQuote?.aggregator, other_quote_selected_source: usedQuote?.aggregator === topQuote?.aggregator ? null : usedQuote?.aggregator, }, }); const editSpendLimitOpened = useNewMetricEvent({ event: 'Edit Spend Limit Opened', category: 'swaps', sensitiveProperties: { ...eventObjectBase, custom_spend_limit_set: originalApproveAmount === approveAmount, custom_spend_limit_amount: originalApproveAmount === approveAmount ? null : approveAmount, }, }); const bestQuoteReviewedEvent = useNewMetricEvent({ event: 'Best Quote Reviewed', category: 'swaps', sensitiveProperties: { ...eventObjectBase, network_fees: feeInFiat, }, }); const viewQuotePageLoadedEvent = useNewMetricEvent({ event: 'View Quote Page Loaded', category: 'swaps', sensitiveProperties: { ...eventObjectBase, response_time: currentTimestamp - reviewSwapClickedTimestamp, }, }); useEffect(() => { if ( !bestQuoteReviewedEventSent.current && [ sourceTokenSymbol, sourceTokenValue, destinationTokenSymbol, destinationTokenValue, fetchParams, topQuote, numberOfQuotes, feeInFiat, ].every((dep) => dep !== null && dep !== undefined) ) { bestQuoteReviewedEventSent.current = true; bestQuoteReviewedEvent(); } }, [ sourceTokenSymbol, sourceTokenValue, destinationTokenSymbol, destinationTokenValue, fetchParams, topQuote, numberOfQuotes, feeInFiat, bestQuoteReviewedEvent, ]); const metaMaskFee = usedQuote.fee; const onFeeCardTokenApprovalClick = () => { editSpendLimitOpened(); dispatch( showModal({ name: 'EDIT_APPROVAL_PERMISSION', decimals: selectedFromToken.decimals, origin: 'MetaMask', setCustomAmount: (newCustomPermissionAmount) => { const customPermissionAmount = newCustomPermissionAmount === '' ? originalApproveAmount : newCustomPermissionAmount; const newData = getCustomTxParamsData(approveTxParams.data, { customPermissionAmount, decimals: selectedFromToken.decimals, }); if ( customPermissionAmount?.length && approveTxParams.data !== newData ) { dispatch(setCustomApproveTxData(newData)); } }, tokenAmount: originalApproveAmount, customTokenAmount: originalApproveAmount === approveAmount ? null : approveAmount, tokenBalance, tokenSymbol: selectedFromToken.symbol, requiredMinimum: calcTokenAmount( usedQuote.sourceAmount, selectedFromToken.decimals, ), }), ); }; const nonGasFeeIsPositive = new BigNumber(nonGasFee, 16).gt(0); const approveGasTotal = calcGasTotal( approveGas || '0x0', networkAndAccountSupports1559 ? baseAndPriorityFeePerGas : gasPrice, ); const extraNetworkFeeTotalInHexWEI = new BigNumber(nonGasFee, 16) .plus(approveGasTotal, 16) .toString(16); const extraNetworkFeeTotalInEth = getValueFromWeiHex({ value: extraNetworkFeeTotalInHexWEI, toDenomination: 'ETH', numberOfDecimals: 4, }); let extraInfoRowLabel = ''; if (approveGas && nonGasFeeIsPositive) { extraInfoRowLabel = t('approvalAndAggregatorTxFeeCost'); } else if (approveGas) { extraInfoRowLabel = t('approvalTxGasCost'); } else if (nonGasFeeIsPositive) { extraInfoRowLabel = t('aggregatorFeeCost'); } const onFeeCardMaxRowClick = () => { networkAndAccountSupports1559 ? setShowEditGasPopover(true) : dispatch( showModal({ name: 'CUSTOMIZE_METASWAP_GAS', value: tradeValue, customGasLimitMessage: approveGas ? t('extraApprovalGas', [hexToDecimal(approveGas)]) : '', customTotalSupplement: approveGasTotal, extraInfoRow: extraInfoRowLabel ? { label: extraInfoRowLabel, value: `${extraNetworkFeeTotalInEth} ${nativeCurrencySymbol}`, } : null, initialGasPrice: gasPrice, initialGasLimit: maxGasLimit, minimumGasLimit: new BigNumber(nonCustomMaxGasLimit, 16).toNumber(), }), ); }; const tokenApprovalTextComponent = ( {sourceTokenSymbol} ); const actionableBalanceErrorMessage = tokenBalanceUnavailable ? t('swapTokenBalanceUnavailable', [sourceTokenSymbol]) : t('swapApproveNeedMoreTokens', [ {tokenBalanceNeeded || ethBalanceNeeded} , tokenBalanceNeeded && !(sourceTokenSymbol === defaultSwapsToken.symbol) ? sourceTokenSymbol : defaultSwapsToken.symbol, ]); // Price difference warning const priceSlippageBucket = usedQuote?.priceSlippage?.bucket; const lastPriceDifferenceBucket = usePrevious(priceSlippageBucket); // If the user agreed to a different bucket of risk, make them agree again useEffect(() => { if ( acknowledgedPriceDifference && lastPriceDifferenceBucket === 'medium' && priceSlippageBucket === 'high' ) { setAcknowledgedPriceDifference(false); } }, [ priceSlippageBucket, acknowledgedPriceDifference, lastPriceDifferenceBucket, ]); let viewQuotePriceDifferenceComponent = null; const priceSlippageFromSource = useEthFiatAmount( usedQuote?.priceSlippage?.sourceAmountInETH || 0, { showFiat: true }, ); const priceSlippageFromDestination = useEthFiatAmount( usedQuote?.priceSlippage?.destinationAmountInETH || 0, { showFiat: true }, ); // We cannot present fiat value if there is a calculation error or no slippage // from source or destination const priceSlippageUnknownFiatValue = !priceSlippageFromSource || !priceSlippageFromDestination || usedQuote?.priceSlippage?.calculationError; let priceDifferencePercentage = 0; if (usedQuote?.priceSlippage?.ratio) { priceDifferencePercentage = parseFloat( new BigNumber(usedQuote.priceSlippage.ratio, 10) .minus(1, 10) .times(100, 10) .toFixed(2), 10, ); } const shouldShowPriceDifferenceWarning = !tokenBalanceUnavailable && !showInsufficientWarning && usedQuote && (priceDifferenceRiskyBuckets.includes(priceSlippageBucket) || priceSlippageUnknownFiatValue); if (shouldShowPriceDifferenceWarning) { viewQuotePriceDifferenceComponent = ( { setAcknowledgedPriceDifference(true); }} acknowledged={acknowledgedPriceDifference} /> ); } const disableSubmissionDueToPriceWarning = shouldShowPriceDifferenceWarning && !acknowledgedPriceDifference; const isShowingWarning = showInsufficientWarning || shouldShowPriceDifferenceWarning; const onCloseEditGasPopover = () => { setShowEditGasPopover(false); }; useEffect(() => { // Thanks to the next line we will only do quotes polling 3 times before showing a Quote Timeout modal. dispatch(setSwapsQuotesPollingLimitEnabled(true)); if (reviewSwapClickedTimestamp) { viewQuotePageLoadedEvent(); } }, [dispatch, viewQuotePageLoadedEvent, reviewSwapClickedTimestamp]); return (
{selectQuotePopoverShown && ( setSelectQuotePopoverShown(false)} onSubmit={(aggId) => dispatch(swapsQuoteSelected(aggId))} swapToSymbol={destinationTokenSymbol} initialAggId={usedQuote.aggregator} onQuoteDetailsIsOpened={quoteDetailsOpened} /> )} {showEditGasPopover && networkAndAccountSupports1559 && ( )}
{viewQuotePriceDifferenceComponent} {(showInsufficientWarning || tokenBalanceUnavailable) && ( setWarningHidden(true)} /> )}
{ allAvailableQuotesOpened(); setSelectQuotePopoverShown(true); }} tokenConversionRate={ destinationTokenSymbol === defaultSwapsToken.symbol ? 1 : memoizedTokenConversionRates[destinationToken.address] } chainId={chainId} networkAndAccountSupports1559={networkAndAccountSupports1559} maxPriorityFeePerGasDecGWEI={hexWEIToDecGWEI(maxPriorityFeePerGas)} maxFeePerGasDecGWEI={hexWEIToDecGWEI(maxFeePerGas)} />
{ setSubmitClicked(true); if (!balanceError) { dispatch(signAndSendTransactions(history, metaMetricsEvent)); } else if (destinationToken.symbol === defaultSwapsToken.symbol) { history.push(DEFAULT_ROUTE); } else { history.push(`${ASSET_ROUTE}/${destinationToken.address}`); } }} submitText={t('swap')} onCancel={async () => await dispatch(navigateBackToBuildQuote(history))} disabled={ submitClicked || balanceError || tokenBalanceUnavailable || disableSubmissionDueToPriceWarning || (networkAndAccountSupports1559 && baseAndPriorityFeePerGas === undefined) || (!networkAndAccountSupports1559 && (gasPrice === null || gasPrice === undefined)) } className={isShowingWarning && 'view-quote__thin-swaps-footer'} showTopBorder />
); }