import { NativeStackScreenProps } from '@react-navigation/native-stack' import BigNumber from 'bignumber.js' import React, { useMemo } from 'react' import { useTranslation } from 'react-i18next' import { SafeAreaView, ScrollView, StyleSheet, Text, View } from 'react-native' import SkeletonPlaceholder from 'react-native-skeleton-placeholder' import AppAnalytics from 'src/analytics/AppAnalytics' import { EarnEvents } from 'src/analytics/Events' import Button, { BtnSizes } from 'src/components/Button' import InLineNotification, { NotificationVariant } from 'src/components/InLineNotification' import TokenDisplay from 'src/components/TokenDisplay' import TokenIcon, { IconSize } from 'src/components/TokenIcon' import { usePrepareEarnConfirmationScreenTransactions } from 'src/earn/hooks' import { withdrawStatusSelector } from 'src/earn/selectors' import { withdrawStart } from 'src/earn/slice' import { EarnActiveMode } from 'src/earn/types' import { getEarnPositionBalanceValues, isGasSubsidizedForNetwork } from 'src/earn/utils' import { CICOFlow } from 'src/fiatExchanges/types' 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 { Token } from 'src/positions/types' import { useDispatch, useSelector } from 'src/redux/hooks' import { NETWORK_NAMES } from 'src/shared/conts' import Colors from 'src/styles/colors' import { typeScale } from 'src/styles/fonts' import { Spacing } from 'src/styles/styles' import { useTokenInfo } from 'src/tokens/hooks' import { feeCurrenciesSelector } from 'src/tokens/selectors' import { TokenBalance } from 'src/tokens/slice' import { getFeeCurrencyAndAmounts } from 'src/viem/prepareTransactions' import { getSerializablePreparedTransactions } from 'src/viem/preparedTransactionSerialization' import { walletAddressSelector } from 'src/web3/selectors' import { isAddress } from 'viem' type Props = NativeStackScreenProps export default function EarnConfirmationScreen({ route }: Props) { const { t } = useTranslation() const dispatch = useDispatch() const { pool, mode, inputAmount, useMax } = route.params const { depositTokenId, withdrawTokenId, rewardsPositionIds } = pool.dataProps const withdrawStatus = useSelector(withdrawStatusSelector) const rewardsPositions = useSelector(positionsWithBalanceSelector).filter((position) => rewardsPositionIds?.includes(position.positionId) ) const hooksApiUrl = useSelector(hooksApiUrlSelector) const walletAddress = useSelector(walletAddressSelector) const depositToken = useTokenInfo(depositTokenId) const withdrawToken = useTokenInfo(withdrawTokenId) const rewardsTokens = useMemo( () => rewardsPositions.flatMap((position) => position.tokens), [rewardsPositions] ) if (!depositToken || !withdrawToken) { // should never happen throw new Error('Deposit token or withdraw token not found') } if (!walletAddress || !isAddress(walletAddress)) { // should never happen throw new Error('Wallet address is not valid') } const isGasSubsidized = isGasSubsidizedForNetwork(depositToken.networkId) const feeCurrencies = useSelector((state) => feeCurrenciesSelector(state, depositToken.networkId)) const withdrawAmountInDepositToken = useMemo( () => inputAmount ? new BigNumber(inputAmount) : getEarnPositionBalanceValues({ pool }).poolBalanceInDepositToken, [withdrawToken, pool.pricePerShare, inputAmount] ) const { result: prepareTransactionsResult, loading: isPreparingTransactions, error: prepareTransactionError, } = usePrepareEarnConfirmationScreenTransactions(mode, { amount: withdrawAmountInDepositToken.dividedBy(pool.pricePerShare[0]).toString(), pool, walletAddress, feeCurrencies, hooksApiUrl, rewardsPositions, useMax, }) const onPress = () => { if (prepareTransactionsResult?.type !== 'possible') { // should never happen because button is disabled if withdraw is not possible throw new Error('Cannot be called without possible prepared transactions') } dispatch( withdrawStart({ preparedTransactions: getSerializablePreparedTransactions( prepareTransactionsResult.transactions ), rewardsTokens, pool, mode, ...(mode !== 'claim-rewards' && { amount: withdrawAmountInDepositToken.toString() }), }) ) AppAnalytics.track(EarnEvents.earn_collect_earnings_press, { depositTokenId, tokenAmount: withdrawAmountInDepositToken.toString(), networkId: withdrawToken.networkId, providerId: pool.appId, poolId: pool.positionId, rewards: rewardsTokens.map((token) => ({ amount: token.balance.toString(), tokenId: token.tokenId, })), mode, }) } const error = prepareTransactionError const ctaDisabled = isPreparingTransactions || error || prepareTransactionsResult?.type !== 'possible' || withdrawStatus === 'loading' const { maxFeeAmount, feeCurrency } = getFeeCurrencyAndAmounts(prepareTransactionsResult) let feeSection = if (maxFeeAmount && feeCurrency) { feeSection = ( ) } else if (!isPreparingTransactions) { feeSection = } return ( <View style={styles.collectInfoContainer}> {mode !== 'claim-rewards' && ( <CollectItem title={t('earnFlow.collect.total')} tokenInfo={depositToken} rewardAmount={withdrawAmountInDepositToken} /> )} {(mode !== 'withdraw' || pool.dataProps.withdrawalIncludesClaim) && rewardsTokens.map((token, index) => ( <CollectItem title={t('earnFlow.collect.reward')} key={index} tokenInfo={token} rewardAmount={token.balance} /> ))} <View style={styles.separator} /> <View> <Text style={styles.rateText}>{t('earnFlow.collect.fee')}</Text> {feeSection} {isGasSubsidized && ( <Text style={styles.gasSubsidized} testID={'EarnConfirmation/GasSubsidized'}> {t('earnFlow.gasSubsidized')} </Text> )} </View> </View> {error && ( <InLineNotification variant={NotificationVariant.Error} title={t('earnFlow.collect.errorTitle')} description={t('earnFlow.collect.errorDescription')} style={styles.error} /> )} {prepareTransactionsResult?.type === 'not-enough-balance-for-gas' && ( <InLineNotification variant={NotificationVariant.Warning} title={t('earnFlow.collect.noGasTitle', { symbol: feeCurrencies[0].symbol })} description={t('earnFlow.collect.noGasDescription', { symbol: feeCurrencies[0].symbol, network: NETWORK_NAMES[depositToken.networkId], })} ctaLabel={t('earnFlow.collect.noGasCta', { symbol: feeCurrencies[0].symbol, network: NETWORK_NAMES[depositToken.networkId], })} onPressCta={() => { AppAnalytics.track(EarnEvents.earn_withdraw_add_gas_press, { gasTokenId: feeCurrencies[0].tokenId, depositTokenId: pool.dataProps.depositTokenId, networkId: pool.networkId, providerId: pool.appId, poolId: pool.positionId, }) navigate(Screens.FiatExchangeAmount, { tokenId: feeCurrencies[0].tokenId, flow: CICOFlow.CashIn, tokenSymbol: feeCurrencies[0].symbol, }) }} style={styles.error} /> )} </ScrollView> <Button style={styles.button} size={BtnSizes.FULL} text={ mode === 'withdraw' ? t('earnFlow.collect.ctaWithdraw') : mode === 'exit' ? t('earnFlow.collect.ctaExit') : t('earnFlow.collect.ctaReward') } onPress={onPress} testID="EarnConfirmationScreen/CTA" disabled={!!ctaDisabled} showLoading={withdrawStatus === 'loading'} /> </SafeAreaView> ) } function CollectItem({ tokenInfo, rewardAmount, title, }: { tokenInfo: TokenBalance | Token rewardAmount: BigNumber.Value title: string }) { return ( <> <Text style={styles.collectItemTitle}>{title}</Text> <View style={styles.row}> <View style={styles.iconContainer}> <TokenIcon token={tokenInfo} size={IconSize.MEDIUM} /> </View> <View> <TokenDisplay style={styles.cryptoText} tokenId={tokenInfo.tokenId} amount={rewardAmount} showLocalAmount={false} testID={`EarnConfirmation/${tokenInfo.tokenId}/CryptoAmount`} /> <TokenDisplay style={styles.fiatText} tokenId={tokenInfo.tokenId} amount={rewardAmount} showLocalAmount={true} testID={`EarnConfirmation/${tokenInfo.tokenId}/FiatAmount`} /> </View> </View> </> ) } function GasFeeError() { return ( <View testID="EarnConfirmation/GasError"> <Text style={styles.apyText}> -- </Text> <Text style={styles.gasFeeFiat}> -- </Text> </View> ) } function GasFeeLoading() { return ( <View testID="EarnConfirmation/GasLoading"> <SkeletonPlaceholder backgroundColor={Colors.skeletonPlaceholderBackground} highlightColor={Colors.skeletonPlaceholderHighlight} > <View style={styles.gasFeeCryptoLoading} /> </SkeletonPlaceholder> <SkeletonPlaceholder backgroundColor={Colors.skeletonPlaceholderBackground} highlightColor={Colors.skeletonPlaceholderHighlight} > <View style={styles.gasFeeFiatLoading} /> </SkeletonPlaceholder> </View> ) } function GasFee({ maxFeeAmount, feeCurrency, isGasSubsidized = false, }: { maxFeeAmount: BigNumber feeCurrency: TokenBalance isGasSubsidized: Boolean }) { return ( <> <TokenDisplay style={[styles.apyText, isGasSubsidized && { textDecorationLine: 'line-through' }]} tokenId={feeCurrency.tokenId} amount={maxFeeAmount} showLocalAmount={false} testID="EarnConfirmation/GasFeeCryptoAmount" /> {!isGasSubsidized && ( <TokenDisplay style={styles.gasFeeFiat} tokenId={feeCurrency.tokenId} amount={maxFeeAmount} showLocalAmount={true} testID="EarnConfirmation/GasFeeFiatAmount" /> )} </> ) } function Title({ mode }: { mode: Exclude<EarnActiveMode, 'deposit' | 'swap-deposit'> }) { const { t } = useTranslation() switch (mode) { case 'claim-rewards': return <Text style={styles.title}>{t('earnFlow.collect.titleClaim')}</Text> case 'withdraw': return <Text style={styles.title}>{t('earnFlow.collect.titleWithdraw')}</Text> case 'exit': default: return <Text style={styles.title}>{t('earnFlow.collect.titleCollect')}</Text> } } const styles = StyleSheet.create({ contentContainer: { padding: Spacing.Thick24, }, container: { flex: 1, justifyContent: 'space-between', }, title: { ...typeScale.titleMedium, marginBottom: Spacing.Thick24, }, collectInfoContainer: { padding: Spacing.Regular16, borderColor: Colors.borderPrimary, borderWidth: 1, borderRadius: 16, backgroundColor: Colors.backgroundSecondary, }, row: { flexDirection: 'row', gap: Spacing.Smallest8, marginBottom: Spacing.Regular16, }, cryptoText: { ...typeScale.labelSemiBoldLarge, }, fiatText: { ...typeScale.bodySmall, color: Colors.contentSecondary, }, collectItemTitle: { ...typeScale.labelSemiBoldXSmall, marginBottom: Spacing.Smallest8, }, separator: { marginBottom: Spacing.Regular16, borderBottomWidth: 1, borderBottomColor: Colors.borderPrimary, }, rateText: { ...typeScale.bodySmall, color: Colors.contentSecondary, marginBottom: Spacing.Tiny4, }, iconContainer: { flexDirection: 'row', alignItems: 'center', }, apyText: { ...typeScale.labelSemiBoldSmall, }, gasFeeFiat: { ...typeScale.bodyXSmall, color: Colors.contentSecondary, }, button: { padding: Spacing.Thick24, }, error: { marginTop: Spacing.Regular16, }, gasFeeCryptoLoading: { width: 80, borderRadius: 100, ...typeScale.labelSemiBoldSmall, }, gasFeeFiatLoading: { width: 64, borderRadius: 100, ...typeScale.bodyXSmall, }, gasSubsidized: { ...typeScale.labelXSmall, color: Colors.accent, marginTop: Spacing.Tiny4, }, })