mirror of
https://github.com/kremalicious/metamask-extension.git
synced 2024-11-23 02:10:12 +01:00
1099 lines
36 KiB
JavaScript
1099 lines
36 KiB
JavaScript
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 { uniqBy, isEqual } from 'lodash';
|
|
import { useHistory } from 'react-router-dom';
|
|
import { getTokenTrackerLink } from '@metamask/etherscan-link';
|
|
import classnames from 'classnames';
|
|
|
|
import { MetaMetricsContext } from '../../../contexts/metametrics';
|
|
import {
|
|
useTokensToSearch,
|
|
getRenderableTokenData,
|
|
} from '../../../hooks/useTokensToSearch';
|
|
import { useEqualityCheck } from '../../../hooks/useEqualityCheck';
|
|
import { I18nContext } from '../../../contexts/i18n';
|
|
import { getTokens, getConversionRate } from '../../../ducks/metamask/metamask';
|
|
import Box from '../../../components/ui/box';
|
|
import {
|
|
DISPLAY,
|
|
TextColor,
|
|
JustifyContent,
|
|
AlignItems,
|
|
SEVERITIES,
|
|
Size,
|
|
TextVariant,
|
|
BLOCK_SIZES,
|
|
} from '../../../helpers/constants/design-system';
|
|
import {
|
|
fetchQuotesAndSetQuoteState,
|
|
setSwapsFromToken,
|
|
setSwapToToken,
|
|
getFromToken,
|
|
getToToken,
|
|
getBalanceError,
|
|
getTopAssets,
|
|
getFetchParams,
|
|
getQuotes,
|
|
setBalanceError,
|
|
setFromTokenInputValue,
|
|
setFromTokenError,
|
|
setMaxSlippage,
|
|
setReviewSwapClickedTimestamp,
|
|
getSmartTransactionsOptInStatus,
|
|
getSmartTransactionsEnabled,
|
|
getCurrentSmartTransactionsEnabled,
|
|
getFromTokenInputValue,
|
|
getFromTokenError,
|
|
getMaxSlippage,
|
|
getIsFeatureFlagLoaded,
|
|
getCurrentSmartTransactionsError,
|
|
getFetchingQuotes,
|
|
getSwapsErrorKey,
|
|
getAggregatorMetadata,
|
|
getTransactionSettingsOpened,
|
|
setTransactionSettingsOpened,
|
|
} from '../../../ducks/swaps/swaps';
|
|
import {
|
|
getSwapsDefaultToken,
|
|
getTokenExchangeRates,
|
|
getCurrentCurrency,
|
|
getCurrentChainId,
|
|
getRpcPrefsForCurrentProvider,
|
|
getTokenList,
|
|
isHardwareWallet,
|
|
getHardwareWalletType,
|
|
} from '../../../selectors';
|
|
import {
|
|
getValueFromWeiHex,
|
|
hexToDecimal,
|
|
} from '../../../../shared/modules/conversion.utils';
|
|
import { getURLHostName } 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 {
|
|
MetaMetricsEventCategory,
|
|
MetaMetricsEventLinkType,
|
|
MetaMetricsEventName,
|
|
} from '../../../../shared/constants/metametrics';
|
|
import {
|
|
SWAPS_CHAINID_DEFAULT_BLOCK_EXPLORER_URL_MAP,
|
|
TokenBucketPriority,
|
|
ERROR_FETCHING_QUOTES,
|
|
QUOTES_NOT_AVAILABLE_ERROR,
|
|
QUOTES_EXPIRED_ERROR,
|
|
} from '../../../../shared/constants/swaps';
|
|
import {
|
|
resetSwapsPostFetchState,
|
|
clearSwapsQuotes,
|
|
stopPollingForQuotes,
|
|
setSmartTransactionsOptInStatus,
|
|
clearSmartTransactionFees,
|
|
setSwapsErrorKey,
|
|
setBackgroundSwapRouteState,
|
|
} from '../../../store/actions';
|
|
import {
|
|
countDecimals,
|
|
fetchTokenPrice,
|
|
formatSwapsValueForDisplay,
|
|
getClassNameForCharLength,
|
|
} from '../swaps.util';
|
|
import { fetchTokenBalance } from '../../../../shared/lib/token-util.ts';
|
|
import { isEqualCaseInsensitive } from '../../../../shared/modules/string-utils';
|
|
import { calcTokenAmount } from '../../../../shared/lib/transactions-controller-utils';
|
|
import { shouldEnableDirectWrapping } from '../../../../shared/lib/swaps-utils';
|
|
import {
|
|
Icon,
|
|
IconName,
|
|
IconSize,
|
|
TextField,
|
|
ButtonLink,
|
|
Text,
|
|
Modal,
|
|
ModalOverlay,
|
|
ModalContent,
|
|
ModalHeader,
|
|
} from '../../../components/component-library';
|
|
import { BannerAlert } from '../../../components/component-library/banner-alert';
|
|
import { SWAPS_NOTIFICATION_ROUTE } from '../../../helpers/constants/routes';
|
|
import ImportToken from '../import-token';
|
|
import TransactionSettings from '../transaction-settings/transaction-settings';
|
|
import SwapsBannerAlert from '../swaps-banner-alert/swaps-banner-alert';
|
|
import SwapsFooter from '../swaps-footer';
|
|
import SelectedToken from '../selected-token/selected-token';
|
|
import ListWithSearch from '../list-with-search/list-with-search';
|
|
import SmartTransactionsPopover from './smart-transactions-popover';
|
|
import QuotesLoadingAnimation from './quotes-loading-animation';
|
|
import ReviewQuote from './review-quote';
|
|
|
|
const MAX_ALLOWED_SLIPPAGE = 15;
|
|
|
|
let timeoutIdForQuotesPrefetching;
|
|
|
|
export default function PrepareSwapPage({
|
|
ethBalance,
|
|
selectedAccountAddress,
|
|
shuffledTokensList,
|
|
}) {
|
|
const t = useContext(I18nContext);
|
|
const dispatch = useDispatch();
|
|
const history = useHistory();
|
|
const trackEvent = useContext(MetaMetricsContext);
|
|
|
|
const [fetchedTokenExchangeRate, setFetchedTokenExchangeRate] =
|
|
useState(undefined);
|
|
const [verificationClicked, setVerificationClicked] = useState(false);
|
|
const [receiveToAmount, setReceiveToAmount] = useState();
|
|
const [isSwapToOpen, setIsSwapToOpen] = useState(false);
|
|
const onSwapToOpen = () => setIsSwapToOpen(true);
|
|
const onSwapToClose = () => setIsSwapToOpen(false);
|
|
const [isSwapFromOpen, setIsSwapFromOpen] = useState(false);
|
|
const onSwapFromOpen = () => setIsSwapFromOpen(true);
|
|
const onSwapFromClose = () => setIsSwapFromOpen(false);
|
|
const [isImportTokenModalOpen, setIsImportTokenModalOpen] = useState(false);
|
|
const [tokenForImport, setTokenForImport] = useState(null);
|
|
const [swapFromSearchQuery, setSwapFromSearchQuery] = useState('');
|
|
const [swapToSearchQuery, setSwapToSearchQuery] = useState('');
|
|
const [quoteCount, updateQuoteCount] = useState(0);
|
|
const [prefetchingQuotes, setPrefetchingQuotes] = useState(false);
|
|
const [rotateSwitchTokens, setRotateSwitchTokens] = 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, isEqual);
|
|
const fromToken = useSelector(getFromToken, isEqual);
|
|
const fromTokenInputValue = useSelector(getFromTokenInputValue);
|
|
const fromTokenError = useSelector(getFromTokenError);
|
|
const maxSlippage = useSelector(getMaxSlippage);
|
|
const toToken = useSelector(getToToken, isEqual) || destinationTokenInfo;
|
|
const defaultSwapsToken = useSelector(getSwapsDefaultToken, isEqual);
|
|
const chainId = useSelector(getCurrentChainId);
|
|
const rpcPrefs = useSelector(getRpcPrefsForCurrentProvider, shallowEqual);
|
|
const tokenList = useSelector(getTokenList, isEqual);
|
|
const quotes = useSelector(getQuotes, isEqual);
|
|
const numberOfQuotes = Object.keys(quotes).length;
|
|
const areQuotesPresent = numberOfQuotes > 0;
|
|
const swapsErrorKey = useSelector(getSwapsErrorKey);
|
|
const aggregatorMetadata = useSelector(getAggregatorMetadata, shallowEqual);
|
|
const transactionSettingsOpened = useSelector(
|
|
getTransactionSettingsOpened,
|
|
shallowEqual,
|
|
);
|
|
const numberOfAggregators = aggregatorMetadata
|
|
? Object.keys(aggregatorMetadata).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 isSmartTransaction =
|
|
currentSmartTransactionsEnabled && smartTransactionsOptInStatus;
|
|
const smartTransactionsOptInPopoverDisplayed =
|
|
smartTransactionsOptInStatus !== undefined;
|
|
const currentSmartTransactionsError = useSelector(
|
|
getCurrentSmartTransactionsError,
|
|
);
|
|
const currentCurrency = useSelector(getCurrentCurrency);
|
|
const fetchingQuotes = useSelector(getFetchingQuotes);
|
|
const loadingComplete = !fetchingQuotes && areQuotesPresent;
|
|
|
|
const showSmartTransactionsOptInPopover =
|
|
smartTransactionsEnabled && !smartTransactionsOptInPopoverDisplayed;
|
|
|
|
const onCloseSmartTransactionsOptInPopover = (e) => {
|
|
e?.preventDefault();
|
|
setSmartTransactionsOptInStatus(false, smartTransactionsOptInStatus);
|
|
};
|
|
|
|
const onEnableSmartTransactionsClick = () =>
|
|
setSmartTransactionsOptInStatus(true, smartTransactionsOptInStatus);
|
|
|
|
const fetchParamsFromToken = isSwapsDefaultTokenSymbol(
|
|
sourceTokenInfo?.symbol,
|
|
chainId,
|
|
)
|
|
? defaultSwapsToken
|
|
: sourceTokenInfo;
|
|
|
|
const { 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,
|
|
);
|
|
|
|
const tokensToSearchSwapFrom = useTokensToSearch({
|
|
usersTokens: memoizedUsersTokens,
|
|
topTokens: topAssets,
|
|
shuffledTokensList,
|
|
tokenBucketPriority: TokenBucketPriority.owned,
|
|
});
|
|
const tokensToSearchSwapTo = useTokensToSearch({
|
|
usersTokens: memoizedUsersTokens,
|
|
topTokens: topAssets,
|
|
shuffledTokensList,
|
|
tokenBucketPriority: TokenBucketPriority.top,
|
|
});
|
|
const selectedToToken =
|
|
tokensToSearchSwapFrom.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],
|
|
);
|
|
|
|
useEffect(() => {
|
|
let timeoutLength;
|
|
|
|
if (!prefetchingQuotes) {
|
|
updateQuoteCount(0);
|
|
return;
|
|
}
|
|
|
|
const onQuotesLoadingDone = async () => {
|
|
await dispatch(setBackgroundSwapRouteState(''));
|
|
setPrefetchingQuotes(false);
|
|
if (
|
|
swapsErrorKey === ERROR_FETCHING_QUOTES ||
|
|
swapsErrorKey === QUOTES_NOT_AVAILABLE_ERROR
|
|
) {
|
|
dispatch(setSwapsErrorKey(QUOTES_NOT_AVAILABLE_ERROR));
|
|
}
|
|
};
|
|
|
|
// The below logic simulates a sequential loading of the aggregator quotes, even though we are fetching them all with a single call.
|
|
// This is to give the user a sense of progress. The callback passed to `setTimeout` updates the quoteCount and therefore causes
|
|
// a new logo to be shown, the fox to look at that logo, the logo bar and aggregator name to update.
|
|
|
|
if (loadingComplete) {
|
|
// If loading is complete, but the quoteCount is not, we quickly display the remaining logos/names/fox looks. 0.2s each
|
|
timeoutLength = 20;
|
|
} else {
|
|
// If loading is not complete, we display remaining logos/names/fox looks at random intervals between 0.5s and 2s, to simulate the
|
|
// sort of loading a user would experience in most async scenarios
|
|
timeoutLength = 500 + Math.floor(Math.random() * 1500);
|
|
}
|
|
const quoteCountTimeout = setTimeout(() => {
|
|
if (quoteCount < numberOfAggregators) {
|
|
updateQuoteCount(quoteCount + 1);
|
|
} else if (quoteCount === numberOfAggregators && loadingComplete) {
|
|
onQuotesLoadingDone();
|
|
}
|
|
}, timeoutLength);
|
|
|
|
// eslint-disable-next-line consistent-return
|
|
return function cleanup() {
|
|
clearTimeout(quoteCountTimeout);
|
|
};
|
|
}, [
|
|
fetchingQuotes,
|
|
quoteCount,
|
|
loadingComplete,
|
|
numberOfQuotes,
|
|
dispatch,
|
|
history,
|
|
swapsErrorKey,
|
|
numberOfAggregators,
|
|
prefetchingQuotes,
|
|
]);
|
|
|
|
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,
|
|
global.ethereumProvider,
|
|
).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(fromTokenInputValue, token.string, token.decimals);
|
|
};
|
|
|
|
const blockExplorerTokenLink = getTokenTrackerLink(
|
|
selectedToToken.address,
|
|
chainId,
|
|
null, // no networkId
|
|
null, // no holderAddress
|
|
{
|
|
blockExplorerUrl:
|
|
SWAPS_CHAINID_DEFAULT_BLOCK_EXPLORER_URL_MAP[chainId] ?? null,
|
|
},
|
|
);
|
|
|
|
const blockExplorerLabel = rpcPrefs.blockExplorerUrl
|
|
? getURLHostName(blockExplorerTokenLink)
|
|
: t('etherscan');
|
|
|
|
const onToSelect = useCallback(
|
|
(token) => {
|
|
dispatch(setSwapToToken(token));
|
|
setVerificationClicked(false);
|
|
},
|
|
[dispatch],
|
|
);
|
|
|
|
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 (!fromToken?.symbol && !fetchParamsFromToken?.symbol) {
|
|
dispatch(setSwapsFromToken(defaultSwapsToken));
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (prevFromTokenBalance !== fromTokenBalance) {
|
|
onInputChange(fromTokenInputValue, fromTokenBalance);
|
|
}
|
|
}, [
|
|
onInputChange,
|
|
prevFromTokenBalance,
|
|
fromTokenInputValue,
|
|
fromTokenBalance,
|
|
]);
|
|
|
|
const trackPrepareSwapPageLoadedEvent = useCallback(() => {
|
|
trackEvent({
|
|
event: 'Prepare Swap Page Loaded',
|
|
category: MetaMetricsEventCategory.Swaps,
|
|
sensitiveProperties: {
|
|
is_hardware_wallet: hardwareWalletUsed,
|
|
hardware_wallet_type: hardwareWalletType,
|
|
stx_enabled: smartTransactionsEnabled,
|
|
current_stx_enabled: currentSmartTransactionsEnabled,
|
|
stx_user_opt_in: smartTransactionsOptInStatus,
|
|
},
|
|
});
|
|
}, [
|
|
trackEvent,
|
|
hardwareWalletUsed,
|
|
hardwareWalletType,
|
|
smartTransactionsEnabled,
|
|
currentSmartTransactionsEnabled,
|
|
smartTransactionsOptInStatus,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
dispatch(resetSwapsPostFetchState());
|
|
dispatch(setReviewSwapClickedTimestamp());
|
|
trackPrepareSwapPageLoadedEvent();
|
|
}, [dispatch, trackPrepareSwapPageLoadedEvent]);
|
|
|
|
const BlockExplorerLink = () => {
|
|
return (
|
|
<a
|
|
className="prepare-swap-page__token-etherscan-link"
|
|
key="prepare-swap-page-etherscan-link"
|
|
onClick={() => {
|
|
/* istanbul ignore next */
|
|
trackEvent({
|
|
event: MetaMetricsEventName.ExternalLinkClicked,
|
|
category: MetaMetricsEventCategory.Swaps,
|
|
properties: {
|
|
link_type: MetaMetricsEventLinkType.TokenTracker,
|
|
location: 'Swaps Confirmation',
|
|
url_domain: getURLHostName(blockExplorerTokenLink),
|
|
},
|
|
});
|
|
global.platform.openTab({
|
|
url: blockExplorerTokenLink,
|
|
});
|
|
}}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
>
|
|
{blockExplorerLabel}
|
|
</a>
|
|
);
|
|
};
|
|
|
|
const swapYourTokenBalance = `${t('balance')}: ${fromTokenString || '0'}`;
|
|
|
|
const isDirectWrappingEnabled = shouldEnableDirectWrapping(
|
|
chainId,
|
|
fromTokenAddress,
|
|
selectedToToken.address,
|
|
);
|
|
const isReviewSwapButtonDisabled =
|
|
fromTokenError ||
|
|
!isFeatureFlagLoaded ||
|
|
!Number(fromTokenInputValue) ||
|
|
!selectedToToken?.address ||
|
|
!fromTokenAddress ||
|
|
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 () => {
|
|
setPrefetchingQuotes(true);
|
|
const pageRedirectionDisabled = true;
|
|
await dispatch(
|
|
fetchQuotesAndSetQuoteState(
|
|
history,
|
|
fromTokenInputValue,
|
|
maxSlippage,
|
|
trackEvent,
|
|
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) {
|
|
if (isSmartTransaction) {
|
|
clearSmartTransactionFees(); // Clean up STX fees eery time there is a form change.
|
|
}
|
|
// Only do quotes prefetching if the Review swap button is enabled.
|
|
prefetchQuotesWithoutRedirecting();
|
|
}
|
|
}, 1000);
|
|
return () => clearTimeout(timeoutIdForQuotesPrefetching);
|
|
}, [
|
|
dispatch,
|
|
history,
|
|
maxSlippage,
|
|
trackEvent,
|
|
isReviewSwapButtonDisabled,
|
|
fromTokenInputValue,
|
|
fromTokenAddress,
|
|
toTokenAddress,
|
|
smartTransactionsOptInStatus,
|
|
isSmartTransaction,
|
|
]);
|
|
|
|
// Set text for the main button based on different conditions.
|
|
let mainButtonText;
|
|
if (swapsErrorKey && swapsErrorKey === QUOTES_NOT_AVAILABLE_ERROR) {
|
|
mainButtonText = t('swapQuotesNotAvailableErrorTitle');
|
|
} else if (!isReviewSwapButtonDisabled) {
|
|
mainButtonText = t('swapFetchingQuotes');
|
|
} else if (!selectedToToken?.address || !fromTokenAddress) {
|
|
mainButtonText = t('swapSelectToken');
|
|
} else {
|
|
mainButtonText = t('swapEnterAmount');
|
|
}
|
|
|
|
const onTextFieldChange = (event) => {
|
|
event.stopPropagation();
|
|
// Automatically prefix value with 0. if user begins typing .
|
|
const valueToUse = event.target.value === '.' ? '0.' : event.target.value;
|
|
|
|
// Regex that validates strings with only numbers, 'x.', '.x', and 'x.x'
|
|
const regexp = /^(\.\d+|\d+(\.\d+)?|\d+\.)$/u;
|
|
// If the value is either empty or contains only numbers and '.' and only has one '.', update input to match
|
|
if (valueToUse === '' || regexp.test(valueToUse)) {
|
|
onInputChange(valueToUse, fromTokenBalance);
|
|
} else {
|
|
// otherwise, use the previously set inputValue (effectively denying the user from inputting the last char)
|
|
// or an empty string if we do not yet have an inputValue
|
|
onInputChange(fromTokenInputValue || '', fromTokenBalance);
|
|
}
|
|
};
|
|
|
|
const hideSwapToTokenIf = useCallback(
|
|
(item) => isEqualCaseInsensitive(item.address, fromTokenAddress),
|
|
[fromTokenAddress],
|
|
);
|
|
|
|
const hideSwapFromTokenIf = useCallback(
|
|
(item) => isEqualCaseInsensitive(item.address, selectedToToken?.address),
|
|
[selectedToToken?.address],
|
|
);
|
|
|
|
const showReviewQuote =
|
|
!swapsErrorKey && !isReviewSwapButtonDisabled && areQuotesPresent;
|
|
const showQuotesLoadingAnimation =
|
|
!swapsErrorKey && !isReviewSwapButtonDisabled && !areQuotesPresent;
|
|
const showNotEnoughTokenMessage =
|
|
!fromTokenError && balanceError && fromTokenSymbol;
|
|
|
|
const tokenVerifiedOn1Source = occurrences === 1;
|
|
|
|
useEffect(() => {
|
|
if (swapsErrorKey === QUOTES_EXPIRED_ERROR) {
|
|
history.push(SWAPS_NOTIFICATION_ROUTE);
|
|
}
|
|
}, [swapsErrorKey, history]);
|
|
|
|
useEffect(() => {
|
|
if (showQuotesLoadingAnimation) {
|
|
setReceiveToAmount('');
|
|
}
|
|
}, [showQuotesLoadingAnimation]);
|
|
|
|
const onOpenImportTokenModalClick = (item) => {
|
|
setTokenForImport(item);
|
|
setIsImportTokenModalOpen(true);
|
|
onSwapToClose();
|
|
setSwapToSearchQuery('');
|
|
};
|
|
|
|
/* istanbul ignore next */
|
|
const onImportTokenClick = () => {
|
|
trackEvent({
|
|
event: 'Token Imported',
|
|
category: MetaMetricsEventCategory.Swaps,
|
|
sensitiveProperties: {
|
|
symbol: tokenForImport?.symbol,
|
|
address: tokenForImport?.address,
|
|
chain_id: chainId,
|
|
is_hardware_wallet: hardwareWalletUsed,
|
|
hardware_wallet_type: hardwareWalletType,
|
|
stx_enabled: smartTransactionsEnabled,
|
|
current_stx_enabled: currentSmartTransactionsEnabled,
|
|
stx_user_opt_in: smartTransactionsOptInStatus,
|
|
},
|
|
});
|
|
// Only when a user confirms import of a token, we add it and show it in a dropdown.
|
|
onToSelect?.(tokenForImport);
|
|
setTokenForImport(null);
|
|
};
|
|
|
|
const onImportTokenCloseClick = () => {
|
|
setIsImportTokenModalOpen(false);
|
|
};
|
|
|
|
const importTokenProps = {
|
|
onImportTokenCloseClick,
|
|
onImportTokenClick,
|
|
setIsImportTokenModalOpen,
|
|
tokenForImport,
|
|
};
|
|
|
|
let receiveToAmountFormatted;
|
|
let receiveToAmountClassName;
|
|
let fromTokenAmountClassName;
|
|
|
|
// TODO: Do this only when these variables change, not on every re-render.
|
|
if (receiveToAmount && !isReviewSwapButtonDisabled) {
|
|
receiveToAmountFormatted = formatSwapsValueForDisplay(receiveToAmount);
|
|
receiveToAmountClassName = getClassNameForCharLength(
|
|
receiveToAmountFormatted,
|
|
'prepare-swap-page__receive-amount',
|
|
);
|
|
}
|
|
if (fromTokenInputValue) {
|
|
fromTokenAmountClassName = getClassNameForCharLength(
|
|
fromTokenInputValue,
|
|
'prepare-swap-page__from-token-amount',
|
|
);
|
|
}
|
|
|
|
const showMaxBalanceLink =
|
|
fromTokenSymbol &&
|
|
!isSwapsDefaultTokenSymbol(fromTokenSymbol, chainId) &&
|
|
rawFromTokenBalance > 0;
|
|
|
|
return (
|
|
<div className="prepare-swap-page">
|
|
<div className="prepare-swap-page__content">
|
|
{tokenForImport && isImportTokenModalOpen && (
|
|
<ImportToken {...importTokenProps} />
|
|
)}
|
|
<Modal
|
|
onClose={onSwapToClose}
|
|
isOpen={isSwapToOpen}
|
|
isClosedOnOutsideClick
|
|
isClosedOnEscapeKey
|
|
className="mm-modal__custom-scrollbar"
|
|
>
|
|
<ModalOverlay />
|
|
<ModalContent>
|
|
<ModalHeader onClose={onSwapToClose}>{t('swapSwapTo')}</ModalHeader>
|
|
<Box
|
|
paddingTop={10}
|
|
paddingRight={0}
|
|
paddingBottom={0}
|
|
paddingLeft={0}
|
|
display={DISPLAY.FLEX}
|
|
>
|
|
<ListWithSearch
|
|
selectedItem={selectedToToken}
|
|
itemsToSearch={tokensToSearchSwapTo}
|
|
onClickItem={(item) => {
|
|
onToSelect?.(item);
|
|
onSwapToClose();
|
|
}}
|
|
maxListItems={30}
|
|
searchQuery={swapToSearchQuery}
|
|
setSearchQuery={setSwapToSearchQuery}
|
|
hideItemIf={hideSwapToTokenIf}
|
|
shouldSearchForImports
|
|
onOpenImportTokenModalClick={onOpenImportTokenModalClick}
|
|
/>
|
|
</Box>
|
|
</ModalContent>
|
|
</Modal>
|
|
<Modal
|
|
onClose={onSwapFromClose}
|
|
isOpen={isSwapFromOpen}
|
|
isClosedOnOutsideClick
|
|
isClosedOnEscapeKey
|
|
className="mm-modal__custom-scrollbar"
|
|
>
|
|
<ModalOverlay />
|
|
<ModalContent>
|
|
<ModalHeader onClose={onSwapFromClose}>
|
|
{t('swapSwapFrom')}
|
|
</ModalHeader>
|
|
<Box
|
|
paddingTop={10}
|
|
paddingRight={0}
|
|
paddingBottom={0}
|
|
paddingLeft={0}
|
|
display={DISPLAY.FLEX}
|
|
>
|
|
<ListWithSearch
|
|
selectedItem={selectedFromToken}
|
|
itemsToSearch={tokensToSearchSwapFrom}
|
|
onClickItem={(item) => {
|
|
onFromSelect?.(item);
|
|
onSwapFromClose();
|
|
}}
|
|
maxListItems={30}
|
|
searchQuery={swapFromSearchQuery}
|
|
setSearchQuery={setSwapFromSearchQuery}
|
|
hideItemIf={hideSwapFromTokenIf}
|
|
/>
|
|
</Box>
|
|
</ModalContent>
|
|
</Modal>
|
|
{showSmartTransactionsOptInPopover && (
|
|
<SmartTransactionsPopover
|
|
onEnableSmartTransactionsClick={onEnableSmartTransactionsClick}
|
|
onCloseSmartTransactionsOptInPopover={
|
|
onCloseSmartTransactionsOptInPopover
|
|
}
|
|
/>
|
|
)}
|
|
<div className="prepare-swap-page__swap-from-content">
|
|
<Box
|
|
display={DISPLAY.FLEX}
|
|
justifyContent={JustifyContent.spaceBetween}
|
|
alignItems={AlignItems.center}
|
|
>
|
|
<SelectedToken
|
|
onClick={onSwapFromOpen}
|
|
onClose={onSwapFromClose}
|
|
selectedToken={selectedFromToken}
|
|
testId="prepare-swap-page-swap-from"
|
|
/>
|
|
<Box display={DISPLAY.FLEX} alignItems={AlignItems.center}>
|
|
<TextField
|
|
className={classnames('prepare-swap-page__from-token-amount', {
|
|
[fromTokenAmountClassName]: fromTokenAmountClassName,
|
|
})}
|
|
size={Size.SM}
|
|
placeholder="0"
|
|
onChange={onTextFieldChange}
|
|
value={fromTokenInputValue}
|
|
truncate={false}
|
|
testId="prepare-swap-page-from-token-amount"
|
|
/>
|
|
</Box>
|
|
</Box>
|
|
<Box
|
|
display={DISPLAY.FLEX}
|
|
justifyContent={JustifyContent.spaceBetween}
|
|
alignItems={AlignItems.stretch}
|
|
>
|
|
<div className="prepare-swap-page__balance-message">
|
|
{fromTokenSymbol && swapYourTokenBalance}
|
|
{showMaxBalanceLink && (
|
|
<div
|
|
className="prepare-swap-page__max-balance"
|
|
data-testid="prepare-swap-page-max-balance"
|
|
onClick={() =>
|
|
onInputChange(fromTokenBalance || '0', fromTokenBalance)
|
|
}
|
|
>
|
|
{t('max')}
|
|
</div>
|
|
)}
|
|
</div>
|
|
{fromTokenInputValue && swapFromFiatValue && (
|
|
<Box
|
|
display={DISPLAY.FLEX}
|
|
justifyContent={JustifyContent.flexEnd}
|
|
alignItems={AlignItems.flexEnd}
|
|
>
|
|
<Text
|
|
variant={TextVariant.bodySm}
|
|
color={TextColor.textAlternative}
|
|
>
|
|
{swapFromFiatValue}
|
|
</Text>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
{showNotEnoughTokenMessage && (
|
|
<Box
|
|
display={DISPLAY.FLEX}
|
|
justifyContent={JustifyContent.flexStart}
|
|
>
|
|
<Text
|
|
variant={TextVariant.bodySmBold}
|
|
color={TextColor.textAlternative}
|
|
marginTop={0}
|
|
>
|
|
{t('swapsNotEnoughToken', [fromTokenSymbol])}
|
|
</Text>
|
|
</Box>
|
|
)}
|
|
{fromTokenError && (
|
|
<Box
|
|
display={DISPLAY.FLEX}
|
|
justifyContent={JustifyContent.flexStart}
|
|
>
|
|
<Text
|
|
variant={TextVariant.bodySmBold}
|
|
color={TextColor.textAlternative}
|
|
marginTop={0}
|
|
>
|
|
{t('swapTooManyDecimalsError', [
|
|
fromTokenSymbol,
|
|
fromTokenDecimals,
|
|
])}
|
|
</Text>
|
|
</Box>
|
|
)}
|
|
<Box
|
|
display={DISPLAY.FLEX}
|
|
justifyContent={JustifyContent.center}
|
|
height={0}
|
|
>
|
|
<div
|
|
className={classnames('prepare-swap-page__switch-tokens', {
|
|
'prepare-swap-page__switch-tokens--rotate': rotateSwitchTokens,
|
|
'prepare-swap-page__switch-tokens--disabled':
|
|
showQuotesLoadingAnimation,
|
|
})}
|
|
data-testid="prepare-swap-page-switch-tokens"
|
|
onClick={() => {
|
|
// If quotes are being loaded, disable the switch button.
|
|
if (!showQuotesLoadingAnimation) {
|
|
onToSelect(selectedFromToken);
|
|
onFromSelect(selectedToToken);
|
|
setRotateSwitchTokens(!rotateSwitchTokens);
|
|
}
|
|
}}
|
|
title={t('swapSwapSwitch')}
|
|
>
|
|
<Icon name={IconName.Arrow2Down} size={IconSize.Lg} />
|
|
</div>
|
|
</Box>
|
|
</div>
|
|
<div className="prepare-swap-page__swap-to-content">
|
|
<Box
|
|
display={DISPLAY.FLEX}
|
|
justifyContent={JustifyContent.spaceBetween}
|
|
alignItems={AlignItems.center}
|
|
>
|
|
<SelectedToken
|
|
onClick={onSwapToOpen}
|
|
onClose={onSwapToClose}
|
|
selectedToken={selectedToToken}
|
|
testId="prepare-swap-page-swap-to"
|
|
/>
|
|
<Box
|
|
display={DISPLAY.FLEX}
|
|
alignItems={AlignItems.center}
|
|
marginLeft={2}
|
|
className="prepare-swap-page__receive-amount-container"
|
|
>
|
|
<Text
|
|
as="h6"
|
|
data-testid="prepare-swap-page-receive-amount"
|
|
className={classnames('prepare-swap-page__receive-amount', {
|
|
[receiveToAmountClassName]: receiveToAmountClassName,
|
|
})}
|
|
>
|
|
{receiveToAmountFormatted}
|
|
</Text>
|
|
</Box>
|
|
</Box>
|
|
</div>
|
|
{!showReviewQuote && toTokenIsNotDefault && occurrences < 2 && (
|
|
<Box display={DISPLAY.FLEX} marginTop={2}>
|
|
<BannerAlert
|
|
severity={
|
|
tokenVerifiedOn1Source ? SEVERITIES.WARNING : SEVERITIES.DANGER
|
|
}
|
|
title={
|
|
tokenVerifiedOn1Source
|
|
? t('swapTokenVerifiedOn1SourceTitle')
|
|
: t('swapTokenAddedManuallyTitle')
|
|
}
|
|
width={BLOCK_SIZES.FULL}
|
|
>
|
|
<Box>
|
|
<Text
|
|
variant={TextVariant.bodyMd}
|
|
as="h6"
|
|
data-testid="mm-banner-alert-notification-text"
|
|
>
|
|
{tokenVerifiedOn1Source
|
|
? t('swapTokenVerifiedOn1SourceDescription', [
|
|
selectedToToken?.symbol,
|
|
<BlockExplorerLink key="block-explorer-link" />,
|
|
])
|
|
: t('swapTokenAddedManuallyDescription', [
|
|
<BlockExplorerLink key="block-explorer-link" />,
|
|
])}
|
|
</Text>
|
|
{!verificationClicked && (
|
|
<ButtonLink
|
|
size={Size.INHERIT}
|
|
textProps={{
|
|
variant: TextVariant.bodyMd,
|
|
alignItems: AlignItems.flexStart,
|
|
}}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
setVerificationClicked(true);
|
|
}}
|
|
>
|
|
{t('swapContinueSwapping')}
|
|
</ButtonLink>
|
|
)}
|
|
</Box>
|
|
</BannerAlert>
|
|
</Box>
|
|
)}
|
|
{swapsErrorKey && (
|
|
<Box display={DISPLAY.FLEX} marginTop={2}>
|
|
<SwapsBannerAlert swapsErrorKey={swapsErrorKey} />
|
|
</Box>
|
|
)}
|
|
{transactionSettingsOpened &&
|
|
(smartTransactionsEnabled ||
|
|
(!smartTransactionsEnabled && !isDirectWrappingEnabled)) && (
|
|
<TransactionSettings
|
|
onSelect={(newSlippage) => {
|
|
dispatch(setMaxSlippage(newSlippage));
|
|
}}
|
|
maxAllowedSlippage={MAX_ALLOWED_SLIPPAGE}
|
|
currentSlippage={maxSlippage}
|
|
smartTransactionsEnabled={smartTransactionsEnabled}
|
|
smartTransactionsOptInStatus={smartTransactionsOptInStatus}
|
|
setSmartTransactionsOptInStatus={setSmartTransactionsOptInStatus}
|
|
currentSmartTransactionsError={currentSmartTransactionsError}
|
|
isDirectWrappingEnabled={isDirectWrappingEnabled}
|
|
onModalClose={() => {
|
|
dispatch(setTransactionSettingsOpened(false));
|
|
}}
|
|
/>
|
|
)}
|
|
{showQuotesLoadingAnimation && (
|
|
<QuotesLoadingAnimation
|
|
quoteCount={quoteCount}
|
|
numberOfAggregators={numberOfAggregators}
|
|
/>
|
|
)}
|
|
{showReviewQuote && (
|
|
<ReviewQuote setReceiveToAmount={setReceiveToAmount} />
|
|
)}
|
|
</div>
|
|
{!areQuotesPresent && (
|
|
<SwapsFooter
|
|
submitText={mainButtonText}
|
|
disabled
|
|
hideCancel
|
|
showTermsOfService
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
PrepareSwapPage.propTypes = {
|
|
ethBalance: PropTypes.string,
|
|
selectedAccountAddress: PropTypes.string,
|
|
shuffledTokensList: PropTypes.array,
|
|
};
|