diff --git a/packages/mobile/src/core/ModalContainer/InsufficientFunds/InsufficientFunds.tsx b/packages/mobile/src/core/ModalContainer/InsufficientFunds/InsufficientFunds.tsx index 091296b1e..3ab65d2d4 100644 --- a/packages/mobile/src/core/ModalContainer/InsufficientFunds/InsufficientFunds.tsx +++ b/packages/mobile/src/core/ModalContainer/InsufficientFunds/InsufficientFunds.tsx @@ -32,10 +32,19 @@ export interface InsufficientFundsParams { currency?: string; stakingFee?: string; fee?: string; + isStakingDeposit?: boolean; } export const InsufficientFundsModal = memo((props) => { - const { totalAmount, balance, currency = 'TON', decimals = 9, stakingFee, fee } = props; + const { + totalAmount, + balance, + currency = 'TON', + decimals = 9, + stakingFee, + fee, + isStakingDeposit, + } = props; const nav = useNavigation(); const formattedAmount = useMemo( () => formatter.format(fromNano(totalAmount, decimals), { decimals }), @@ -58,6 +67,48 @@ export const InsufficientFundsModal = memo((props) => { openExploreTab('defi'); }, [nav]); + const content = useMemo(() => { + if (isStakingDeposit) { + return ( + + {t('txActions.signRaw.insufficientFunds.stakingDeposit', { + amount: formattedAmount, + currency, + })} + {t('txActions.signRaw.insufficientFunds.yourBalance', { + balance: formattedBalance, + currency, + })} + + ); + } + + if (stakingFee && fee) { + return ( + + {t('txActions.signRaw.insufficientFunds.stakingFee', { + count: Number(stakingFee), + fee, + })} + + ); + } + + return ( + + {t('txActions.signRaw.insufficientFunds.toBePaid', { + amount: formattedAmount, + currency, + })} + {currency === 'TON' && t('txActions.signRaw.insufficientFunds.withFees')} + {t('txActions.signRaw.insufficientFunds.yourBalance', { + balance: formattedBalance, + currency, + })} + + ); + }, [currency, fee, formattedAmount, formattedBalance, isStakingDeposit, stakingFee]); + return ( @@ -67,26 +118,7 @@ export const InsufficientFundsModal = memo((props) => { {t('txActions.signRaw.insufficientFunds.title')} - {stakingFee && fee ? ( - - {t('txActions.signRaw.insufficientFunds.stakingFee', { - count: Number(stakingFee), - fee, - })} - - ) : ( - - {t('txActions.signRaw.insufficientFunds.toBePaid', { - amount: formattedAmount, - currency, - })} - {currency === 'TON' && t('txActions.signRaw.insufficientFunds.withFees')} - {t('txActions.signRaw.insufficientFunds.yourBalance', { - balance: formattedBalance, - currency, - })} - - )} + {content} diff --git a/packages/mobile/src/core/Staking/Staking.tsx b/packages/mobile/src/core/Staking/Staking.tsx index 828b0e4c3..c993554eb 100644 --- a/packages/mobile/src/core/Staking/Staking.tsx +++ b/packages/mobile/src/core/Staking/Staking.tsx @@ -21,7 +21,7 @@ import { t } from '@tonkeeper/shared/i18n'; import { Address } from '@tonkeeper/shared/Address'; import { PoolImplementationType } from '@tonkeeper/core/src/TonAPI'; import { walletSelector } from '$store/wallet'; -import { CryptoCurrencies, getServerConfig } from '$shared/constants'; +import { CryptoCurrencies, Decimals, getServerConfig } from '$shared/constants'; import { Flash } from '@tonkeeper/uikit'; import { Ton } from '$libs/Ton'; @@ -142,8 +142,45 @@ export const Staking: FC = () => { openDAppBrowser(getServerConfig('stakingInfoUrl')); }, []); + const otherPoolsEstimation = useMemo(() => { + const otherPools = activePools.filter( + (pool) => pool.implementation !== PoolImplementationType.LiquidTF, + ); + + return otherPools.reduce( + (acc, pool) => { + return { + balance: new BigNumber(acc.balance) + .plus(pool.balance || '0') + .decimalPlaces(Decimals[CryptoCurrencies.Ton]) + .toString(), + estimatedProfit: new BigNumber(pool.balance || '0') + .multipliedBy(new BigNumber(pool.apy).dividedBy(100)) + .plus(acc.estimatedProfit) + .decimalPlaces(Decimals[CryptoCurrencies.Ton]) + .toString(), + }; + }, + { balance: '0', estimatedProfit: '0' }, + ); + }, [activePools]); + const getEstimateProfitMessage = useCallback( (provider: StakingProvider) => { + if (new BigNumber(otherPoolsEstimation.balance).isGreaterThan(0)) { + const estimatedProfit = new BigNumber(otherPoolsEstimation.balance).multipliedBy( + new BigNumber(provider.maxApy).dividedBy(100), + ); + + const profitDiff = estimatedProfit.minus(otherPoolsEstimation.estimatedProfit); + + if (profitDiff.isGreaterThan(0)) { + return t('staking.estimated_profit_compare', { + amount: formatter.format(profitDiff), + }); + } + } + const balance = new BigNumber(tonBalance); if (balance.isGreaterThanOrEqualTo(10)) { @@ -156,7 +193,7 @@ export const Staking: FC = () => { }); } }, - [tonBalance], + [otherPoolsEstimation, tonBalance], ); useEffect(() => { diff --git a/packages/mobile/src/core/StakingPoolDetails/StakingPoolDetails.tsx b/packages/mobile/src/core/StakingPoolDetails/StakingPoolDetails.tsx index 28bdab961..f56cfac60 100644 --- a/packages/mobile/src/core/StakingPoolDetails/StakingPoolDetails.tsx +++ b/packages/mobile/src/core/StakingPoolDetails/StakingPoolDetails.tsx @@ -2,7 +2,6 @@ import { usePoolInfo } from '$hooks/usePoolInfo'; import { useStakingRefreshControl } from '$hooks/useStakingRefreshControl'; import { MainStackRouteNames, openDAppBrowser, openJetton } from '$navigation'; import { MainStackParamList } from '$navigation/MainStack'; -import { NextCycle } from '$shared/components'; import { getServerConfig, KNOWN_STAKING_IMPLEMENTATIONS } from '$shared/constants'; import { getStakingPoolByAddress, getStakingProviderById, useStakingStore } from '$store'; import { @@ -319,20 +318,14 @@ export const StakingPoolDetails: FC = (props) => { ) : null} - {hasAnyBalance ? ( + {/* {hasAnyBalance && stakingJetton && isLiquidTF ? ( <> - - - {/* {stakingJetton && isLiquidTF ? ( - <> - - - - - - ) : null} */} + + + + - ) : null} + ) : null} */} {t('staking.details.about_pool')} diff --git a/packages/mobile/src/core/StakingSend/steps/AmountStep/AmountStep.tsx b/packages/mobile/src/core/StakingSend/steps/AmountStep/AmountStep.tsx index 1f6907366..7d3ccd4b0 100644 --- a/packages/mobile/src/core/StakingSend/steps/AmountStep/AmountStep.tsx +++ b/packages/mobile/src/core/StakingSend/steps/AmountStep/AmountStep.tsx @@ -67,7 +67,19 @@ const AmountStepComponent: FC = (props) => { isLiquidJetton, } = useCurrencyToSend(currency, isJetton); - const walletBalance = isLiquidJetton ? price!.totalTon : tonBalance; + const availableTonBalance = useMemo(() => { + if (pool.implementation === PoolImplementationType.LiquidTF && !isWithdrawal) { + const tonAmount = new BigNumber(tonBalance).minus(1.1); + + return tonAmount.isGreaterThanOrEqualTo(0) + ? tonAmount.decimalPlaces(Decimals[CryptoCurrencies.Ton]).toString() + : '0'; + } + + return tonBalance; + }, [isWithdrawal, pool.implementation, tonBalance]); + + const walletBalance = isLiquidJetton ? price!.totalTon : availableTonBalance; const minAmount = isWithdrawal ? '0' : Ton.fromNano(pool.min_stake); diff --git a/packages/mobile/src/hooks/usePoolInfo.ts b/packages/mobile/src/hooks/usePoolInfo.ts index 164bd204c..6ce44038b 100644 --- a/packages/mobile/src/hooks/usePoolInfo.ts +++ b/packages/mobile/src/hooks/usePoolInfo.ts @@ -21,6 +21,9 @@ import { PoolImplementationType, } from '@tonkeeper/core/src/TonAPI'; import { useGetTokenPrice, useTokenPrice } from './useTokenPrice'; +import { openInsufficientFundsModal } from '$core/ModalContainer/InsufficientFunds/InsufficientFunds'; +import { useCurrencyToSend } from './useCurrencyToSend'; +import { Ton } from '$libs/Ton'; export interface PoolDetailsItem { label: string; @@ -34,6 +37,8 @@ export const usePoolInfo = (pool: PoolInfo, poolStakingInfo?: AccountStakingInfo const wallet = useWallet(); + const { balance: tonBalance } = useCurrencyToSend(CryptoCurrencies.Ton); + const jettonBalances = useSelector(jettonsBalancesSelector); const highestApyPool = useStakingStore((s) => s.highestApyPool, shallow); @@ -113,6 +118,15 @@ export const usePoolInfo = (pool: PoolInfo, poolStakingInfo?: AccountStakingInfo const handleTopUpPress = useCallback(() => { if (wallet) { + const canDeposit = new BigNumber(tonBalance).isGreaterThanOrEqualTo(2.1); + if (!canDeposit) { + return openInsufficientFundsModal({ + totalAmount: Ton.toNano(2.1), + balance: Ton.toNano(tonBalance), + isStakingDeposit: true, + }); + } + nav.push(AppStackRouteNames.StakingSend, { poolAddress: pool.address, transactionType: StakingTransactionType.DEPOSIT, @@ -120,7 +134,7 @@ export const usePoolInfo = (pool: PoolInfo, poolStakingInfo?: AccountStakingInfo } else { openRequireWalletModal(); } - }, [nav, pool.address, wallet]); + }, [nav, pool.address, tonBalance, wallet]); const handleWithdrawalPress = useCallback(() => { if (!hasDeposit) { diff --git a/packages/mobile/src/shared/components/NextCycle/NextCycle.style.ts b/packages/mobile/src/shared/components/NextCycle/NextCycle.style.ts index cb0c258d9..a9a2d026d 100644 --- a/packages/mobile/src/shared/components/NextCycle/NextCycle.style.ts +++ b/packages/mobile/src/shared/components/NextCycle/NextCycle.style.ts @@ -1,21 +1,10 @@ import styled from '$styled'; -import { changeAlphaValue, convertHexToRGBA, ns } from '$utils'; -import Animated from 'react-native-reanimated'; +import { ns } from '$utils'; export const Container = styled.View` background: ${({ theme }) => theme.colors.backgroundSecondary}; border-radius: ${ns(16)}px; padding: ${ns(16)}px; - overflow: hidden; - position: relative; -`; - -export const ProgressView = styled(Animated.View)` - position: absolute; - top: 0; - left: 0; - bottom: 0; - background: ${({ theme }) => theme.colors.backgroundTertiary}; `; export const Row = styled.View` diff --git a/packages/mobile/src/shared/components/NextCycle/NextCycle.tsx b/packages/mobile/src/shared/components/NextCycle/NextCycle.tsx index 96a6214a3..3395748dd 100644 --- a/packages/mobile/src/shared/components/NextCycle/NextCycle.tsx +++ b/packages/mobile/src/shared/components/NextCycle/NextCycle.tsx @@ -1,40 +1,20 @@ import { useStakingCycle } from '$hooks/useStakingCycle'; import { Text } from '$uikit'; -import React, { FC, memo, useCallback } from 'react'; +import React, { FC, memo } from 'react'; import * as S from './NextCycle.style'; -import { LayoutChangeEvent } from 'react-native'; -import { interpolate, useAnimatedStyle, useSharedValue } from 'react-native-reanimated'; import { t } from '@tonkeeper/shared/i18n'; import { PoolInfo, PoolImplementationType } from '@tonkeeper/core/src/TonAPI'; interface Props { pool: PoolInfo; - nextReward?: string; } const NextCycleComponent: FC = (props) => { const { pool: { cycle_start, cycle_end, implementation }, - nextReward, } = props; - const { formattedDuration, progress, isCooldown } = useStakingCycle( - cycle_start, - cycle_end, - ); - - const containerWidth = useSharedValue(0); - - const handleLayout = useCallback( - (event: LayoutChangeEvent) => { - containerWidth.value = event.nativeEvent.layout.width; - }, - [containerWidth], - ); - - const progressAnimatedStyle = useAnimatedStyle(() => ({ - width: interpolate(progress.value, [0, 1], [0, containerWidth.value]), - })); + const { formattedDuration, isCooldown } = useStakingCycle(cycle_start, cycle_end); if (isCooldown && implementation !== PoolImplementationType.LiquidTF) { return ( @@ -51,33 +31,10 @@ const NextCycleComponent: FC = (props) => { } return ( - - - - - {nextReward ? `+ ${nextReward} TON` : t('staking.details.next_cycle.title')} - - - {t('staking.details.next_cycle.in')}{' '} - - {formattedDuration} - - - - {!nextReward ? ( - - {implementation === PoolImplementationType.LiquidTF - ? t('staking.details.next_cycle.desc_liquid') - : t('staking.details.next_cycle.desc')} - - ) : null} + + + {t('staking.details.next_cycle.message', { value: formattedDuration })} + ); }; diff --git a/packages/mobile/src/tabs/Wallet/components/StakingWidgetStatus.tsx b/packages/mobile/src/tabs/Wallet/components/StakingWidgetStatus.tsx index 47b95b68a..ba1c46db4 100644 --- a/packages/mobile/src/tabs/Wallet/components/StakingWidgetStatus.tsx +++ b/packages/mobile/src/tabs/Wallet/components/StakingWidgetStatus.tsx @@ -7,7 +7,11 @@ import { MainStackRouteNames } from '$navigation'; import { t } from '@tonkeeper/shared/i18n'; import { StakedTonIcon, Text } from '$uikit'; import { stakingFormatter } from '@tonkeeper/shared/formatter'; -import { AccountStakingInfo, PoolInfo } from '@tonkeeper/core/src/TonAPI'; +import { + AccountStakingInfo, + PoolImplementationType, + PoolInfo, +} from '@tonkeeper/core/src/TonAPI'; interface Props { pool: PoolInfo; @@ -50,6 +54,13 @@ const StakingWidgetStatusComponent: FC = (props) => { } if (hasPendingWithdraw) { + if (pool.implementation === PoolImplementationType.LiquidTF) { + return t('staking.message.pendingWithdrawLiquid', { + amount: stakingFormatter.format(pendingWithdraw.amount), + count: pendingWithdraw.totalTon, + }); + } + return ( <> {t('staking.message.pendingWithdraw', { diff --git a/packages/shared/i18n/locales/tonkeeper/en.json b/packages/shared/i18n/locales/tonkeeper/en.json index 817c69ce0..a604e9eff 100644 --- a/packages/shared/i18n/locales/tonkeeper/en.json +++ b/packages/shared/i18n/locales/tonkeeper/en.json @@ -685,11 +685,8 @@ "value" : "%{value} TON" }, "next_cycle" : { - "desc" : "All transactions take effect once the cycle ends.", - "desc_liquid" : "Unstake requests are complete after the cycle ends.", "in" : "in", - "reward_title" : "Next reward", - "title" : "Next cycle" + "message": "Unstake request will be processed after the end of the validation cycle in %{value}" }, "note" : "Staking is based on smart contracts by third parties. Tonkeeper is not responsible for staking experience.", "pendingDeposit" : "Pending Stake", @@ -706,6 +703,7 @@ "tap_to_collect" : "Tap to collect" }, "estimated_profit" : "%{amount} TON – annual profit\nif you stake TON today", + "estimated_profit_compare" : "More profitable by %{amount} TON per year than your current staking", "get_withdrawal" : "Get withdrawal", "highest_apy" : "MAX APY", "jetton_note" : "When you stake TON in a %{poolName} pool, you receive a token called %{token} that represents your share in the pool. As the pool accumulates profits, your %{token} represents larger amount of TON.", @@ -713,6 +711,7 @@ "message" : { "pendingDeposit" : "%{amount} TON staked\n", "pendingWithdraw" : "%{amount} TON unstaked\n", + "pendingWithdrawLiquid" : "%{amount} TON unstaked after the end of the cycle", "readyWithdraw" : "%{amount} TON ready.\nTap to collect" }, "no_funds" : "No funds available for unstake", @@ -748,7 +747,7 @@ "message" : "Please leave at least {{amount}} TON on your balance.", "title" : "You will have not enough funds for withdraw" }, - "withdrawal_request" : "Unstake Request" + "withdrawal_request" : "Unstake" }, "subscription_back_to_merchant_button" : "Back", "subscription_back_to_merchant_caption" : "The transaction is being processed. Your subscription will be active soon.", @@ -915,8 +914,9 @@ "insufficientFunds" : { "rechargeWallet" : "Recharge wallet", "stakingFee" : "%{count} TON needed for transaction. Estimated fee %{fee} TON will be deducted, the rest will be refunded.", + "stakingDeposit" : "Minimum balance for participate:\n%{amount} %{currency}\n", "title" : "Insufficient funds", - "toBePaid" : "To be paid: %{amount} %{currency}\n", + "toBePaid" : "To be paid: %{amount} %{currency}\n", "withFees" : "+ blockchain fees.\n", "yourBalance" : "Your balance: %{balance} %{currency}." }, diff --git a/packages/shared/i18n/locales/tonkeeper/ru-RU.json b/packages/shared/i18n/locales/tonkeeper/ru-RU.json index 6c2898183..917d7be8c 100644 --- a/packages/shared/i18n/locales/tonkeeper/ru-RU.json +++ b/packages/shared/i18n/locales/tonkeeper/ru-RU.json @@ -562,7 +562,7 @@ "insufficient_balance" : "Недостаточно средств", "less_than_min" : "Минимум %{minAmount} TON", "liquid_jetton_note" : "Отправка токена ликвидности tsTON", - "max" : "Максимум", + "max" : "Max", "recipient_label" : "Кому:", "remaining" : "Доступно: %{amount}", "title" : "Сумма" @@ -700,11 +700,8 @@ "value" : "%{value} TON" }, "next_cycle" : { - "desc" : "Все транзакции исполняются только после завершения цикла.", - "desc_liquid" : "Запросы на вывод исполнятся после завершения цикла.", "in" : "через", - "reward_title" : "Следующая награда", - "title" : "Следующий цикл" + "message": "Запрос на вывод будет исполнен после завершения цикла через %{value}" }, "note" : "Стейкинг основан на сторонних смарт-контрактах. Tonkeeper не несёт ответственность за стабильность и результат.", "pendingDeposit" : "Будет зачислено", @@ -720,6 +717,8 @@ }, "tap_to_collect" : "Нажмите, чтобы вывести" }, + "estimated_profit" : "%{amount} TON – доход за год, 
если внесёте TON сегодня.", + "estimated_profit_compare" : "На %{amount} TON в год прибыльнее чем ваш текущий стейкинг", "get_withdrawal" : "Получить вывод", "highest_apy" : "MAX APY", "jetton_note" : "Когда вы вносите TON в пул, вы получаете токен %{token}, который отображает вашу долю в пуле. По мере накопления прибыли в пуле, %{token} представляет всё большее число TON.", @@ -739,6 +738,13 @@ "other" : "%{amount} TON будет выведено\n", "zero" : "%{amount} TON будет выведено\n" }, + "pendingWithdrawLiquid" : { + "few" : "%{amount} TON будет выведено\nпосле окончания цикла", + "many" : "%{amount} TON будет выведено\nпосле окончания цикла", + "one" : "%{amount} TON будет выведен\nпосле окончания цикла", + "other" : "%{amount} TON будет выведено\nпосле окончания цикла", + "zero" : "%{amount} TON будет выведено\nпосле окончания цикла" + }, "readyWithdraw" : { "few" : "%{amount} TON готовы к выводу.\nНажмите, чтобы вывести", "many" : "%{amount} TON готовы к выводу.\nНажмите, чтобы вывести", @@ -958,6 +964,7 @@ "other" : "Для совершения транзакции необходим %{count} TON. Предполагаемая комиссия в размере %{fee} TON будет удержана, остаток будет возвращён.", "zero" : "Для совершения транзакции необходимо %{count} TON. Предполагаемая комиссия в размере %{fee} TON будет удержана, остаток будет возвращён." }, + "stakingDeposit" : "Минимальный баланс для участия:\n%{amount} %{currency}\n", "title" : "Недостаточно средств", "toBePaid" : "Необходимо: %{amount} %{currency}\n", "withFees" : "+ комиссия сети.\n",