import EventEmitter from 'events' import React, { useContext, useRef, useState, useEffect } from 'react' import { useDispatch, useSelector } from 'react-redux' import PropTypes from 'prop-types' import classnames from 'classnames' import { useHistory } from 'react-router-dom' import { I18nContext } from '../../../contexts/i18n' import { useNewMetricEvent } from '../../../hooks/useMetricEvent' import { MetaMetricsContext } from '../../../contexts/metametrics.new' import { getCurrentCurrency, conversionRateSelector } from '../../../selectors' import { getUsedQuote, getFetchParams, getApproveTxParams, getUsedSwapsGasPrice, fetchQuotesAndSetQuoteState, navigateBackToBuildQuote, prepareForRetryGetQuotes, prepareToLeaveSwaps, } from '../../../ducks/swaps/swaps' import { useTransactionTimeRemaining } from '../../../hooks/useTransactionTimeRemaining' import { usePrevious } from '../../../hooks/usePrevious' import Mascot from '../../../components/ui/mascot' import PulseLoader from '../../../components/ui/pulse-loader' import { getBlockExplorerUrlForTx, getStatusKey, } from '../../../helpers/utils/transactions.util' import CountdownTimer from '../countdown-timer' import { QUOTES_EXPIRED_ERROR, SWAP_FAILED_ERROR, ERROR_FETCHING_QUOTES, QUOTES_NOT_AVAILABLE_ERROR, OFFLINE_FOR_MAINTENANCE, } from '../../../helpers/constants/swaps' import { ASSET_ROUTE, DEFAULT_ROUTE } from '../../../helpers/constants/routes' import { getRenderableNetworkFeesForQuote } from '../swaps.util' import SwapsFooter from '../swaps-footer' import { TRANSACTION_STATUSES } from '../../../../../shared/constants/transaction' import SwapFailureIcon from './swap-failure-icon' import SwapSuccessIcon from './swap-success-icon' import QuotesTimeoutIcon from './quotes-timeout-icon' import ViewOnEtherScanLink from './view-on-ether-scan-link' export default function AwaitingSwap({ swapComplete, errorKey, txHash, networkId, tokensReceived, rpcPrefs, submittingSwap, tradeTxData, usedGasPrice, inputValue, maxSlippage, }) { const t = useContext(I18nContext) const metaMetricsEvent = useContext(MetaMetricsContext) const history = useHistory() const dispatch = useDispatch() const animationEventEmitter = useRef(new EventEmitter()) const fetchParams = useSelector(getFetchParams) const { destinationTokenInfo, sourceTokenInfo } = fetchParams?.metaData || {} const usedQuote = useSelector(getUsedQuote) const approveTxParams = useSelector(getApproveTxParams) const swapsGasPrice = useSelector(getUsedSwapsGasPrice) const currentCurrency = useSelector(getCurrentCurrency) const conversionRate = useSelector(conversionRateSelector) const [timeRemainingExpired, setTimeRemainingExpired] = useState(false) const [trackedQuotesExpiredEvent, setTrackedQuotesExpiredEvent] = useState( false, ) let feeinFiat if (usedQuote && swapsGasPrice) { const renderableNetworkFees = getRenderableNetworkFeesForQuote( usedQuote.gasEstimateWithRefund || usedQuote.averageGas, approveTxParams?.gas || '0x0', swapsGasPrice, currentCurrency, conversionRate, usedQuote?.trade?.value, sourceTokenInfo?.symbol, usedQuote.sourceAmount, ) feeinFiat = renderableNetworkFees.feeinFiat?.slice(1) } const quotesExpiredEvent = useNewMetricEvent({ event: 'Quotes Timed Out', excludeMetaMetricsId: true, properties: { token_from: sourceTokenInfo?.symbol, token_from_amount: fetchParams?.value, token_to: destinationTokenInfo?.symbol, request_type: fetchParams?.balanceError ? 'Quote' : 'Order', slippage: fetchParams?.slippage, custom_slippage: fetchParams?.slippage === 2, gas_fees: feeinFiat, }, category: 'swaps', }) const anonymousQuotesExpiredEvent = useNewMetricEvent({ event: 'Quotes Timed Out', category: 'swaps', }) const blockExplorerUrl = txHash && getBlockExplorerUrlForTx(networkId, txHash, rpcPrefs) const statusKey = tradeTxData && getStatusKey(tradeTxData) const timeRemaining = useTransactionTimeRemaining( statusKey === TRANSACTION_STATUSES.SUBMITTED, true, tradeTxData?.submittedTime, usedGasPrice, true, true, ) const previousTimeRemaining = usePrevious(timeRemaining) const timeRemainingIsNumber = typeof timeRemaining === 'number' && !isNaN(timeRemaining) const previousTimeRemainingIsNumber = typeof previousTimeRemaining === 'number' && !isNaN(previousTimeRemaining) const estimatedTransactionWaitTime = timeRemaining * 1000 * 60 useEffect(() => { if ( !timeRemainingIsNumber && previousTimeRemainingIsNumber && !timeRemainingExpired ) { setTimeRemainingExpired(true) } }, [ timeRemainingIsNumber, previousTimeRemainingIsNumber, timeRemainingExpired, ]) let countdownText if ( timeRemainingIsNumber && !timeRemainingExpired && tradeTxData?.submittedTime ) { countdownText = ( ) } else if (tradeTxData?.submittedTime) { countdownText = t('swapsAlmostDone') } else { countdownText = t('swapEstimatedTimeCalculating') } let headerText let statusImage let descriptionText let submitText let content if (errorKey === OFFLINE_FOR_MAINTENANCE) { headerText = t('offlineForMaintenance') descriptionText = t('metamaskSwapsOfflineDescription') submitText = t('close') statusImage = } else if (errorKey === SWAP_FAILED_ERROR) { headerText = t('swapFailedErrorTitle') descriptionText = t('swapFailedErrorDescription') submitText = t('tryAgain') statusImage = content = blockExplorerUrl && ( ) } else if (errorKey === QUOTES_EXPIRED_ERROR) { headerText = t('swapQuotesExpiredErrorTitle') descriptionText = t('swapQuotesExpiredErrorDescription') submitText = t('tryAgain') statusImage = if (!trackedQuotesExpiredEvent) { setTrackedQuotesExpiredEvent(true) quotesExpiredEvent() anonymousQuotesExpiredEvent() } } else if (errorKey === ERROR_FETCHING_QUOTES) { headerText = t('swapFetchingQuotesErrorTitle') descriptionText = t('swapFetchingQuotesErrorDescription') submitText = t('back') statusImage = } else if (errorKey === QUOTES_NOT_AVAILABLE_ERROR) { headerText = t('swapQuotesNotAvailableErrorTitle') descriptionText = t('swapQuotesNotAvailableErrorDescription') submitText = t('tryAgain') statusImage = } else if (!errorKey && !swapComplete) { /** * only show estimated time if the transaction has a submitted time, the swap has * not yet completed and there isn't an error. If the swap has not completed and * there is no error, but also has no submitted time (has not yet been published), * then we apply the invisible class to the time estimate div. This creates consistent * spacing before and after display of the time estimate. */ headerText = t('swapProcessing') statusImage = submitText = t('swapsViewInActivity') descriptionText = t('swapOnceTransactionHasProcess', [ {destinationTokenInfo.symbol} , ]) content = ( <>
{t('swapEstimatedTimeFull', [ {t('swapEstimatedTime')} , countdownText, ])}
{blockExplorerUrl && ( )} ) } else if (!errorKey && swapComplete) { headerText = t('swapTransactionComplete') statusImage = submitText = t('swapViewToken', [destinationTokenInfo.symbol]) descriptionText = t('swapTokenAvailable', [ {`${tokensReceived || ''} ${destinationTokenInfo.symbol}`} , ]) content = blockExplorerUrl && ( ) } return (
{!(swapComplete || errorKey) && ( )}
{statusImage}
{headerText}
{descriptionText}
{content}
{ if (errorKey === OFFLINE_FOR_MAINTENANCE) { await dispatch(prepareToLeaveSwaps()) history.push(DEFAULT_ROUTE) } else if (errorKey === QUOTES_EXPIRED_ERROR) { dispatch(prepareForRetryGetQuotes()) await dispatch( fetchQuotesAndSetQuoteState( history, inputValue, maxSlippage, metaMetricsEvent, ), ) } else if (errorKey) { await dispatch(navigateBackToBuildQuote(history)) } else if (destinationTokenInfo?.symbol === 'ETH') { history.push(DEFAULT_ROUTE) } else { history.push(`${ASSET_ROUTE}/${destinationTokenInfo?.address}`) } }} onCancel={async () => await dispatch(navigateBackToBuildQuote(history))} submitText={submitText} disabled={submittingSwap} hideCancel={errorKey !== QUOTES_EXPIRED_ERROR} />
) } AwaitingSwap.propTypes = { swapComplete: PropTypes.bool, networkId: PropTypes.string.isRequired, txHash: PropTypes.string, tokensReceived: PropTypes.string, rpcPrefs: PropTypes.object.isRequired, errorKey: PropTypes.oneOf([ QUOTES_EXPIRED_ERROR, SWAP_FAILED_ERROR, ERROR_FETCHING_QUOTES, QUOTES_NOT_AVAILABLE_ERROR, OFFLINE_FOR_MAINTENANCE, ]), submittingSwap: PropTypes.bool, tradeTxData: PropTypes.object, usedGasPrice: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), inputValue: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), maxSlippage: PropTypes.number, }