|
|
|
import React, { useContext, useEffect, useState, useCallback } from 'react';
|
|
|
|
import PropTypes from 'prop-types';
|
|
|
|
import { useDispatch, useSelector } from 'react-redux';
|
|
|
|
import classnames from 'classnames';
|
|
|
|
import { uniqBy } from 'lodash';
|
|
|
|
import { useHistory } from 'react-router-dom';
|
|
|
|
import { MetaMetricsContext } from '../../../contexts/metametrics.new';
|
|
|
|
import { useTokensToSearch } 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 } from '../../../ducks/metamask/metamask';
|
|
|
|
import InfoTooltip from '../../../components/ui/info-tooltip';
|
|
|
|
import ActionableMessage from '../actionable-message';
|
|
|
|
|
|
|
|
import {
|
|
|
|
fetchQuotesAndSetQuoteState,
|
|
|
|
setSwapsFromToken,
|
|
|
|
setSwapToToken,
|
|
|
|
getFromToken,
|
|
|
|
getToToken,
|
|
|
|
getBalanceError,
|
|
|
|
getTopAssets,
|
|
|
|
getFetchParams,
|
|
|
|
} from '../../../ducks/swaps/swaps';
|
|
|
|
import { getSwapsEthToken } from '../../../selectors';
|
|
|
|
import {
|
|
|
|
getValueFromWeiHex,
|
|
|
|
hexToDecimal,
|
|
|
|
} from '../../../helpers/utils/conversions.util';
|
|
|
|
import { calcTokenAmount } from '../../../helpers/utils/token-util';
|
|
|
|
import { usePrevious } from '../../../hooks/usePrevious';
|
|
|
|
import { useTokenTracker } from '../../../hooks/useTokenTracker';
|
|
|
|
import { useTokenFiatAmount } from '../../../hooks/useTokenFiatAmount';
|
|
|
|
import { useEthFiatAmount } from '../../../hooks/useEthFiatAmount';
|
|
|
|
|
|
|
|
import { ETH_SWAPS_TOKEN_OBJECT } from '../../../helpers/constants/swaps';
|
|
|
|
|
|
|
|
import { resetSwapsPostFetchState, removeToken } from '../../../store/actions';
|
|
|
|
import { fetchTokenPrice, fetchTokenBalance } 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;
|
|
|
|
|
|
|
|
export default function BuildQuote({
|
|
|
|
inputValue,
|
|
|
|
onInputChange,
|
|
|
|
ethBalance,
|
|
|
|
setMaxSlippage,
|
|
|
|
maxSlippage,
|
|
|
|
selectedAccountAddress,
|
|
|
|
}) {
|
|
|
|
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 balanceError = useSelector(getBalanceError);
|
|
|
|
const fetchParams = useSelector(getFetchParams);
|
|
|
|
const { sourceTokenInfo = {}, destinationTokenInfo = {} } =
|
|
|
|
fetchParams?.metaData || {};
|
|
|
|
const tokens = useSelector(getTokens);
|
|
|
|
const topAssets = useSelector(getTopAssets);
|
|
|
|
const fromToken = useSelector(getFromToken);
|
|
|
|
const toToken = useSelector(getToToken) || destinationTokenInfo;
|
|
|
|
const swapsEthToken = useSelector(getSwapsEthToken);
|
|
|
|
const fetchParamsFromToken =
|
|
|
|
sourceTokenInfo?.symbol === 'ETH' ? swapsEthToken : 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 =
|
|
|
|
fromToken?.symbol !== 'ETH' && fromToken?.balance ? [fromToken] : [];
|
|
|
|
const usersTokens = uniqBy(
|
|
|
|
[...tokensWithBalances, ...tokens, ...fromTokenArray],
|
|
|
|
'address',
|
|
|
|
);
|
|
|
|
const memoizedUsersTokens = useEqualityCheck(usersTokens);
|
|
|
|
|
|
|
|
const selectedFromToken = useTokensToSearch({
|
|
|
|
providedTokens:
|
|
|
|
fromToken || fetchParamsFromToken
|
|
|
|
? [fromToken || fetchParamsFromToken]
|
|
|
|
: [],
|
|
|
|
usersTokens: memoizedUsersTokens,
|
|
|
|
onlyEth: (fromToken || fetchParamsFromToken)?.symbol === 'ETH',
|
|
|
|
singleToken: true,
|
|
|
|
})[0];
|
|
|
|
|
|
|
|
const tokensToSearch = useTokensToSearch({
|
|
|
|
usersTokens: memoizedUsersTokens,
|
|
|
|
topTokens: topAssets,
|
|
|
|
});
|
|
|
|
const selectedToToken =
|
|
|
|
tokensToSearch.find(({ address }) => address === toToken?.address) ||
|
|
|
|
toToken;
|
|
|
|
const toTokenIsNotEth =
|
|
|
|
selectedToToken?.address &&
|
|
|
|
selectedToToken?.address !== ETH_SWAPS_TOKEN_OBJECT.address;
|
|
|
|
const occurances = Number(selectedToToken?.occurances || 0);
|
|
|
|
const {
|
|
|
|
address: fromTokenAddress,
|
|
|
|
symbol: fromTokenSymbol,
|
|
|
|
string: fromTokenString,
|
|
|
|
decimals: fromTokenDecimals,
|
|
|
|
balance: rawFromTokenBalance,
|
|
|
|
} = selectedFromToken || {};
|
|
|
|
|
|
|
|
const fromTokenBalance =
|
|
|
|
rawFromTokenBalance &&
|
|
|
|
calcTokenAmount(rawFromTokenBalance, fromTokenDecimals).toString(10);
|
|
|
|
|
|
|
|
const prevFromTokenBalance = usePrevious(fromTokenBalance);
|
|
|
|
|
|
|
|
const swapFromTokenFiatValue = useTokenFiatAmount(
|
|
|
|
fromTokenAddress,
|
|
|
|
inputValue || 0,
|
|
|
|
fromTokenSymbol,
|
|
|
|
{
|
|
|
|
showFiat: true,
|
|
|
|
},
|
|
|
|
true,
|
|
|
|
);
|
|
|
|
const swapFromEthFiatValue = useEthFiatAmount(
|
|
|
|
inputValue || 0,
|
|
|
|
{ showFiat: true },
|
|
|
|
true,
|
|
|
|
);
|
|
|
|
const swapFromFiatValue =
|
|
|
|
fromTokenSymbol === 'ETH' ? swapFromEthFiatValue : swapFromTokenFiatValue;
|
|
|
|
|
|
|
|
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) => 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 ? inputValue : '',
|
|
|
|
token.string,
|
|
|
|
token.decimals,
|
|
|
|
);
|
|
|
|
};
|
|
|
|
|
|
|
|
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) => item.address === fromTokenAddress,
|
|
|
|
[fromTokenAddress],
|
|
|
|
);
|
|
|
|
|
|
|
|
const tokensWithBalancesFromToken = tokensWithBalances.find(
|
|
|
|
(token) => token.address === fromToken?.address,
|
|
|
|
);
|
|
|
|
const previousTokensWithBalancesFromToken = usePrevious(
|
|
|
|
tokensWithBalancesFromToken,
|
|
|
|
);
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
const notEth =
|
|
|
|
tokensWithBalancesFromToken?.address !== ETH_SWAPS_TOKEN_OBJECT.address;
|
|
|
|
const addressesAreTheSame =
|
|
|
|
tokensWithBalancesFromToken?.address ===
|
|
|
|
previousTokensWithBalancesFromToken?.address;
|
|
|
|
const balanceHasChanged =
|
|
|
|
tokensWithBalancesFromToken?.balance !==
|
|
|
|
previousTokensWithBalancesFromToken?.balance;
|
|
|
|
if (notEth && addressesAreTheSame && balanceHasChanged) {
|
|
|
|
dispatch(
|
|
|
|
setSwapsFromToken({
|
|
|
|
...fromToken,
|
|
|
|
balance: tokensWithBalancesFromToken?.balance,
|
|
|
|
string: tokensWithBalancesFromToken?.string,
|
|
|
|
}),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}, [
|
|
|
|
dispatch,
|
|
|
|
tokensWithBalancesFromToken,
|
|
|
|
previousTokensWithBalancesFromToken,
|
|
|
|
fromToken,
|
|
|
|
]);
|
|
|
|
|
|
|
|
// If the eth balance changes while on build quote, we update the selected from token
|
|
|
|
useEffect(() => {
|
|
|
|
if (
|
|
|
|
fromToken?.address === ETH_SWAPS_TOKEN_OBJECT.address &&
|
|
|
|
fromToken?.balance !== hexToDecimal(ethBalance)
|
|
|
|
) {
|
|
|
|
dispatch(
|
|
|
|
setSwapsFromToken({
|
|
|
|
...fromToken,
|
|
|
|
balance: hexToDecimal(ethBalance),
|
|
|
|
string: getValueFromWeiHex({
|
|
|
|
value: ethBalance,
|
|
|
|
numberOfDecimals: 4,
|
|
|
|
toDenomination: 'ETH',
|
|
|
|
}),
|
|
|
|
}),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}, [dispatch, fromToken, ethBalance]);
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
if (prevFromTokenBalance !== fromTokenBalance) {
|
|
|
|
onInputChange(inputValue, fromTokenBalance);
|
|
|
|
}
|
|
|
|
}, [onInputChange, prevFromTokenBalance, inputValue, fromTokenBalance]);
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
dispatch(resetSwapsPostFetchState());
|
|
|
|
}, [dispatch]);
|
|
|
|
|
|
|
|
return (
|
|
|
|
<div className="build-quote">
|
|
|
|
<div className="build-quote__content">
|
|
|
|
<div className="build-quote__dropdown-input-pair-header">
|
|
|
|
<div className="build-quote__input-label">{t('swapSwapFrom')}</div>
|
|
|
|
{fromTokenSymbol !== 'ETH' && (
|
|
|
|
<div
|
|
|
|
className="build-quote__max-button"
|
|
|
|
onClick={() =>
|
|
|
|
onInputChange(fromTokenBalance || '0', fromTokenBalance)
|
|
|
|
}
|
|
|
|
>
|
|
|
|
{t('max')}
|
|
|
|
</div>
|
|
|
|
)}
|
|
|
|
</div>
|
|
|
|
<DropdownInputPair
|
|
|
|
onSelect={onFromSelect}
|
|
|
|
itemsToSearch={tokensToSearch}
|
|
|
|
onInputChange={(value) => {
|
|
|
|
onInputChange(value, fromTokenBalance);
|
|
|
|
}}
|
|
|
|
inputValue={inputValue}
|
|
|
|
leftValue={inputValue && swapFromFiatValue}
|
|
|
|
selectedItem={selectedFromToken}
|
|
|
|
maxListItems={30}
|
|
|
|
loading={
|
|
|
|
loading &&
|
|
|
|
(!tokensToSearch?.length ||
|
|
|
|
!topAssets ||
|
|
|
|
!Object.keys(topAssets).length)
|
|
|
|
}
|
|
|
|
selectPlaceHolderText={t('swapSelect')}
|
|
|
|
hideItemIf={(item) => item.address === selectedToToken?.address}
|
|
|
|
listContainerClassName="build-quote__open-dropdown"
|
|
|
|
autoFocus
|
|
|
|
/>
|
|
|
|
<div
|
|
|
|
className={classnames('build-quote__balance-message', {
|
|
|
|
'build-quote__balance-message--error': balanceError,
|
|
|
|
})}
|
|
|
|
>
|
|
|
|
{!balanceError &&
|
|
|
|
fromTokenSymbol &&
|
|
|
|
t('swapYourTokenBalance', [
|
|
|
|
fromTokenString || '0',
|
|
|
|
fromTokenSymbol,
|
|
|
|
])}
|
|
|
|
{balanceError && fromTokenSymbol && (
|
|
|
|
<div className="build-quite__insufficient-funds">
|
|
|
|
<div className="build-quite__insufficient-funds-first">
|
|
|
|
{t('swapsNotEnoughForTx', [fromTokenSymbol])}
|
|
|
|
</div>
|
|
|
|
<div className="build-quite__insufficient-funds-second">
|
|
|
|
{t('swapYourTokenBalance', [
|
|
|
|
fromTokenString || '0',
|
|
|
|
fromTokenSymbol,
|
|
|
|
])}
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
)}
|
|
|
|
</div>
|
|
|
|
<div className="build-quote__swap-arrows-row">
|
|
|
|
<button
|
|
|
|
className="build-quote__swap-arrows"
|
|
|
|
onClick={() => {
|
|
|
|
onToSelect(selectedFromToken);
|
|
|
|
onFromSelect(selectedToToken);
|
|
|
|
}}
|
|
|
|
>
|
|
|
|
<img
|
|
|
|
src="/images/icons/swap2.svg"
|
|
|
|
alt={t('swapSwapSwitch')}
|
|
|
|
width="12"
|
|
|
|
height="16"
|
|
|
|
/>
|
|
|
|
</button>
|
|
|
|
</div>
|
|
|
|
<div className="build-quote__dropdown-swap-to-header">
|
|
|
|
<div className="build-quote__input-label">{t('swapSwapTo')}</div>
|
|
|
|
</div>
|
|
|
|
<div className="dropdown-input-pair dropdown-input-pair__to">
|
|
|
|
<DropdownSearchList
|
|
|
|
startingItem={selectedToToken}
|
|
|
|
itemsToSearch={tokensToSearch}
|
|
|
|
searchPlaceholderText={t('swapSearchForAToken')}
|
|
|
|
fuseSearchKeys={fuseSearchKeys}
|
|
|
|
selectPlaceHolderText={t('swapSelectAToken')}
|
|
|
|
maxListItems={30}
|
|
|
|
onSelect={onToSelect}
|
|
|
|
loading={
|
|
|
|
loading &&
|
|
|
|
(!tokensToSearch?.length ||
|
|
|
|
!topAssets ||
|
|
|
|
!Object.keys(topAssets).length)
|
|
|
|
}
|
|
|
|
externallySelectedItem={selectedToToken}
|
|
|
|
hideItemIf={hideDropdownItemIf}
|
|
|
|
listContainerClassName="build-quote__open-to-dropdown"
|
|
|
|
hideRightLabels
|
|
|
|
defaultToAll
|
|
|
|
/>
|
|
|
|
</div>
|
|
|
|
{toTokenIsNotEth &&
|
|
|
|
(occurances < 2 ? (
|
|
|
|
<ActionableMessage
|
|
|
|
message={
|
|
|
|
<div className="build-quote__token-verification-warning-message">
|
|
|
|
<div className="build-quote__bold">
|
|
|
|
{occurances === 1
|
|
|
|
? t('swapTokenVerificationOnlyOneSource')
|
|
|
|
: t('swapTokenVerificationNoSource')}
|
|
|
|
</div>
|
|
|
|
<div>
|
|
|
|
{t('verifyThisTokenOn', [
|
|
|
|
<a
|
|
|
|
className="build-quote__token-etherscan-link build-quote__underline"
|
|
|
|
key="build-quote-etherscan-link"
|
|
|
|
href={`https://etherscan.io/token/${selectedToToken.address}`}
|
|
|
|
target="_blank"
|
|
|
|
rel="noopener noreferrer"
|
|
|
|
>
|
|
|
|
{t('etherscan')}
|
|
|
|
</a>,
|
|
|
|
])}
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
}
|
|
|
|
primaryAction={
|
|
|
|
verificationClicked
|
|
|
|
? null
|
|
|
|
: {
|
|
|
|
label: t('continue'),
|
|
|
|
onClick: () => setVerificationClicked(true),
|
|
|
|
}
|
|
|
|
}
|
|
|
|
type="warning"
|
|
|
|
withRightButton
|
|
|
|
infoTooltipText={t('swapVerifyTokenExplanation')}
|
|
|
|
/>
|
|
|
|
) : (
|
|
|
|
<div className="build-quote__token-message">
|
|
|
|
<span
|
|
|
|
className="build-quote__bold"
|
|
|
|
key="token-verification-bold-text"
|
|
|
|
>
|
|
|
|
{t('swapTokenVerificationSources', [occurances])}
|
|
|
|
</span>
|
|
|
|
{t('swapTokenVerificationMessage', [
|
|
|
|
<a
|
|
|
|
className="build-quote__token-etherscan-link"
|
|
|
|
key="build-quote-etherscan-link"
|
|
|
|
href={`https://etherscan.io/token/${selectedToToken.address}`}
|
|
|
|
target="_blank"
|
|
|
|
rel="noopener noreferrer"
|
|
|
|
>
|
|
|
|
{t('etherscan')}
|
|
|
|
</a>,
|
|
|
|
])}
|
|
|
|
<InfoTooltip
|
|
|
|
position="top"
|
|
|
|
contentText={t('swapVerifyTokenExplanation')}
|
|
|
|
containerClassName="build-quote__token-tooltip-container"
|
|
|
|
key="token-verification-info-tooltip"
|
|
|
|
/>
|
|
|
|
</div>
|
|
|
|
))}
|
|
|
|
<div className="build-quote__slippage-buttons-container">
|
|
|
|
<SlippageButtons
|
|
|
|
onSelect={(newSlippage) => {
|
|
|
|
setMaxSlippage(newSlippage);
|
|
|
|
}}
|
|
|
|
maxAllowedSlippage={MAX_ALLOWED_SLIPPAGE}
|
|
|
|
/>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<SwapsFooter
|
|
|
|
onSubmit={() => {
|
|
|
|
dispatch(
|
|
|
|
fetchQuotesAndSetQuoteState(
|
|
|
|
history,
|
|
|
|
inputValue,
|
|
|
|
maxSlippage,
|
|
|
|
metaMetricsEvent,
|
|
|
|
),
|
|
|
|
);
|
|
|
|
}}
|
|
|
|
submitText={t('swapReviewSwap')}
|
|
|
|
disabled={
|
|
|
|
!Number(inputValue) ||
|
|
|
|
!selectedToToken?.address ||
|
|
|
|
Number(maxSlippage) === 0 ||
|
|
|
|
Number(maxSlippage) > MAX_ALLOWED_SLIPPAGE ||
|
|
|
|
(toTokenIsNotEth && occurances < 2 && !verificationClicked)
|
|
|
|
}
|
|
|
|
hideCancel
|
|
|
|
showTermsOfService
|
|
|
|
/>
|
|
|
|
</div>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
BuildQuote.propTypes = {
|
|
|
|
maxSlippage: PropTypes.number,
|
|
|
|
inputValue: PropTypes.string,
|
|
|
|
onInputChange: PropTypes.func,
|
|
|
|
ethBalance: PropTypes.string,
|
|
|
|
setMaxSlippage: PropTypes.func,
|
|
|
|
selectedAccountAddress: PropTypes.string,
|
|
|
|
};
|