import React, { useContext, useEffect, useState, useCallback } from 'react'; import BigNumber from 'bignumber.js'; import PropTypes from 'prop-types'; import { shallowEqual, useDispatch, useSelector } from 'react-redux'; import classnames from 'classnames'; import { uniqBy, isEqual } from 'lodash'; import { useHistory } from 'react-router-dom'; import { getTokenTrackerLink } from '@metamask/etherscan-link'; import { MetaMetricsContext } from '../../../contexts/metametrics.new'; import { useNewMetricEvent } from '../../../hooks/useMetricEvent'; import { useTokensToSearch, getRenderableTokenData, } from '../../../hooks/useTokensToSearch'; import { useEqualityCheck } from '../../../hooks/useEqualityCheck'; import { I18nContext } from '../../../contexts/i18n'; import DropdownInputPair from '../dropdown-input-pair'; import DropdownSearchList from '../dropdown-search-list'; import SlippageButtons from '../slippage-buttons'; import { getTokens, getConversionRate } from '../../../ducks/metamask/metamask'; import InfoTooltip from '../../../components/ui/info-tooltip'; import Popover from '../../../components/ui/popover'; import Button from '../../../components/ui/button'; import ActionableMessage from '../../../components/ui/actionable-message/actionable-message'; import Box from '../../../components/ui/box'; import Typography from '../../../components/ui/typography'; import { TYPOGRAPHY, DISPLAY, FLEX_DIRECTION, FONT_WEIGHT, COLORS, } from '../../../helpers/constants/design-system'; import { VIEW_QUOTE_ROUTE, LOADING_QUOTES_ROUTE, } from '../../../helpers/constants/routes'; import { fetchQuotesAndSetQuoteState, setSwapsFromToken, setSwapToToken, getFromToken, getToToken, getBalanceError, getTopAssets, getFetchParams, getQuotes, setBalanceError, setFromTokenInputValue, setFromTokenError, setMaxSlippage, setReviewSwapClickedTimestamp, getSmartTransactionsOptInStatus, getSmartTransactionsEnabled, getCurrentSmartTransactionsEnabled, getFromTokenInputValue, getFromTokenError, getMaxSlippage, getIsFeatureFlagLoaded, getCurrentSmartTransactionsError, } from '../../../ducks/swaps/swaps'; import { getSwapsDefaultToken, getTokenExchangeRates, getCurrentCurrency, getCurrentChainId, getRpcPrefsForCurrentProvider, getUseTokenDetection, getTokenList, isHardwareWallet, getHardwareWalletType, } from '../../../selectors'; import { getValueFromWeiHex, hexToDecimal, } from '../../../helpers/utils/conversions.util'; import { calcTokenAmount } from '../../../helpers/utils/token-util'; import { getURLHostName, isEqualCaseInsensitive, } from '../../../helpers/utils/util'; import { usePrevious } from '../../../hooks/usePrevious'; import { useTokenTracker } from '../../../hooks/useTokenTracker'; import { useTokenFiatAmount } from '../../../hooks/useTokenFiatAmount'; import { useEthFiatAmount } from '../../../hooks/useEthFiatAmount'; import { isSwapsDefaultTokenAddress, isSwapsDefaultTokenSymbol, } from '../../../../shared/modules/swaps.utils'; import { SWAPS_CHAINID_DEFAULT_BLOCK_EXPLORER_URL_MAP, SWAPS_CHAINID_DEFAULT_TOKEN_MAP, } from '../../../../shared/constants/swaps'; import { resetSwapsPostFetchState, removeToken, setBackgroundSwapRouteState, clearSwapsQuotes, stopPollingForQuotes, setSmartTransactionsOptInStatus, } from '../../../store/actions'; import { countDecimals, fetchTokenPrice, fetchTokenBalance, shouldEnableDirectWrapping, } from '../swaps.util'; import SwapsFooter from '../swaps-footer'; const fuseSearchKeys = [ { name: 'name', weight: 0.499 }, { name: 'symbol', weight: 0.499 }, { name: 'address', weight: 0.002 }, ]; const MAX_ALLOWED_SLIPPAGE = 15; let timeoutIdForQuotesPrefetching; export default function BuildQuote({ ethBalance, selectedAccountAddress, shuffledTokensList, }) { const t = useContext(I18nContext); const dispatch = useDispatch(); const history = useHistory(); const metaMetricsEvent = useContext(MetaMetricsContext); const [fetchedTokenExchangeRate, setFetchedTokenExchangeRate] = useState( undefined, ); const [verificationClicked, setVerificationClicked] = useState(false); const isFeatureFlagLoaded = useSelector(getIsFeatureFlagLoaded); const balanceError = useSelector(getBalanceError); const fetchParams = useSelector(getFetchParams, isEqual); const { sourceTokenInfo = {}, destinationTokenInfo = {} } = fetchParams?.metaData || {}; const tokens = useSelector(getTokens, isEqual); const topAssets = useSelector(getTopAssets); const fromToken = useSelector(getFromToken, isEqual); const fromTokenInputValue = useSelector(getFromTokenInputValue); const fromTokenError = useSelector(getFromTokenError); const maxSlippage = useSelector(getMaxSlippage); const toToken = useSelector(getToToken) || destinationTokenInfo; const defaultSwapsToken = useSelector(getSwapsDefaultToken, isEqual); const chainId = useSelector(getCurrentChainId); const rpcPrefs = useSelector(getRpcPrefsForCurrentProvider, shallowEqual); const tokenList = useSelector(getTokenList, isEqual); const useTokenDetection = useSelector(getUseTokenDetection); const quotes = useSelector(getQuotes, isEqual); const areQuotesPresent = Object.keys(quotes).length > 0; const tokenConversionRates = useSelector(getTokenExchangeRates, isEqual); const conversionRate = useSelector(getConversionRate); const hardwareWalletUsed = useSelector(isHardwareWallet); const hardwareWalletType = useSelector(getHardwareWalletType); const smartTransactionsOptInStatus = useSelector( getSmartTransactionsOptInStatus, ); const smartTransactionsEnabled = useSelector(getSmartTransactionsEnabled); const currentSmartTransactionsEnabled = useSelector( getCurrentSmartTransactionsEnabled, ); const smartTransactionsOptInPopoverDisplayed = smartTransactionsOptInStatus !== undefined; const currentSmartTransactionsError = useSelector( getCurrentSmartTransactionsError, ); const currentCurrency = useSelector(getCurrentCurrency); const showSmartTransactionsOptInPopover = smartTransactionsEnabled && !smartTransactionsOptInPopoverDisplayed; const onCloseSmartTransactionsOptInPopover = (e) => { e?.preventDefault(); setSmartTransactionsOptInStatus(false); }; const onEnableSmartTransactionsClick = () => setSmartTransactionsOptInStatus(true); const fetchParamsFromToken = isSwapsDefaultTokenSymbol( sourceTokenInfo?.symbol, chainId, ) ? defaultSwapsToken : sourceTokenInfo; const { loading, tokensWithBalances } = useTokenTracker(tokens); // If the fromToken was set in a call to `onFromSelect` (see below), and that from token has a balance // but is not in tokensWithBalances or tokens, then we want to add it to the usersTokens array so that // the balance of the token can appear in the from token selection dropdown const fromTokenArray = !isSwapsDefaultTokenSymbol(fromToken?.symbol, chainId) && fromToken?.balance ? [fromToken] : []; const usersTokens = uniqBy( [...tokensWithBalances, ...tokens, ...fromTokenArray], 'address', ); const memoizedUsersTokens = useEqualityCheck(usersTokens); const selectedFromToken = getRenderableTokenData( fromToken || fetchParamsFromToken, tokenConversionRates, conversionRate, currentCurrency, chainId, tokenList, useTokenDetection, ); const tokensToSearch = useTokensToSearch({ usersTokens: memoizedUsersTokens, topTokens: topAssets, shuffledTokensList, }); const selectedToToken = tokensToSearch.find(({ address }) => isEqualCaseInsensitive(address, toToken?.address), ) || toToken; const toTokenIsNotDefault = selectedToToken?.address && !isSwapsDefaultTokenAddress(selectedToToken?.address, chainId); const occurrences = Number( selectedToToken?.occurances || selectedToToken?.occurrences || 0, ); const { address: fromTokenAddress, symbol: fromTokenSymbol, string: fromTokenString, decimals: fromTokenDecimals, balance: rawFromTokenBalance, } = selectedFromToken || {}; const { address: toTokenAddress } = selectedToToken || {}; const fromTokenBalance = rawFromTokenBalance && calcTokenAmount(rawFromTokenBalance, fromTokenDecimals).toString(10); const prevFromTokenBalance = usePrevious(fromTokenBalance); const swapFromTokenFiatValue = useTokenFiatAmount( fromTokenAddress, fromTokenInputValue || 0, fromTokenSymbol, { showFiat: true, }, true, ); const swapFromEthFiatValue = useEthFiatAmount( fromTokenInputValue || 0, { showFiat: true }, true, ); const swapFromFiatValue = isSwapsDefaultTokenSymbol(fromTokenSymbol, chainId) ? swapFromEthFiatValue : swapFromTokenFiatValue; const onInputChange = useCallback( (newInputValue, balance) => { dispatch(setFromTokenInputValue(newInputValue)); const newBalanceError = new BigNumber(newInputValue || 0).gt( balance || 0, ); // "setBalanceError" is just a warning, a user can still click on the "Review Swap" button. if (balanceError !== newBalanceError) { dispatch(setBalanceError(newBalanceError)); } dispatch( setFromTokenError( fromToken && countDecimals(newInputValue) > fromToken.decimals ? 'tooManyDecimals' : null, ), ); }, [dispatch, fromToken, balanceError], ); const onFromSelect = (token) => { if ( token?.address && !swapFromFiatValue && fetchedTokenExchangeRate !== null ) { fetchTokenPrice(token.address).then((rate) => { if (rate !== null && rate !== undefined) { setFetchedTokenExchangeRate(rate); } }); } else { setFetchedTokenExchangeRate(null); } if ( token?.address && !memoizedUsersTokens.find((usersToken) => isEqualCaseInsensitive(usersToken.address, token.address), ) ) { fetchTokenBalance(token.address, selectedAccountAddress).then( (fetchedBalance) => { if (fetchedBalance?.balance) { const balanceAsDecString = fetchedBalance.balance.toString(10); const userTokenBalance = calcTokenAmount( balanceAsDecString, token.decimals, ); dispatch( setSwapsFromToken({ ...token, string: userTokenBalance.toString(10), balance: balanceAsDecString, }), ); } }, ); } dispatch(setSwapsFromToken(token)); onInputChange( token?.address ? fromTokenInputValue : '', token.string, token.decimals, ); }; const blockExplorerTokenLink = getTokenTrackerLink( selectedToToken.address, chainId, null, // no networkId null, // no holderAddress { blockExplorerUrl: rpcPrefs.blockExplorerUrl ?? SWAPS_CHAINID_DEFAULT_BLOCK_EXPLORER_URL_MAP[chainId] ?? null, }, ); const blockExplorerLabel = rpcPrefs.blockExplorerUrl ? getURLHostName(blockExplorerTokenLink) : t('etherscan'); const blockExplorerLinkClickedEvent = useNewMetricEvent({ category: 'Swaps', event: 'Clicked Block Explorer Link', properties: { link_type: 'Token Tracker', action: 'Swaps Confirmation', block_explorer_domain: getURLHostName(blockExplorerTokenLink), }, }); const { destinationTokenAddedForSwap } = fetchParams || {}; const { address: toAddress } = toToken || {}; const onToSelect = useCallback( (token) => { if (destinationTokenAddedForSwap && token.address !== toAddress) { dispatch(removeToken(toAddress)); } dispatch(setSwapToToken(token)); setVerificationClicked(false); }, [dispatch, destinationTokenAddedForSwap, toAddress], ); const hideDropdownItemIf = useCallback( (item) => isEqualCaseInsensitive(item.address, fromTokenAddress), [fromTokenAddress], ); const tokensWithBalancesFromToken = tokensWithBalances.find((token) => isEqualCaseInsensitive(token.address, fromToken?.address), ); const previousTokensWithBalancesFromToken = usePrevious( tokensWithBalancesFromToken, ); useEffect(() => { const notDefault = !isSwapsDefaultTokenAddress( tokensWithBalancesFromToken?.address, chainId, ); const addressesAreTheSame = isEqualCaseInsensitive( tokensWithBalancesFromToken?.address, previousTokensWithBalancesFromToken?.address, ); const balanceHasChanged = tokensWithBalancesFromToken?.balance !== previousTokensWithBalancesFromToken?.balance; if (notDefault && addressesAreTheSame && balanceHasChanged) { dispatch( setSwapsFromToken({ ...fromToken, balance: tokensWithBalancesFromToken?.balance, string: tokensWithBalancesFromToken?.string, }), ); } }, [ dispatch, tokensWithBalancesFromToken, previousTokensWithBalancesFromToken, fromToken, chainId, ]); // If the eth balance changes while on build quote, we update the selected from token useEffect(() => { if ( isSwapsDefaultTokenAddress(fromToken?.address, chainId) && fromToken?.balance !== hexToDecimal(ethBalance) ) { dispatch( setSwapsFromToken({ ...fromToken, balance: hexToDecimal(ethBalance), string: getValueFromWeiHex({ value: ethBalance, numberOfDecimals: 4, toDenomination: 'ETH', }), }), ); } }, [dispatch, fromToken, ethBalance, chainId]); useEffect(() => { if (prevFromTokenBalance !== fromTokenBalance) { onInputChange(fromTokenInputValue, fromTokenBalance); } }, [ onInputChange, prevFromTokenBalance, fromTokenInputValue, fromTokenBalance, ]); const buildQuotePageLoadedEvent = useNewMetricEvent({ event: 'Build Quote Page Loaded', category: 'swaps', sensitiveProperties: { is_hardware_wallet: hardwareWalletUsed, hardware_wallet_type: hardwareWalletType, stx_enabled: smartTransactionsEnabled, current_stx_enabled: currentSmartTransactionsEnabled, stx_user_opt_in: smartTransactionsOptInStatus, }, }); useEffect(() => { dispatch(resetSwapsPostFetchState()); dispatch(setReviewSwapClickedTimestamp()); buildQuotePageLoadedEvent(); }, [dispatch, buildQuotePageLoadedEvent]); const BlockExplorerLink = () => { return ( { blockExplorerLinkClickedEvent(); global.platform.openTab({ url: blockExplorerTokenLink, }); }} target="_blank" rel="noopener noreferrer" > {blockExplorerLabel} ); }; let tokenVerificationDescription = ''; if (blockExplorerTokenLink) { if (occurrences === 1) { tokenVerificationDescription = t('verifyThisTokenOn', [ , ]); } else if (occurrences === 0) { tokenVerificationDescription = t('verifyThisUnconfirmedTokenOn', [ , ]); } } const swapYourTokenBalance = t('swapYourTokenBalance', [ fromTokenString || '0', fromTokenSymbol || SWAPS_CHAINID_DEFAULT_TOKEN_MAP[chainId]?.symbol || '', ]); const isDirectWrappingEnabled = shouldEnableDirectWrapping( chainId, fromTokenAddress, selectedToToken.address, ); const isReviewSwapButtonDisabled = fromTokenError || !isFeatureFlagLoaded || !Number(fromTokenInputValue) || !selectedToToken?.address || Number(maxSlippage) < 0 || Number(maxSlippage) > MAX_ALLOWED_SLIPPAGE || (toTokenIsNotDefault && occurrences < 2 && !verificationClicked); // It's triggered every time there is a change in form values (token from, token to, amount and slippage). useEffect(() => { dispatch(clearSwapsQuotes()); dispatch(stopPollingForQuotes()); const prefetchQuotesWithoutRedirecting = async () => { const pageRedirectionDisabled = true; await dispatch( fetchQuotesAndSetQuoteState( history, fromTokenInputValue, maxSlippage, metaMetricsEvent, pageRedirectionDisabled, ), ); }; // Delay fetching quotes until a user is done typing an input value. If they type a new char in less than a second, // we will cancel previous setTimeout call and start running a new one. timeoutIdForQuotesPrefetching = setTimeout(() => { timeoutIdForQuotesPrefetching = null; if (!isReviewSwapButtonDisabled) { // Only do quotes prefetching if the Review Swap button is enabled. prefetchQuotesWithoutRedirecting(); } }, 1000); return () => clearTimeout(timeoutIdForQuotesPrefetching); }, [ dispatch, history, maxSlippage, metaMetricsEvent, isReviewSwapButtonDisabled, fromTokenInputValue, fromTokenAddress, toTokenAddress, smartTransactionsOptInStatus, ]); return (
{showSmartTransactionsOptInPopover && ( } footerClassName="smart-transactions-popover__footer" className="smart-transactions-popover" > {t('swapSwapSwitch')} {t('stxDescription')}
  • {t('stxBenefit1')}
  • {t('stxBenefit2')}
  • {t('stxBenefit3')}
  • {t('stxBenefit4')}
  • {t('stxSubDescription')}  {t('stxYouCanOptOut')} 
    )}
    {t('swapSwapFrom')}
    {!isSwapsDefaultTokenSymbol(fromTokenSymbol, chainId) && (
    onInputChange(fromTokenBalance || '0', fromTokenBalance) } > {t('max')}
    )}
    { onInputChange(value, fromTokenBalance); }} inputValue={fromTokenInputValue} leftValue={fromTokenInputValue && swapFromFiatValue} selectedItem={selectedFromToken} maxListItems={30} loading={ loading && (!tokensToSearch?.length || !topAssets || !Object.keys(topAssets).length) } selectPlaceHolderText={t('swapSelect')} hideItemIf={(item) => isEqualCaseInsensitive(item.address, selectedToToken?.address) } listContainerClassName="build-quote__open-dropdown" autoFocus />
    {!fromTokenError && !balanceError && fromTokenSymbol && swapYourTokenBalance} {!fromTokenError && balanceError && fromTokenSymbol && (
    {t('swapsNotEnoughForTx', [fromTokenSymbol])}
    {swapYourTokenBalance}
    )} {fromTokenError && ( <>
    {t('swapTooManyDecimalsError', [ fromTokenSymbol, fromTokenDecimals, ])}
    {swapYourTokenBalance}
    )}
    {t('swapSwapTo')}
    {toTokenIsNotDefault && (occurrences < 2 ? (
    {occurrences === 1 ? t('swapTokenVerificationOnlyOneSource') : t('swapTokenVerificationAddedManually')}
    {tokenVerificationDescription}
    } primaryAction={ verificationClicked ? null : { label: t('continue'), onClick: () => setVerificationClicked(true), } } withRightButton infoTooltipText={ blockExplorerTokenLink && t('swapVerifyTokenExplanation', [blockExplorerLabel]) } /> ) : (
    {t('swapTokenVerificationSources', [occurrences])} {blockExplorerTokenLink && ( <> {t('swapTokenVerificationMessage', [ { blockExplorerLinkClickedEvent(); global.platform.openTab({ url: blockExplorerTokenLink, }); }} target="_blank" rel="noopener noreferrer" > {blockExplorerLabel} , ])} )}
    ))} {!isDirectWrappingEnabled && (
    { dispatch(setMaxSlippage(newSlippage)); }} maxAllowedSlippage={MAX_ALLOWED_SLIPPAGE} currentSlippage={maxSlippage} smartTransactionsEnabled={smartTransactionsEnabled} smartTransactionsOptInStatus={smartTransactionsOptInStatus} setSmartTransactionsOptInStatus={setSmartTransactionsOptInStatus} currentSmartTransactionsError={currentSmartTransactionsError} />
    )}
    { // We need this to know how long it took to go from clicking on the Review Swap button to rendered View Quote page. dispatch(setReviewSwapClickedTimestamp(Date.now())); // In case that quotes prefetching is waiting to be executed, but hasn't started yet, // we want to cancel it and fetch quotes from here. if (timeoutIdForQuotesPrefetching) { clearTimeout(timeoutIdForQuotesPrefetching); dispatch( fetchQuotesAndSetQuoteState( history, fromTokenInputValue, maxSlippage, metaMetricsEvent, ), ); } else if (areQuotesPresent) { // If there are prefetched quotes already, go directly to the View Quote page. history.push(VIEW_QUOTE_ROUTE); } else { // If the "Review Swap" button was clicked while quotes are being fetched, go to the Loading Quotes page. await dispatch(setBackgroundSwapRouteState('loading')); history.push(LOADING_QUOTES_ROUTE); } }} submitText={t('swapReviewSwap')} disabled={isReviewSwapButtonDisabled} hideCancel showTermsOfService /> ); } BuildQuote.propTypes = { ethBalance: PropTypes.string, selectedAccountAddress: PropTypes.string, shuffledTokensList: PropTypes.array, };