import { NativeStackScreenProps } from '@react-navigation/native-stack' import BigNumber from 'bignumber.js' import React, { useEffect, useMemo, useRef, useState } from 'react' import { Trans, useTranslation } from 'react-i18next' import { TextInput as RNTextInput, StyleSheet, Text, View } from 'react-native' import { ScrollView } from 'react-native-gesture-handler' import { SafeAreaView } from 'react-native-safe-area-context' import { showError } from 'src/alert/actions' import AppAnalytics from 'src/analytics/AppAnalytics' import { SwapEvents } from 'src/analytics/Events' import { ErrorMessages } from 'src/app/ErrorMessages' import BackButton from 'src/components/BackButton' import BottomSheet, { BottomSheetModalRefType } from 'src/components/BottomSheet' import Button, { BtnSizes, BtnTypes } from 'src/components/Button' import InLineNotification, { NotificationVariant } from 'src/components/InLineNotification' import Toast from 'src/components/Toast' import TokenBottomSheet, { TokenPickerOrigin } from 'src/components/TokenBottomSheet' import TokenEnterAmount, { FETCH_UPDATED_TRANSACTIONS_DEBOUNCE_TIME_MS, useEnterAmount, } from 'src/components/TokenEnterAmount' import Touchable from 'src/components/Touchable' import CustomHeader from 'src/components/header/CustomHeader' import ArrowDown from 'src/icons/ArrowDown' import CircledIcon from 'src/icons/CircledIcon' import CrossChainIndicator from 'src/icons/CrossChainIndicator' import { getLocalCurrencyCode } from 'src/localCurrency/selectors' import { navigate } from 'src/navigator/NavigationService' import { Screens } from 'src/navigator/Screens' import { StackParamList } from 'src/navigator/types' import { useDispatch, useSelector } from 'src/redux/hooks' import EnterAmountOptions from 'src/send/EnterAmountOptions' import { NETWORK_NAMES } from 'src/shared/conts' import { getDynamicConfigParams, getFeatureGate } from 'src/statsig' import { DynamicConfigs } from 'src/statsig/constants' import { StatsigDynamicConfigs, StatsigFeatureGates } from 'src/statsig/types' import colors from 'src/styles/colors' import { typeScale } from 'src/styles/fonts' import { Spacing } from 'src/styles/styles' import variables from 'src/styles/variables' import FeeInfoBottomSheet from 'src/swap/FeeInfoBottomSheet' import SwapTransactionDetails from 'src/swap/SwapTransactionDetails' import getCrossChainFee from 'src/swap/getCrossChainFee' import { getSwapTxsAnalyticsProperties } from 'src/swap/getSwapTxsAnalyticsProperties' import { currentSwapSelector, priceImpactWarningThresholdSelector } from 'src/swap/selectors' import { swapStart } from 'src/swap/slice' import { AppFeeAmount, Field, SwapFeeAmount } from 'src/swap/types' import useFilterChips from 'src/swap/useFilterChips' import useSwapQuote, { NO_QUOTE_ERROR_MESSAGE, QuoteResult } from 'src/swap/useSwapQuote' import { useSwappableTokens } from 'src/tokens/hooks' import { feeCurrenciesSelector, feeCurrenciesWithPositiveBalancesSelector, tokensByIdSelector, } from 'src/tokens/selectors' import { TokenBalance } from 'src/tokens/slice' import { getSupportedNetworkIdsForSwap } from 'src/tokens/utils' import { NetworkId } from 'src/transactions/types' import Logger from 'src/utils/Logger' import { getFeeCurrencyAndAmounts } from 'src/viem/prepareTransactions' import { getSerializablePreparedTransactions } from 'src/viem/preparedTransactionSerialization' import networkConfig from 'src/web3/networkConfig' import { v4 as uuidv4 } from 'uuid' const TAG = 'SwapScreen' function getNetworkFee(quote: QuoteResult | null): SwapFeeAmount | undefined { const { feeCurrency, maxFeeAmount, estimatedFeeAmount } = getFeeCurrencyAndAmounts( quote?.preparedTransactions ) return feeCurrency && estimatedFeeAmount ? { token: feeCurrency, maxAmount: maxFeeAmount, amount: estimatedFeeAmount, } : undefined } type Props = NativeStackScreenProps export default function SwapScreenV2({ route }: Props) { const { t } = useTranslation() const dispatch = useDispatch() const allowCrossChainSwaps = getFeatureGate(StatsigFeatureGates.ALLOW_CROSS_CHAIN_SWAPS) const showUKCompliantVariant = getFeatureGate(StatsigFeatureGates.SHOW_UK_COMPLIANT_VARIANT) const { swappableFromTokens, swappableToTokens, areSwapTokensShuffled } = useSwappableTokens() const { links } = getDynamicConfigParams(DynamicConfigs[StatsigDynamicConfigs.APP_CONFIG]) const { maxSlippagePercentage, enableAppFee } = getDynamicConfigParams( DynamicConfigs[StatsigDynamicConfigs.SWAP_CONFIG] ) const inputFromRef = useRef(null) const inputToRef = useRef(null) const tokenBottomSheetFromRef = useRef(null) const tokenBottomSheetToRef = useRef(null) const exchangeRateInfoBottomSheetRef = useRef(null) const feeInfoBottomSheetRef = useRef(null) const slippageInfoBottomSheetRef = useRef(null) const estimatedDurationBottomSheetRef = useRef(null) const [noUsdPriceToken, setNoUsdPriceToken] = useState< { token: TokenBalance; tokenPositionInList: number } | undefined >(undefined) const [selectedPercentage, setSelectedPercentage] = useState(null) const [startedSwapId, setStartedSwapId] = useState(undefined) const [switchedToNetworkId, setSwitchedToNetworkId] = useState<{ networkId: NetworkId field: Field } | null>(null) const [fromToken, setFromToken] = useState(() => { if (!route.params?.fromTokenId) return undefined return swappableFromTokens.find((token) => token.tokenId === route.params!.fromTokenId) }) const [toToken, setToToken] = useState(() => { if (!route.params?.toTokenId) return undefined return swappableToTokens.find((token) => token.tokenId === route.params!.toTokenId) }) const currentSwap = useSelector(currentSwapSelector) const localCurrency = useSelector(getLocalCurrencyCode) const priceImpactWarningThreshold = useSelector(priceImpactWarningThresholdSelector) const tokensById = useSelector((state) => tokensByIdSelector(state, getSupportedNetworkIdsForSwap()) ) const crossChainFeeCurrency = useSelector((state) => feeCurrenciesSelector(state, fromToken?.networkId || networkConfig.defaultNetworkId) ).find((token) => token.isNative) const feeCurrenciesWithPositiveBalances = useSelector((state) => feeCurrenciesWithPositiveBalancesSelector( state, fromToken?.networkId || networkConfig.defaultNetworkId ) ) const { quote, refreshQuote, fetchSwapQuoteError, fetchingSwapQuote, clearQuote } = useSwapQuote({ networkId: fromToken?.networkId || networkConfig.defaultNetworkId, slippagePercentage: maxSlippagePercentage, enableAppFee: enableAppFee, onError: (error) => { if (!error.message.includes(NO_QUOTE_ERROR_MESSAGE)) { dispatch(showError(ErrorMessages.FETCH_SWAP_QUOTE_FAILED)) } }, onSuccess: (newQuote) => { if (!newQuote) { replaceAmountTo('') return } if (!processedAmountsFrom.token.bignum) { return } const newAmount = processedAmountsFrom.token.bignum .multipliedBy(new BigNumber(newQuote.price)) .toString() replaceAmountTo(newAmount) }, }) const { amount: amountFrom, amountType: amountTypeFrom, processedAmounts: processedAmountsFrom, handleAmountInputChange, handleToggleAmountType: handleToggleAmountTypeFrom, handleSelectPercentageAmount, } = useEnterAmount({ inputRef: inputFromRef, token: fromToken, onHandleAmountInputChange: () => { setSelectedPercentage(null) }, }) const { amount: amountTo, amountType: amountTypeTo, processedAmounts: processedAmountsTo, replaceAmount: replaceAmountTo, handleToggleAmountType: handleToggleAmountTypeTo, } = useEnterAmount({ token: toToken, inputRef: inputToRef }) const filterChipsFrom = useFilterChips(Field.FROM) const filterChipsTo = useFilterChips(Field.TO, route.params?.toTokenNetworkId) const parsedSlippagePercentage = new BigNumber(maxSlippagePercentage).toFormat() const crossChainFee = quote?.swapType === 'cross-chain' ? getCrossChainFee({ feeCurrency: crossChainFeeCurrency, preparedTransactions: quote.preparedTransactions, fromTokenId: quote.fromTokenId, sellAmount: quote.sellAmount, estimatedCrossChainFee: quote.estimatedCrossChainFee, maxCrossChainFee: quote.maxCrossChainFee, }) : undefined const swapStatus = startedSwapId === currentSwap?.id ? currentSwap?.status : null const confirmSwapIsLoading = swapStatus === 'started' const confirmSwapFailed = swapStatus === 'error' const switchedToNetworkName = switchedToNetworkId && NETWORK_NAMES[switchedToNetworkId.networkId] const showCrossChainSwapNotification = toToken && fromToken && toToken.networkId !== fromToken.networkId && allowCrossChainSwaps const feeCurrencies = quote && quote.preparedTransactions.type === 'not-enough-balance-for-gas' ? quote.preparedTransactions.feeCurrencies.map((feeCurrency) => feeCurrency.symbol).join(', ') : '' const networkFee = useMemo(() => getNetworkFee(quote), [fromToken, quote]) const feeToken = networkFee?.token ? tokensById[networkFee.token.tokenId] : undefined const appFee: AppFeeAmount | undefined = useMemo(() => { if (!quote || !fromToken || !processedAmountsFrom.token.bignum) { return undefined } const percentage = new BigNumber(quote.appFeePercentageIncludedInPrice || 0) return { amount: processedAmountsFrom.token.bignum.multipliedBy(percentage).dividedBy(100), token: fromToken, percentage, } }, [quote, processedAmountsFrom.token.bignum, fromToken]) const shouldShowSkeletons = useMemo(() => { if (fetchingSwapQuote) return true if ( quote && (quote.fromTokenId !== fromToken?.tokenId || quote.toTokenId !== toToken?.tokenId) ) { return true } if ( quote && processedAmountsFrom.token.bignum && !quote.swapAmount.eq(processedAmountsFrom.token.bignum) ) { return true } return false }, [fetchingSwapQuote, quote, fromToken, toToken, processedAmountsFrom]) const warnings = useMemo(() => { const shouldShowMaxSwapAmountWarning = feeCurrenciesWithPositiveBalances.length === 1 && fromToken && fromToken.tokenId === feeCurrenciesWithPositiveBalances[0].tokenId && fromToken.balance.gt(0) && processedAmountsFrom.token.bignum && processedAmountsFrom.token.bignum.gte(fromToken.balance) // NOTE: If a new condition is added here, make sure to update `allowSwap` below if // the condition should prevent the user from swapping. const checks = { showSwitchedToNetworkWarning: !!switchedToNetworkId, showUnsupportedTokensWarning: !shouldShowSkeletons && fetchSwapQuoteError?.message.includes(NO_QUOTE_ERROR_MESSAGE), showInsufficientBalanceWarning: fromToken && processedAmountsFrom.token.bignum && processedAmountsFrom.token.bignum.gt(fromToken.balance), showCrossChainFeeWarning: !shouldShowSkeletons && crossChainFee?.nativeTokenBalanceDeficit.lt(0), showDecreaseSpendForGasWarning: !shouldShowSkeletons && quote?.preparedTransactions.type === 'need-decrease-spend-amount-for-gas', showNotEnoughBalanceForGasWarning: !shouldShowSkeletons && quote?.preparedTransactions.type === 'not-enough-balance-for-gas', showMaxSwapAmountWarning: shouldShowMaxSwapAmountWarning && !confirmSwapFailed, showNoUsdPriceWarning: !confirmSwapFailed && !shouldShowSkeletons && toToken && !toToken.priceUsd, showPriceImpactWarning: !confirmSwapFailed && !shouldShowSkeletons && (quote?.estimatedPriceImpact ? new BigNumber(quote.estimatedPriceImpact).gte(priceImpactWarningThreshold) : false), showMissingPriceImpactWarning: !shouldShowSkeletons && quote && !quote.estimatedPriceImpact, } // Only ever show a single warning, according to precedence as above. // Warnings that prevent the user from confirming the swap should // take higher priority over others. return Object.entries(checks).reduce( (acc, [name, status]) => { acc[name] = Object.values(acc).some(Boolean) ? false : !!status return acc }, {} as Record ) }, [ feeCurrenciesWithPositiveBalances, fromToken, toToken, processedAmountsFrom, switchedToNetworkId, shouldShowSkeletons, fetchSwapQuoteError, crossChainFee, quote, confirmSwapFailed, priceImpactWarningThreshold, ]) const allowSwap = useMemo( () => !warnings.showDecreaseSpendForGasWarning && !warnings.showNotEnoughBalanceForGasWarning && !warnings.showInsufficientBalanceWarning && !warnings.showCrossChainFeeWarning && !confirmSwapIsLoading && !shouldShowSkeletons && processedAmountsFrom.token.bignum && processedAmountsFrom.token.bignum.gt(0) && processedAmountsTo.token.bignum && processedAmountsTo.token.bignum.gt(0), [ processedAmountsFrom.token.bignum, processedAmountsTo.token.bignum, shouldShowSkeletons, confirmSwapIsLoading, warnings.showInsufficientBalanceWarning, warnings.showDecreaseSpendForGasWarning, warnings.showNotEnoughBalanceForGasWarning, warnings.showCrossChainFeeWarning, ] ) useEffect( function refreshTransactionQuote() { setStartedSwapId(undefined) if (!processedAmountsFrom.token.bignum) { clearQuote() replaceAmountTo('') return } const debounceTimeout = setTimeout(() => { const bothTokensPresent = !!(fromToken && toToken) const amountIsTooSmall = !processedAmountsFrom.token.bignum || processedAmountsFrom.token.bignum.lte(0) if (!bothTokensPresent || amountIsTooSmall) { return } // This variable prevents the quote from needlessly being fetched again. const quoteIsTheSameAsTheLastOne = quote && quote.toTokenId === toToken.tokenId && quote.fromTokenId === fromToken.tokenId && processedAmountsFrom.token.bignum && quote.swapAmount.eq(processedAmountsFrom.token.bignum) if (!quoteIsTheSameAsTheLastOne) { replaceAmountTo('') void refreshQuote( fromToken, toToken, { FROM: processedAmountsFrom.token.bignum, TO: null }, Field.FROM ) } }, FETCH_UPDATED_TRANSACTIONS_DEBOUNCE_TIME_MS) return () => { clearTimeout(debounceTimeout) } }, [ processedAmountsFrom.token.bignum?.toString(), fromToken, toToken, quote, refreshQuote, /** * TODO * This useEffect doesn't follow the rules of hooks which can introduce unnecessary bugs. * Functions below should be optimized to not cause unnecessary re-runs. Once that's done - * they should be uncommented. */ // clearQuote, // replaceAmountTo, ] ) useEffect(function trackSwapScreenOpen() { AppAnalytics.track(SwapEvents.swap_screen_open) }, []) useEffect( function trackImpactWarningDisplayed() { if (warnings.showPriceImpactWarning || warnings.showMissingPriceImpactWarning) { if (!quote) { return } const fromToken = tokensById[quote.fromTokenId] const toToken = tokensById[quote.toTokenId] if (!fromToken || !toToken) { // Should never happen Logger.error(TAG, 'fromToken or toToken not found') return } AppAnalytics.track(SwapEvents.swap_price_impact_warning_displayed, { toToken: toToken.address, toTokenId: toToken.tokenId, toTokenNetworkId: toToken.networkId, toTokenIsImported: !!toToken.isManuallyImported, fromToken: fromToken.address, fromTokenId: fromToken.tokenId, fromTokenNetworkId: fromToken?.networkId, fromTokenIsImported: !!fromToken.isManuallyImported, amount: processedAmountsFrom.token.bignum ? processedAmountsFrom.token.bignum.toString() : '', amountType: 'sellAmount', priceImpact: quote.estimatedPriceImpact, provider: quote.provider, }) } }, [warnings.showPriceImpactWarning || warnings.showMissingPriceImpactWarning] ) function handleOpenTokenPicker(field: Field) { AppAnalytics.track(SwapEvents.swap_screen_select_token, { fieldType: field }) // use requestAnimationFrame so that the bottom sheet open animation is done // after the selectingField value is updated, so that the title of the // bottom sheet (which depends on selectingField) does not change on the // screen requestAnimationFrame(() => { const ref = field === Field.FROM ? tokenBottomSheetFromRef : tokenBottomSheetToRef ref.current?.snapToIndex(0) }) } function handleConfirmSwap() { if (!quote) { return // this should never happen, because the button must be disabled in that cases } const fromToken = tokensById[quote.fromTokenId] const toToken = tokensById[quote.toTokenId] if (!fromToken || !toToken) { // Should never happen return } const userInput = { toTokenId: toToken.tokenId, fromTokenId: fromToken.tokenId, swapAmount: { [Field.FROM]: processedAmountsFrom.token.bignum?.toString() ?? '', [Field.TO]: processedAmountsTo.token.bignum?.toString() ?? '', }, updatedField: Field.FROM, } const { estimatedPriceImpact, price, allowanceTarget, appFeePercentageIncludedInPrice } = quote const resultType = quote.preparedTransactions.type switch (resultType) { case 'need-decrease-spend-amount-for-gas': // fallthrough on purpose case 'not-enough-balance-for-gas': // This should never actually happen, since the user should not be able // to confirm the swap in this case. break case 'possible': const swapId = uuidv4() AppAnalytics.track(SwapEvents.swap_review_submit, { swapId, toToken: toToken.address, toTokenId: toToken.tokenId, toTokenNetworkId: toToken.networkId, toTokenIsImported: !!toToken.isManuallyImported, fromToken: fromToken.address, fromTokenId: fromToken.tokenId, fromTokenNetworkId: fromToken.networkId, fromTokenIsImported: !!fromToken.isManuallyImported, amount: processedAmountsFrom.token.bignum?.toString() || '', amountType: 'sellAmount', allowanceTarget, estimatedPriceImpact, price, appFeePercentageIncludedInPrice, provider: quote.provider, swapType: quote.swapType, web3Library: 'viem', ...getSwapTxsAnalyticsProperties( quote.preparedTransactions.transactions, fromToken.networkId, tokensById ), }) setStartedSwapId(swapId) dispatch( swapStart({ swapId, quote: { preparedTransactions: getSerializablePreparedTransactions( quote.preparedTransactions.transactions ), receivedAt: quote.receivedAt, price: quote.price, appFeePercentageIncludedInPrice, provider: quote.provider, estimatedPriceImpact, allowanceTarget, swapType: quote.swapType, }, userInput, areSwapTokensShuffled, }) ) break default: // To catch any missing cases at compile time const assertNever: never = resultType return assertNever } } function handleSwitchTokens() { AppAnalytics.track(SwapEvents.swap_switch_tokens, { fromTokenId: fromToken?.tokenId, toTokenId: toToken?.tokenId, }) setFromToken(toToken) setToToken(fromToken) replaceAmountTo('') } function handleConfirmSelectTokenNoUsdPrice() { if (noUsdPriceToken) { handleConfirmSelectToken({ field: Field.TO, selectedToken: noUsdPriceToken.token, tokenPositionInList: noUsdPriceToken.tokenPositionInList, }) setNoUsdPriceToken(undefined) } } function handleDismissSelectTokenNoUsdPrice() { setNoUsdPriceToken(undefined) } const handleConfirmSelectToken = ({ field, selectedToken, tokenPositionInList, }: { field: Field selectedToken: TokenBalance tokenPositionInList: number }) => { if (!field) { // Should never happen Logger.error(TAG, 'handleConfirmSelectToken called without field') return } let newFromToken = fromToken let newToToken = toToken let newSwitchedToNetwork: typeof switchedToNetworkId | null = null switch (true) { // If we're selecting a field that was already selected in the other input then switch inputs case (field === Field.FROM && toToken?.tokenId === selectedToken.tokenId) || (field === Field.TO && fromToken?.tokenId === selectedToken.tokenId): { newFromToken = toToken newToToken = fromToken break } case field === Field.FROM: { newFromToken = selectedToken newSwitchedToNetwork = toToken && toToken.networkId !== newFromToken.networkId && !allowCrossChainSwaps ? { networkId: newFromToken.networkId, field: Field.FROM } : null if (newSwitchedToNetwork) { // reset the toToken if the user is switching networks newToToken = undefined } break } case field === Field.TO: { if (!selectedToken.priceUsd && !noUsdPriceToken) { setNoUsdPriceToken({ token: selectedToken, tokenPositionInList }) return } newToToken = selectedToken newSwitchedToNetwork = fromToken && fromToken.networkId !== newToToken.networkId && !allowCrossChainSwaps ? { networkId: newToToken.networkId, field: Field.TO } : null if (newSwitchedToNetwork) { // reset the fromToken if the user is switching networks newFromToken = undefined } } } AppAnalytics.track(SwapEvents.swap_screen_confirm_token, { fieldType: field, tokenSymbol: selectedToken.symbol, tokenId: selectedToken.tokenId, tokenNetworkId: selectedToken.networkId, fromTokenSymbol: newFromToken?.symbol, fromTokenId: newFromToken?.tokenId, fromTokenNetworkId: newFromToken?.networkId, toTokenSymbol: newToToken?.symbol, toTokenId: newToToken?.tokenId, toTokenNetworkId: newToToken?.networkId, switchedNetworkId: !!newSwitchedToNetwork, areSwapTokensShuffled, tokenPositionInList, }) setFromToken(newFromToken) setToToken(newToToken) setSwitchedToNetworkId(allowCrossChainSwaps ? null : newSwitchedToNetwork) setStartedSwapId(undefined) replaceAmountTo('') if (newSwitchedToNetwork) { clearQuote() } // use requestAnimationFrame so that the bottom sheet and keyboard dismiss // animation can be synchronised and starts after the state changes above. // without this, the keyboard animation lags behind the state updates while // the bottom sheet does not requestAnimationFrame(() => { const ref = field === Field.FROM ? tokenBottomSheetFromRef : tokenBottomSheetToRef ref.current?.close() }) } function handleToggleAmountType() { const newAmountType = handleToggleAmountTypeFrom() handleToggleAmountTypeTo(newAmountType) } function handleSelectAmountPercentage(percentage: number) { handleSelectPercentageAmount(percentage) setSelectedPercentage(percentage) if (!fromToken) { // Should never happen return } AppAnalytics.track(SwapEvents.swap_screen_percentage_selected, { tokenSymbol: fromToken.symbol, tokenId: fromToken.tokenId, tokenNetworkId: fromToken.networkId, percentage, }) } function handlePressLearnMore() { AppAnalytics.track(SwapEvents.swap_learn_more) navigate(Screens.WebViewScreen, { uri: links.swapLearnMore }) } function handlePressLearnMoreFees() { AppAnalytics.track(SwapEvents.swap_gas_fees_learn_more) navigate(Screens.WebViewScreen, { uri: links.transactionFeesLearnMore }) } return ( } title={t('swapScreen.title')} /> handleOpenTokenPicker(Field.FROM)} testID="SwapAmountInput" /> handleOpenTokenPicker(Field.TO)} loading={shouldShowSkeletons} testID="SwapAmountInput" /> {showCrossChainSwapNotification && ( {t('swapScreen.crossChainNotification')} )} {warnings.showCrossChainFeeWarning && ( )} {warnings.showDecreaseSpendForGasWarning && ( { if ( !quote || quote.preparedTransactions.type !== 'need-decrease-spend-amount-for-gas' ) return handleAmountInputChange( quote.preparedTransactions.decreasedSpendAmount.toString() ) }} ctaLabel={t('swapScreen.decreaseSwapAmountForGasWarning.cta')} style={styles.warning} /> )} {warnings.showNotEnoughBalanceForGasWarning && ( )} {warnings.showInsufficientBalanceWarning && ( )} {warnings.showUnsupportedTokensWarning && ( )} {warnings.showSwitchedToNetworkWarning && ( )} {warnings.showMaxSwapAmountWarning && ( )} {warnings.showPriceImpactWarning && ( )} {warnings.showNoUsdPriceWarning && ( )} {warnings.showMissingPriceImpactWarning && ( )} {confirmSwapFailed && ( )}