import { NativeStackScreenProps } from '@react-navigation/native-stack' import BigNumber from 'bignumber.js' import React, { useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { Keyboard, TextInput as RNTextInput, StyleSheet, Text, View } from 'react-native' import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context' import AppAnalytics from 'src/analytics/AppAnalytics' import { EarnEvents, SendEvents } from 'src/analytics/Events' import BackButton from 'src/components/BackButton' import BottomSheet, { BottomSheetModalRefType } from 'src/components/BottomSheet' import Button, { BtnSizes, BtnTypes } from 'src/components/Button' import GasFeeWarning from 'src/components/GasFeeWarning' import InLineNotification, { NotificationVariant } from 'src/components/InLineNotification' import KeyboardAwareScrollView from 'src/components/KeyboardAwareScrollView' import { LabelWithInfo } from 'src/components/LabelWithInfo' import RowDivider from 'src/components/RowDivider' import TokenBottomSheet, { TokenBottomSheetProps, TokenPickerOrigin, } from 'src/components/TokenBottomSheet' import TokenDisplay from 'src/components/TokenDisplay' import TokenEnterAmount, { FETCH_UPDATED_TRANSACTIONS_DEBOUNCE_TIME_MS, useEnterAmount, } from 'src/components/TokenEnterAmount' import CustomHeader from 'src/components/header/CustomHeader' import EarnDepositBottomSheet from 'src/earn/EarnDepositBottomSheet' import { usePrepareEnterAmountTransactionsCallback } from 'src/earn/hooks' import { depositStatusSelector } from 'src/earn/selectors' import { getSwapToAmountInDecimals } from 'src/earn/utils' import ArrowRightThick from 'src/icons/ArrowRightThick' import { navigate } from 'src/navigator/NavigationService' import { Screens } from 'src/navigator/Screens' import { StackParamList } from 'src/navigator/types' import { hooksApiUrlSelector, positionsWithBalanceSelector } from 'src/positions/selectors' import { EarnPosition, Position } from 'src/positions/types' import { useSelector } from 'src/redux/hooks' import EnterAmountOptions from 'src/send/EnterAmountOptions' import { getFeatureGate } from 'src/statsig' import { StatsigFeatureGates } from 'src/statsig/types' import Colors from 'src/styles/colors' import { typeScale } from 'src/styles/fonts' import { Spacing } from 'src/styles/styles' import getCrossChainFee from 'src/swap/getCrossChainFee' import { SwapFeeAmount, SwapTransaction } from 'src/swap/types' import { useSwappableTokens, useTokenInfo } from 'src/tokens/hooks' import { feeCurrenciesSelector } from 'src/tokens/selectors' import { TokenBalance } from 'src/tokens/slice' import Logger from 'src/utils/Logger' import { getFeeCurrencyAndAmounts, PreparedTransactionsResult } from 'src/viem/prepareTransactions' import { walletAddressSelector } from 'src/web3/selectors' import { isAddress } from 'viem' type Props = NativeStackScreenProps const TAG = 'EarnEnterAmount' function useTokens({ pool }: { pool: EarnPosition }) { const depositToken = useTokenInfo(pool.dataProps.depositTokenId) const withdrawToken = useTokenInfo(pool.dataProps.withdrawTokenId) const { swappableFromTokens: swappableTokens } = useSwappableTokens() const allowCrossChainSwapAndDeposit = getFeatureGate( StatsigFeatureGates.ALLOW_CROSS_CHAIN_SWAP_AND_DEPOSIT ) const eligibleSwappableTokens = useMemo( () => swappableTokens .filter( ({ tokenId, balance, networkId }) => (allowCrossChainSwapAndDeposit || networkId === pool.networkId) && tokenId !== pool.dataProps.depositTokenId && tokenId !== pool.dataProps.withdrawTokenId && balance.gt(0) ) .sort((token1, token2) => { // Sort pool network tokens first, otherwise by USD balance (which // should be the default already from the useSwappableTokens hook) if (token1.networkId === pool.networkId && token2.networkId !== pool.networkId) { return -1 } if (token1.networkId !== pool.networkId && token2.networkId === pool.networkId) { return 1 } return 0 }), [ swappableTokens, pool.dataProps.depositTokenId, pool.dataProps.withdrawTokenId, pool.networkId, allowCrossChainSwapAndDeposit, ] ) if (!depositToken) { // should never happen throw new Error(`Token info not found for token ID ${pool.dataProps.depositTokenId}`) } if (!withdrawToken) { // should never happen throw new Error(`Token info not found for token ID ${pool.dataProps.withdrawTokenId}`) } return { depositToken, withdrawToken, eligibleSwappableTokens, } } export default function EarnEnterAmount({ route }: Props) { const { t } = useTranslation() const insets = useSafeAreaInsets() const { pool, mode = 'deposit' } = route.params const isWithdrawal = mode === 'withdraw' const { depositToken, withdrawToken, eligibleSwappableTokens } = useTokens({ pool }) // We do not need to check withdrawal status/show a spinner for a pending // withdrawal, since withdrawals navigate to a separate confirmation screen. const depositStatus = useSelector(depositStatusSelector) const transactionSubmitted = depositStatus === 'loading' const availableInputTokens = useMemo(() => { switch (mode) { case 'deposit': case 'withdraw': return [depositToken] case 'swap-deposit': default: return eligibleSwappableTokens } }, [mode]) /** * Use different balance for the withdrawal flow. As described in this discussion * (https://github.com/valora-inc/wallet/pull/6246#discussion_r1883426564) the intent of this * is to abstract away the LP token from the user and just display the token they're depositing, * so we need to convert the LP token balance to deposit and back to LP token when transacting." */ const [inputToken, setInputToken] = useState(() => ({ ...availableInputTokens[0], balance: isWithdrawal ? withdrawToken.balance.multipliedBy(pool.pricePerShare[0]) : availableInputTokens[0].balance, })) const inputRef = useRef(null) const tokenBottomSheetRef = useRef(null) const reviewBottomSheetRef = useRef(null) const feeDetailsBottomSheetRef = useRef(null) const swapDetailsBottomSheetRef = useRef(null) const estimatedDurationBottomSheetRef = useRef(null) const [selectedPercentage, setSelectedPercentage] = useState(null) const hooksApiUrl = useSelector(hooksApiUrlSelector) const walletAddress = useSelector(walletAddressSelector) const { prepareTransactionsResult: { prepareTransactionsResult, swapTransaction } = {}, refreshPreparedTransactions, clearPreparedTransactions, prepareTransactionError, isPreparingTransactions, } = usePrepareEnterAmountTransactionsCallback(mode) const { amount, replaceAmount, amountType, processedAmounts, handleAmountInputChange, handleToggleAmountType, handleSelectPercentageAmount, } = useEnterAmount({ token: inputToken, inputRef, onHandleAmountInputChange: () => { setSelectedPercentage(null) }, }) const onOpenTokenPicker = () => { tokenBottomSheetRef.current?.snapToIndex(0) AppAnalytics.track(SendEvents.token_dropdown_opened, { currentTokenId: inputToken.tokenId, currentTokenAddress: inputToken.address, currentNetworkId: inputToken.networkId, }) } const onSelectToken: TokenBottomSheetProps['onTokenSelected'] = (selectedToken) => { // Use different balance for the withdrawal flow. setInputToken({ ...selectedToken, balance: isWithdrawal ? withdrawToken.balance.multipliedBy(pool.pricePerShare[0]) : selectedToken.balance, }) replaceAmount('') tokenBottomSheetRef.current?.close() // NOTE: analytics is already fired by the bottom sheet, don't need one here } const handleRefreshPreparedTransactions = ( amount: BigNumber, token: TokenBalance, feeCurrencies: TokenBalance[] ) => { if (!walletAddress || !isAddress(walletAddress)) { Logger.error(TAG, 'Wallet address not set. Cannot refresh prepared transactions.') return } return refreshPreparedTransactions({ amount: amount.toString(), token, walletAddress, feeCurrencies, pool, hooksApiUrl, shortcutId: mode, useMax: selectedPercentage === 1, }) } const crossChainFeeCurrency = useSelector((state) => feeCurrenciesSelector(state, inputToken.networkId) ).find((token) => token.isNative) const crossChainFee = swapTransaction?.swapType === 'cross-chain' && prepareTransactionsResult ? getCrossChainFee({ feeCurrency: crossChainFeeCurrency, preparedTransactions: prepareTransactionsResult, estimatedCrossChainFee: swapTransaction.estimatedCrossChainFee, maxCrossChainFee: swapTransaction.maxCrossChainFee, fromTokenId: inputToken.tokenId, sellAmount: swapTransaction.sellAmount, }) : undefined // This is for withdrawals as we want the user to be able to input the amounts in the deposit token const { transactionToken, transactionTokenAmount } = useMemo(() => { const transactionToken = isWithdrawal ? withdrawToken : inputToken const transactionTokenAmount = isWithdrawal ? processedAmounts.token.bignum && processedAmounts.token.bignum.dividedBy(pool.pricePerShare[0]) : processedAmounts.token.bignum return { transactionToken, transactionTokenAmount, } }, [inputToken, withdrawToken, processedAmounts.token.bignum, isWithdrawal, pool]) const feeCurrencies = useSelector((state) => feeCurrenciesSelector(state, transactionToken.networkId) ) useEffect(() => { clearPreparedTransactions() if ( !processedAmounts.token.bignum || !transactionTokenAmount || processedAmounts.token.bignum.isLessThanOrEqualTo(0) || processedAmounts.token.bignum.isGreaterThan(inputToken.balance) ) { return } const debouncedRefreshTransactions = setTimeout(() => { return handleRefreshPreparedTransactions( transactionTokenAmount, transactionToken, feeCurrencies ) }, FETCH_UPDATED_TRANSACTIONS_DEBOUNCE_TIME_MS) return () => clearTimeout(debouncedRefreshTransactions) }, [processedAmounts.token.bignum?.toString(), mode, transactionToken, inputToken, feeCurrencies]) const { estimatedFeeAmount, feeCurrency, maxFeeAmount } = getFeeCurrencyAndAmounts(prepareTransactionsResult) const showLowerAmountError = processedAmounts.token.bignum && processedAmounts.token.bignum.gt(inputToken.balance) const transactionIsPossible = !showLowerAmountError && prepareTransactionsResult && prepareTransactionsResult.type === 'possible' && prepareTransactionsResult.transactions.length > 0 const allPositionsWithBalance = useSelector(positionsWithBalanceSelector) const rewardsPositions = useMemo( () => allPositionsWithBalance.filter((position) => pool.dataProps.rewardsPositionIds?.includes(position.positionId) ), [allPositionsWithBalance, pool.dataProps.rewardsPositionIds] ) const disabled = // Should disable if the user enters 0, has enough balance but the transaction // is not possible, does not have enough balance, or if transaction is already // submitted !!processedAmounts.token.bignum?.isZero() || !transactionIsPossible || transactionSubmitted const onSelectPercentageAmount = (percentage: number) => { handleSelectPercentageAmount(percentage) setSelectedPercentage(percentage) AppAnalytics.track(SendEvents.send_percentage_selected, { tokenId: inputToken.tokenId, tokenAddress: inputToken.address, networkId: inputToken.networkId, percentage: percentage * 100, flow: 'earn', }) } const onPressContinue = () => { if (!processedAmounts.token.bignum || !transactionToken) { // should never happen return } AppAnalytics.track(EarnEvents.earn_enter_amount_continue_press, { // TokenAmount is always deposit token amountInUsd: processedAmounts.token.bignum.multipliedBy(inputToken.priceUsd ?? 0).toFixed(2), amountEnteredIn: amountType, depositTokenId: pool.dataProps.depositTokenId, networkId: pool.networkId, providerId: pool.appId, poolId: pool.positionId, fromTokenId: inputToken.tokenId, fromTokenAmount: processedAmounts.token.bignum.toString(), fromNetworkId: inputToken.networkId, swapType: swapTransaction?.swapType, mode, depositTokenAmount: isWithdrawal ? undefined : swapTransaction ? getSwapToAmountInDecimals({ swapTransaction, fromAmount: processedAmounts.token.bignum, }).toString() : processedAmounts.token.bignum.toString(), }) if (isWithdrawal) { navigate(Screens.EarnConfirmationScreen, { pool, mode, inputAmount: processedAmounts.token.bignum.toString(), useMax: selectedPercentage === 1, }) } else { reviewBottomSheetRef.current?.snapToIndex(0) } } const dropdownEnabled = availableInputTokens.length > 1 return ( } /> { Keyboard.dismiss() }} > {isWithdrawal ? t('earnFlow.enterAmount.titleWithdraw') : t('earnFlow.enterAmount.title')} {processedAmounts.token.bignum && prepareTransactionsResult && !isWithdrawal && ( )} {isWithdrawal && ( )} {showLowerAmountError && ( )} {prepareTransactionError && ( )} {isWithdrawal && pool.dataProps.withdrawalIncludesClaim && rewardsPositions.length > 0 && ( )}