diff --git a/packages/suite-desktop-core/src/config.ts b/packages/suite-desktop-core/src/config.ts index 7c6d4c7f186..e8b2f2937db 100644 --- a/packages/suite-desktop-core/src/config.ts +++ b/packages/suite-desktop-core/src/config.ts @@ -27,6 +27,7 @@ export const allowedDomains = [ 'blockfrost.dev', 'eth-api-b2c-stage.everstake.one', // staking endpoint for Holesky testnet, works only with VPN 'eth-api-b2c.everstake.one', // staking endpoint for Ethereum mainnet + 'dashboard-api.everstake.one', // staking enpoint for Solana ]; export const cspRules = [ diff --git a/packages/suite/src/components/suite/CoinList/CoinList.tsx b/packages/suite/src/components/suite/CoinList/CoinList.tsx index 4efa7a15d97..3813ab66946 100644 --- a/packages/suite/src/components/suite/CoinList/CoinList.tsx +++ b/packages/suite/src/components/suite/CoinList/CoinList.tsx @@ -9,6 +9,7 @@ import { Network, NetworkSymbol } from '@suite-common/wallet-config'; import { Translation } from 'src/components/suite'; import { useDevice, useDiscovery, useSelector } from 'src/hooks/suite'; import { getCoinLabel } from 'src/utils/suite/getCoinLabel'; +import { selectIsDebugModeActive } from 'src/reducers/suite/suiteReducer'; import { Coin } from './Coin'; @@ -35,6 +36,8 @@ export const CoinList = ({ onToggle, }: CoinListProps) => { const { device, isLocked } = useDevice(); + const isDebug = useSelector(selectIsDebugModeActive); + const blockchain = useSelector(state => state.wallet.blockchain); const isDeviceLocked = !!device && isLocked(); const { isDiscoveryRunning } = useDiscovery(); @@ -50,7 +53,14 @@ export const CoinList = ({ return ( {networks.map(network => { - const { symbol, name, support, features, testnet: isTestnet } = network; + const { + symbol, + name, + support, + features, + testnet: isTestnet, + networkType, + } = network; const hasCustomBackend = !!blockchain[symbol].backends.selected; const firmwareSupportRestriction = @@ -76,7 +86,13 @@ export const CoinList = ({ getCoinUnavailabilityMessage(unavailableReason); const tooltipString = discoveryTooltip || lockedTooltip || unavailabilityTooltip; - const label = getCoinLabel(features, isTestnet, hasCustomBackend); + const label = getCoinLabel( + features, + isTestnet, + hasCustomBackend, + networkType, + isDebug, + ); return ( { + switch (networkType) { + case 'ethereum': + return { + payoutDays: daysToAddToPool, + rewardsPeriodHeading: , + rewardsPeriodSubheading: ( + + ), + rewardsEarningHeading: , + }; + case 'solana': + return { + payoutDays: SOLANA_EPOCH_DAYS, + rewardsPeriodHeading: , + rewardsPeriodSubheading: , + rewardsEarningHeading: ( + + ), + }; + default: + return null; + } +}; + interface StakingInfoProps { isExpanded?: boolean; } @@ -33,13 +76,14 @@ export const StakingInfo = ({ isExpanded }: StakingInfoProps) => { selectAccountStakeTransactions(state, account?.key ?? ''), ); - const ethApy = useSelector((state: StakeRootState) => + const apy = useSelector((state: StakeRootState) => selectPoolStatsApyData(state, account?.symbol), ); if (!account) return null; const daysToAddToPool = getDaysToAddToPool(stakeTxs, data); + const infoRowsData = getInfoRowsData(account.networkType, account.symbol, daysToAddToPool); const infoRows = [ { @@ -47,25 +91,24 @@ export const StakingInfo = ({ isExpanded }: StakingInfoProps) => { content: { text: , isBadge: true }, }, { - heading: , - subheading: ( - - ), + heading: infoRowsData?.rewardsPeriodHeading, + subheading: infoRowsData?.rewardsPeriodSubheading, content: { text: ( <> - ~ + ~ + ), }, }, { - heading: , + heading: infoRowsData?.rewardsEarningHeading, subheading: , - content: { text: `~${ethApy}% p.a.` }, + content: { text: `~${apy}% p.a.` }, }, ]; diff --git a/packages/suite/src/components/suite/StakingProcess/UnstakingInfo.tsx b/packages/suite/src/components/suite/StakingProcess/UnstakingInfo.tsx index ae28b634b7b..8eed1076d36 100644 --- a/packages/suite/src/components/suite/StakingProcess/UnstakingInfo.tsx +++ b/packages/suite/src/components/suite/StakingProcess/UnstakingInfo.tsx @@ -10,7 +10,8 @@ import { StakeRootState, AccountsRootState, } from '@suite-common/wallet-core'; -import { getNetworkDisplaySymbol } from '@suite-common/wallet-config'; +import { getNetworkDisplaySymbol, NetworkSymbol, NetworkType } from '@suite-common/wallet-config'; +import { SOLANA_EPOCH_DAYS } from '@suite-common/wallet-constants'; import { Translation } from 'src/components/suite'; import { getDaysToUnstake } from 'src/utils/suite/ethereumStaking'; @@ -18,6 +19,40 @@ import { CoinjoinRootState } from 'src/reducers/wallet/coinjoinReducer'; import { InfoRow } from './InfoRow'; +type InfoRowsData = { + readyForClaimDays: number | undefined; + deactivatePeriodHeading: JSX.Element; + deactivatePeriodSubheading: JSX.Element; +}; + +const getInfoRowsData = ( + networkType: NetworkType, + accountSymbol: NetworkSymbol, + daysToUnstake?: number, +): InfoRowsData | null => { + switch (networkType) { + case 'ethereum': + return { + readyForClaimDays: daysToUnstake, + deactivatePeriodHeading: , + deactivatePeriodSubheading: ( + + ), + }; + case 'solana': + return { + readyForClaimDays: SOLANA_EPOCH_DAYS, + deactivatePeriodHeading: , + deactivatePeriodSubheading: , + }; + default: + return null; + } +}; + interface UnstakingInfoProps { isExpanded?: boolean; } @@ -36,6 +71,7 @@ export const UnstakingInfo = ({ isExpanded }: UnstakingInfoProps) => { const daysToUnstake = getDaysToUnstake(unstakeTxs, data); const displaySymbol = getNetworkDisplaySymbol(account.symbol); + const infoRowsData = getInfoRowsData(account.networkType, account.symbol, daysToUnstake); const infoRows = [ { @@ -46,15 +82,15 @@ export const UnstakingInfo = ({ isExpanded }: UnstakingInfoProps) => { }, }, { - heading: , - subheading: ( - - ), + heading: infoRowsData?.deactivatePeriodHeading, + subheading: infoRowsData?.deactivatePeriodSubheading, content: { - text: , + text: ( + + ), }, }, { diff --git a/packages/suite/src/components/suite/layouts/SuiteLayout/PageHeader/PageNames/AccountName/AccountDetails.tsx b/packages/suite/src/components/suite/layouts/SuiteLayout/PageHeader/PageNames/AccountName/AccountDetails.tsx index f2b44601429..ca39c7d0e2e 100644 --- a/packages/suite/src/components/suite/layouts/SuiteLayout/PageHeader/PageNames/AccountName/AccountDetails.tsx +++ b/packages/suite/src/components/suite/layouts/SuiteLayout/PageHeader/PageNames/AccountName/AccountDetails.tsx @@ -6,6 +6,7 @@ import { Account } from '@suite-common/wallet-types'; import { spacingsPx, zIndices, typography } from '@trezor/theme'; import { H2 } from '@trezor/components'; import { CoinLogo } from '@trezor/product-components'; +import { getNetworkDisplaySymbol } from '@suite-common/wallet-config'; import { MetadataLabeling, @@ -117,7 +118,7 @@ export const AccountDetails = ({ selectedAccount, isBalanceShown }: AccountDetai showAccountTypeBadge accountLabel={selectedAccountLabels.accountLabel} accountType={accountType} - symbol={selectedAccount.symbol} + symbol={getNetworkDisplaySymbol(selectedAccount.symbol)} index={index} path={path} networkType={selectedAccount.networkType} diff --git a/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/AdvancedCoinSettingsModal/AdvancedCoinSettingsModal.tsx b/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/AdvancedCoinSettingsModal/AdvancedCoinSettingsModal.tsx index 338d3de364c..19a1ab47bfb 100644 --- a/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/AdvancedCoinSettingsModal/AdvancedCoinSettingsModal.tsx +++ b/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/AdvancedCoinSettingsModal/AdvancedCoinSettingsModal.tsx @@ -7,6 +7,7 @@ import { CoinLogo } from '@trezor/product-components'; import { Modal, Translation } from 'src/components/suite'; import { getCoinLabel } from 'src/utils/suite/getCoinLabel'; import { useSelector } from 'src/hooks/suite'; +import { selectIsDebugModeActive } from 'src/reducers/suite/suiteReducer'; import { CustomBackends } from './CustomBackends/CustomBackends'; @@ -42,12 +43,13 @@ interface AdvancedCoinSettingsModalProps { } export const AdvancedCoinSettingsModal = ({ symbol, onCancel }: AdvancedCoinSettingsModalProps) => { + const isDebug = useSelector(selectIsDebugModeActive); const blockchain = useSelector(state => state.wallet.blockchain); const network = getNetwork(symbol); - const { name, features, testnet: isTestnet } = network; + const { name, networkType, features, testnet: isTestnet } = network; const hasCustomBackend = !!blockchain[symbol].backends.selected; - const label = getCoinLabel(features, isTestnet, hasCustomBackend); + const label = getCoinLabel(features, isTestnet, hasCustomBackend, networkType, isDebug); return ( { // used instead of formState.isValid, which is sometimes returning false even if there are no errors const formIsValid = Object.keys(errors).length === 0; - const { claimableAmount = '0' } = getAccountEverstakeStakingPool(selectedAccount.account) ?? {}; + const { claimableAmount = '0' } = getStakingDataForNetwork(selectedAccount.account) ?? {}; const isDisabled = !(formIsValid && hasValues) || isSubmitting || isLocked() || !device?.available; diff --git a/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/StakeModal/StakeEthForm/Inputs.tsx b/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/StakeModal/StakeEthForm/Inputs.tsx index 1a779b02531..b6ff0011572 100644 --- a/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/StakeModal/StakeEthForm/Inputs.tsx +++ b/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/StakeModal/StakeEthForm/Inputs.tsx @@ -15,7 +15,7 @@ import { validateReserveOrBalance, } from 'src/utils/suite/validation'; import { FIAT_INPUT, CRYPTO_INPUT } from 'src/types/wallet/stakeForms'; -import { validateStakingMax } from 'src/utils/suite/ethereumStaking'; +import { validateStakingMax } from 'src/utils/suite/staking'; import { FormFractionButtons } from 'src/components/suite/FormFractionButtons'; export const Inputs = () => { diff --git a/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/StakeModal/StakingInfoCards/EstimatedGains.tsx b/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/StakeModal/StakingInfoCards/EstimatedGains.tsx index e172a8371ca..fbaa0b2c14d 100644 --- a/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/StakeModal/StakingInfoCards/EstimatedGains.tsx +++ b/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/StakeModal/StakingInfoCards/EstimatedGains.tsx @@ -4,13 +4,13 @@ import { useSelector } from 'react-redux'; import { selectPoolStatsApyData, StakeRootState } from '@suite-common/wallet-core'; import { Column, Grid, Image, Paragraph, Text } from '@trezor/components'; import { negativeSpacings, spacings } from '@trezor/theme'; -import { HELP_CENTER_ETH_STAKING } from '@trezor/urls'; +import { HELP_CENTER_ETH_STAKING, HELP_CENTER_SOL_STAKING } from '@trezor/urls'; import { Translation } from 'src/components/suite/Translation'; import { useStakeEthFormContext } from 'src/hooks/wallet/useStakeEthForm'; import { CRYPTO_INPUT } from 'src/types/wallet/stakeForms'; import { FiatValue, FormattedCryptoAmount, TrezorLink } from 'src/components/suite'; -import { calculateGains } from 'src/utils/suite/ethereumStaking'; +import { calculateGains } from 'src/utils/suite/staking'; export const EstimatedGains = () => { const { account, getValues, formState } = useStakeEthFormContext(); @@ -22,22 +22,22 @@ export const EstimatedGains = () => { const cryptoInput = hasInvalidFormState || !value ? '0' : value; - const ethApy = useSelector((state: StakeRootState) => + const apy = useSelector((state: StakeRootState) => selectPoolStatsApyData(state, account?.symbol), ); const gains = [ { label: , - value: calculateGains(cryptoInput, ethApy, 52), + value: calculateGains(cryptoInput, apy, 52), }, { label: , - value: calculateGains(cryptoInput, ethApy, 12), + value: calculateGains(cryptoInput, apy, 12), }, { label: , - value: calculateGains(cryptoInput, ethApy, 1), + value: calculateGains(cryptoInput, apy, 1), }, ]; @@ -45,7 +45,7 @@ export const EstimatedGains = () => { - {ethApy}% + {apy}% { id="TR_STAKING_YOUR_EARNINGS" values={{ a: chunks => ( - {chunks} + + {chunks} + ), }} /> diff --git a/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/UnstakeModal/UnstakeEthForm/Options.tsx b/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/UnstakeModal/UnstakeEthForm/Options.tsx index 821c91748a2..f6ebca7d644 100644 --- a/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/UnstakeModal/UnstakeEthForm/Options.tsx +++ b/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/UnstakeModal/UnstakeEthForm/Options.tsx @@ -6,7 +6,7 @@ import { Radio, Column, Row, Text } from '@trezor/components'; import { spacings } from '@trezor/theme'; import { NetworkSymbol } from '@suite-common/wallet-config'; import { BigNumber } from '@trezor/utils/src/bigNumber'; -import { getAccountEverstakeStakingPool } from '@suite-common/wallet-utils'; +import { getStakingDataForNetwork } from '@suite-common/wallet-utils'; import { FiatValue, FormattedCryptoAmount, Translation } from 'src/components/suite'; import { useSelector } from 'src/hooks/suite'; @@ -70,7 +70,7 @@ export const Options = ({ symbol }: OptionsProps) => { autocompoundBalance = '0', depositedBalance = '0', restakedReward = '0', - } = getAccountEverstakeStakingPool(selectedAccount) ?? {}; + } = getStakingDataForNetwork(selectedAccount) ?? {}; return ( diff --git a/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/UnstakeModal/UnstakeEthForm/UnstakeEthForm.tsx b/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/UnstakeModal/UnstakeEthForm/UnstakeEthForm.tsx index d97d303a610..6a625a340b1 100644 --- a/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/UnstakeModal/UnstakeEthForm/UnstakeEthForm.tsx +++ b/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/UnstakeModal/UnstakeEthForm/UnstakeEthForm.tsx @@ -1,8 +1,8 @@ import { InfoItem, Tooltip, Banner, Column, Card } from '@trezor/components'; import { spacings } from '@trezor/theme'; import { selectValidatorsQueueData } from '@suite-common/wallet-core'; -import { getAccountEverstakeStakingPool } from '@suite-common/wallet-utils'; import { BigNumber } from '@trezor/utils/src/bigNumber'; +import { getStakingDataForNetwork } from '@suite-common/wallet-utils'; import { Translation } from 'src/components/suite'; import { useSelector } from 'src/hooks/suite'; @@ -40,7 +40,7 @@ export const UnstakeEthForm = () => { ); const unstakingPeriod = getUnstakingPeriodInDays(validatorWithdrawTime); const { canClaim = false, claimableAmount = '0' } = - getAccountEverstakeStakingPool(selectedAccount) ?? {}; + getStakingDataForNetwork(selectedAccount) ?? {}; const inputError = errors[CRYPTO_INPUT] || errors[FIAT_INPUT]; const showError = inputError && inputError.type === 'compose'; diff --git a/packages/suite/src/components/wallet/WalletLayout/AccountBanners/StakeEthBanner.tsx b/packages/suite/src/components/wallet/WalletLayout/AccountBanners/StakeEthBanner.tsx index 0f51f767252..b5727033621 100644 --- a/packages/suite/src/components/wallet/WalletLayout/AccountBanners/StakeEthBanner.tsx +++ b/packages/suite/src/components/wallet/WalletLayout/AccountBanners/StakeEthBanner.tsx @@ -23,7 +23,7 @@ export const StakeEthBanner = ({ account }: StakeEthBannerProps) => { const dispatch = useDispatch(); const { stakeEthBannerClosed } = useSelector(selectSuiteFlags); const { route } = useSelector(state => state.router); - const ethApy = useSelector(state => selectPoolStatsApyData(state, account.symbol)); + const apy = useSelector(state => selectPoolStatsApyData(state, account.symbol)); const theme = useTheme(); const closeBanner = () => { @@ -65,7 +65,7 @@ export const StakeEthBanner = ({ account }: StakeEthBannerProps) => { selectCoinDefinitions(state, symbol)); - const hasStaked = useSelector(state => selectAccountHasStaked(state, account.key)); + const hasEthStaked = useSelector(state => selectEthAccountHasStaked(state, account.key)); const solStakingAccounts = useSelector(state => selectSolStakingAccounts(state, account.key)); // TODO: remove isDebugModeActive when staking will be ready for launch - const hasStakingAccount = !!solStakingAccounts?.length && isDebugModeActive; // for solana + const hasSolStakingAccount = !!solStakingAccounts?.length && isDebugModeActive; // for solana const isStakeShown = - isSupportedStakingNetworkSymbol(symbol) && (hasStaked || hasStakingAccount); + isSupportedStakingNetworkSymbol(symbol) && (hasEthStaked || hasSolStakingAccount); const showGroup = ['ethereum', 'solana', 'cardano'].includes(networkType); diff --git a/packages/suite/src/hooks/wallet/useUnstakeEthForm.ts b/packages/suite/src/hooks/wallet/useUnstakeEthForm.ts index 94ea4298d29..f6c7270a226 100644 --- a/packages/suite/src/hooks/wallet/useUnstakeEthForm.ts +++ b/packages/suite/src/hooks/wallet/useUnstakeEthForm.ts @@ -5,9 +5,9 @@ import useDebounce from 'react-use/lib/useDebounce'; import { fromFiatCurrency, - getAccountAutocompoundBalance, getFeeLevels, getFiatRateKey, + getStakingDataForNetwork, toFiatCurrency, } from '@suite-common/wallet-utils'; import { PrecomposedTransactionFinal } from '@suite-common/wallet-types'; @@ -70,7 +70,7 @@ export const useUnstakeEthForm = ({ selectFiatRatesByFiatRateKey(state, getFiatRateKey(symbol, localCurrency), 'current'), ); - const autocompoundBalance = getAccountAutocompoundBalance(account); + const { autocompoundBalance = '0' } = getStakingDataForNetwork(account) ?? {}; const amountLimits: AmountLimitProps = { currency: symbol, maxCrypto: autocompoundBalance, diff --git a/packages/suite/src/support/messages.ts b/packages/suite/src/support/messages.ts index 7ca90b1187b..fc4ff6bfc8e 100644 --- a/packages/suite/src/support/messages.ts +++ b/packages/suite/src/support/messages.ts @@ -8746,6 +8746,26 @@ export default defineMessages({ id: 'TR_STAKE_ENTER_THE_STAKING_POOL', defaultMessage: 'Enter the staking pool', }, + TR_STAKE_WAIT_FOR_ACTIVATION: { + id: 'TR_STAKE_WAIT_FOR_ACTIVATION', + defaultMessage: 'Wait for the next epoch until your stake activated', + }, + TR_STAKE_WARM_UP_PERIOD: { + id: 'TR_STAKE_WARM_UP_PERIOD', + defaultMessage: 'Warm Up period', + }, + TR_STAKE_EARN_REWARDS_EVERY: { + id: 'TR_STAKE_EARN_REWARDS_EVERY', + defaultMessage: 'Earn rewards every ~{days} days', + }, + TR_STAKE_COOL_DOWN_PERIOD: { + id: 'TR_STAKE_COOL_DOWN_PERIOD', + defaultMessage: 'Cool Down Period', + }, + TR_STAKE_WAIT_FOR_DEACTIVATION: { + id: 'TR_STAKE_WAIT_FOR_DEACTIVATION', + defaultMessage: 'Wait for the next epoch until your stake deactivated', + }, TR_STAKE_EARN_REWARDS_WEEKLY: { id: 'TR_STAKE_EARN_REWARDS_WEEKLY', defaultMessage: 'Earn rewards weekly', @@ -8900,6 +8920,10 @@ export default defineMessages({ id: 'TR_STAKE_REWARDS', defaultMessage: 'Rewards', }, + TR_STAKE_EXPECTED_REWARDS: { + id: 'TR_STAKE_EXPECTED_REWARDS', + defaultMessage: 'Expected rewards per 1 epoch (~{days} days)', + }, TR_TX_CONFIRMED: { id: 'TR_TX_CONFIRMED', defaultMessage: 'Transaction confirmed', diff --git a/packages/suite/src/utils/suite/ethereumStaking.ts b/packages/suite/src/utils/suite/ethereumStaking.ts index 3b91937d955..7799398a83d 100644 --- a/packages/suite/src/utils/suite/ethereumStaking.ts +++ b/packages/suite/src/utils/suite/ethereumStaking.ts @@ -28,8 +28,6 @@ import { ValidatorsQueue } from '@suite-common/wallet-core'; import { BlockchainEstimatedFee } from '@trezor/connect/src/types/api/blockchainEstimateFee'; import { PartialRecord } from '@trezor/type-utils'; -import { TranslationFunction } from 'src/hooks/suite/useTranslation'; - const secondsToDays = (seconds: number) => Math.round(seconds / 60 / 60 / 24); type EthNetwork = 'holesky' | 'mainnet'; @@ -630,27 +628,6 @@ export const getChangedInternalTx = ( return internalTransfer ?? null; }; -export const calculateGains = (input: string, apy: number, divisor: number) => { - const amount = new BigNumber(input).multipliedBy(apy).dividedBy(100).dividedBy(divisor); - - return amount.toFixed(5, 1); -}; - -interface ValidateMaxOptions { - maxAmount: BigNumber; - except?: boolean; -} - -export const validateStakingMax = - (translationString: TranslationFunction, { except, maxAmount }: ValidateMaxOptions) => - (value: string) => { - if (!except && value && BigNumber(value).gt(maxAmount)) { - return translationString('AMOUNT_EXCEEDS_MAX', { - maxAmount: maxAmount.toString(), - }); - } - }; - export const simulateUnstake = async ({ amount, from, diff --git a/packages/suite/src/utils/suite/getCoinLabel.ts b/packages/suite/src/utils/suite/getCoinLabel.ts index 66623ee5865..47a65fd6853 100644 --- a/packages/suite/src/utils/suite/getCoinLabel.ts +++ b/packages/suite/src/utils/suite/getCoinLabel.ts @@ -1,13 +1,19 @@ import type { TranslationKey } from '@suite-common/intl-types'; -import type { NetworkFeature } from '@suite-common/wallet-config'; +import type { NetworkFeature, NetworkType } from '@suite-common/wallet-config'; export const getCoinLabel = ( features: NetworkFeature[], isTestnet: boolean, isCustomBackend: boolean, + networkType?: NetworkType, + isDebug?: boolean, ): TranslationKey | undefined => { const hasTokens = features.includes('tokens'); - const hasStaking = features.includes('staking'); + // TODO: Remove this condition when Solana staking is available + const hasStaking = + networkType === 'solana' + ? isDebug && features.includes('staking') // Solana staking only in debug mode + : features.includes('staking'); if (isCustomBackend) { return 'TR_CUSTOM_BACKEND'; diff --git a/packages/suite/src/utils/suite/staking.ts b/packages/suite/src/utils/suite/staking.ts new file mode 100644 index 00000000000..dd32d8660a4 --- /dev/null +++ b/packages/suite/src/utils/suite/staking.ts @@ -0,0 +1,24 @@ +import { BigNumber } from '@trezor/utils'; + +import { TranslationFunction } from 'src/hooks/suite/useTranslation'; + +interface ValidateMaxOptions { + maxAmount: BigNumber; + except?: boolean; +} + +export const validateStakingMax = + (translationString: TranslationFunction, { except, maxAmount }: ValidateMaxOptions) => + (value: string) => { + if (!except && value && BigNumber(value).gt(maxAmount)) { + return translationString('AMOUNT_EXCEEDS_MAX', { + maxAmount: maxAmount.toString(), + }); + } + }; + +export const calculateGains = (input: string, apy: number, divisor: number) => { + const amount = new BigNumber(input).multipliedBy(apy).dividedBy(100).dividedBy(divisor); + + return amount.toFixed(5, 1); +}; diff --git a/packages/suite/src/views/dashboard/StakeEthCard/StakeEthCard.tsx b/packages/suite/src/views/dashboard/StakeEthCard/StakeEthCard.tsx index ced38e360bb..fa8c861b273 100644 --- a/packages/suite/src/views/dashboard/StakeEthCard/StakeEthCard.tsx +++ b/packages/suite/src/views/dashboard/StakeEthCard/StakeEthCard.tsx @@ -46,7 +46,7 @@ export const StakeEthCard = () => { showDashboardStakingPromoBanner && !isBitcoinOnlyDevice; - const ethApy = useSelector(state => selectPoolStatsApyData(state, bannerSymbol)); + const apy = useSelector(state => selectPoolStatsApyData(state, bannerSymbol)); const { discovery } = useDiscovery(); const { accounts } = useAccounts(discovery); @@ -72,7 +72,7 @@ export const StakeEthCard = () => { ( { description: , }, ], - [ethApy], + [apy], ); if (!isShown) return null; diff --git a/packages/suite/src/views/wallet/staking/components/EthStakingDashboard/components/EthStakingDashboard.tsx b/packages/suite/src/views/wallet/staking/components/EthStakingDashboard/components/EthStakingDashboard.tsx index ace64bc2718..6076c420c33 100644 --- a/packages/suite/src/views/wallet/staking/components/EthStakingDashboard/components/EthStakingDashboard.tsx +++ b/packages/suite/src/views/wallet/staking/components/EthStakingDashboard/components/EthStakingDashboard.tsx @@ -10,24 +10,25 @@ import { selectPoolStatsNextRewardPayout, selectValidatorsQueue, } from '@suite-common/wallet-core'; -import { getAccountEverstakeStakingPool } from '@suite-common/wallet-utils'; -import { SelectedAccountStatus } from '@suite-common/wallet-types'; +import { getStakingDataForNetwork } from '@suite-common/wallet-utils'; +import { getNetworkDisplaySymbol } from '@suite-common/wallet-config'; +import { SelectedAccountLoaded } from '@suite-common/wallet-types'; import { useDispatch, useSelector } from 'src/hooks/suite'; import { Translation } from 'src/components/suite'; import { DashboardSection } from 'src/components/dashboard'; import { getDaysToAddToPool, getDaysToUnstake } from 'src/utils/suite/ethereumStaking'; -import { StakingCard } from './StakingCard'; -import { ApyCard } from './ApyCard'; -import { PayoutCard } from './PayoutCard'; -import { ClaimCard } from './ClaimCard'; +import { StakingCard } from '../../StakingDashboard/components/StakingCard'; +import { ClaimCard } from '../../StakingDashboard/components/ClaimCard'; +import { StakingDashboard } from '../../StakingDashboard/StakingDashboard'; +import { ApyCard } from '../../StakingDashboard/components/ApyCard'; +import { PayoutCard } from '../../StakingDashboard/components/PayoutCard'; import { Transactions } from './Transactions'; import { InstantStakeBanner } from './InstantStakeBanner'; -import { StakingDashboard } from '../../StakingDashboard/StakingDashboard'; interface EthStakingDashboardProps { - selectedAccount: SelectedAccountStatus; + selectedAccount: SelectedAccountLoaded; } export const EthStakingDashboard = ({ selectedAccount }: EthStakingDashboardProps) => { @@ -38,7 +39,7 @@ export const EthStakingDashboard = ({ selectedAccount }: EthStakingDashboardProp const { data, isLoading } = useSelector(state => selectValidatorsQueue(state, account?.symbol)) || {}; - const ethApy = useSelector(state => selectPoolStatsApyData(state, account?.symbol)); + const apy = useSelector(state => selectPoolStatsApyData(state, account?.symbol)); const nextRewardPayout = useSelector(state => selectPoolStatsNextRewardPayout(state, account?.symbol), ); @@ -64,7 +65,7 @@ export const EthStakingDashboard = ({ selectedAccount }: EthStakingDashboardProp const daysToAddToPool = getDaysToAddToPool(stakeTxs, data); const daysToUnstake = getDaysToUnstake(unstakeTxs, data); - const { canClaim = false } = getAccountEverstakeStakingPool(account) ?? {}; + const { canClaim = false } = getStakingDataForNetwork(account) ?? {}; return ( } > @@ -88,7 +89,7 @@ export const EthStakingDashboard = ({ selectedAccount }: EthStakingDashboardProp - + { - const progressLabelsData: ProgressLabelData[] = useMemo( - () => [ - { - id: 0, - progressState: (() => { - if (isStakeConfirming) return 'active'; - - return 'done'; - })(), - children: isStakeConfirming ? ( - - ) : ( - - ), - }, - { - id: 1, - progressState: (() => { - if (!isStakeConfirming && isStakePending) return 'active'; - if (!isStakeConfirming && !isStakePending) return 'done'; - - return 'stale'; - })(), - children: ( - - - {isDaysToAddToPoolShown && ( - - ~ - - - )} - - ), - }, - { - id: 2, - progressState: (() => { - if (!isStakeConfirming && !isStakePending) { - return 'active'; - } - - return 'stale'; - })(), - children: , - }, - ], - [daysToAddToPool, isDaysToAddToPoolShown, isStakeConfirming, isStakePending], - ); - - return { progressLabelsData }; -}; diff --git a/packages/suite/src/views/wallet/staking/components/SolStakingDashboard/SolStakingDashboard.tsx b/packages/suite/src/views/wallet/staking/components/SolStakingDashboard/SolStakingDashboard.tsx index 98db57dae5b..f2c41e5a615 100644 --- a/packages/suite/src/views/wallet/staking/components/SolStakingDashboard/SolStakingDashboard.tsx +++ b/packages/suite/src/views/wallet/staking/components/SolStakingDashboard/SolStakingDashboard.tsx @@ -1,17 +1,74 @@ -import { SelectedAccountStatus } from '@suite-common/wallet-types'; +import { useSelector } from 'react-redux'; + +import { Column, Flex, Grid, useMediaQuery, variables } from '@trezor/components'; +import { spacings } from '@trezor/theme'; +import { SelectedAccountLoaded } from '@suite-common/wallet-types'; +import { selectPoolStatsApyData, StakeRootState } from '@suite-common/wallet-core'; +import { SOLANA_EPOCH_DAYS } from '@suite-common/wallet-constants'; +import { getStakingDataForNetwork } from '@suite-common/wallet-utils'; +import { getNetworkDisplaySymbol } from '@suite-common/wallet-config'; + +import { DashboardSection } from 'src/components/dashboard'; +import { Translation } from 'src/components/suite'; import { StakingDashboard } from '../StakingDashboard/StakingDashboard'; +import { ApyCard } from '../StakingDashboard/components/ApyCard'; +import { PayoutCard } from '../StakingDashboard/components/PayoutCard'; +import { ClaimCard } from '../StakingDashboard/components/ClaimCard'; +import { StakingCard } from '../StakingDashboard/components/StakingCard'; interface SolStakingDashboardProps { - selectedAccount: SelectedAccountStatus; + selectedAccount: SelectedAccountLoaded; } export const SolStakingDashboard = ({ selectedAccount }: SolStakingDashboardProps) => { + const { account } = selectedAccount; + + const isBelowLaptop = useMediaQuery(`(max-width: ${variables.SCREEN_SIZE.LG})`); + + const { canClaim = false } = getStakingDataForNetwork(account) ?? {}; + + const apy = useSelector((state: StakeRootState) => + selectPoolStatsApyData(state, account?.symbol), + ); + return ( } + dashboard={ + + + } + > + + + + + + + + + + + + {/* TODO: implement Transactions component */} + {/* */} + + } /> ); }; diff --git a/packages/suite/src/views/wallet/staking/components/StakingDashboard/StakingDashboard.tsx b/packages/suite/src/views/wallet/staking/components/StakingDashboard/StakingDashboard.tsx index 85871f348a4..6d71c3634d1 100644 --- a/packages/suite/src/views/wallet/staking/components/StakingDashboard/StakingDashboard.tsx +++ b/packages/suite/src/views/wallet/staking/components/StakingDashboard/StakingDashboard.tsx @@ -1,4 +1,4 @@ -import { selectAccountHasStaked, selectSolStakingAccounts } from '@suite-common/wallet-core'; +import { selectEthAccountHasStaked, selectSolStakingAccounts } from '@suite-common/wallet-core'; import { SelectedAccountStatus } from '@suite-common/wallet-types'; import { getNetworkDisplaySymbol } from '@suite-common/wallet-config'; @@ -14,16 +14,17 @@ interface StakingDashboardProps { } export const StakingDashboard = ({ selectedAccount, dashboard }: StakingDashboardProps) => { - const hasStaked = useSelector(state => - selectAccountHasStaked(state, selectedAccount?.account?.key ?? ''), + const hasEthStaked = useSelector(state => + selectEthAccountHasStaked(state, selectedAccount.account?.key ?? ''), + ); + const solStakingAccounts = useSelector(state => + selectSolStakingAccounts(state, selectedAccount.account?.key), ); - const { account } = selectedAccount; - - const solStakingAccounts = useSelector(state => selectSolStakingAccounts(state, account?.key)); + if (selectedAccount.status !== 'loaded') return null; const hasSolStakingAccount = !!solStakingAccounts?.length; - const shouldShowDashboard = hasStaked || hasSolStakingAccount; + const shouldShowDashboard = hasEthStaked || hasSolStakingAccount; return ( { const isClaimPending = useMemo(() => claimTxs.some(tx => isPending(tx)), [claimTxs]); const { canClaim = false, claimableAmount = '0' } = - getAccountEverstakeStakingPool(selectedAccount) ?? {}; + getStakingDataForNetwork(selectedAccount) ?? {}; // Show success message when claim tx confirmation is complete. const prevIsClaimPending = useRef(false); diff --git a/packages/suite/src/views/wallet/staking/components/StakingDashboard/components/EmptyStakingCard.tsx b/packages/suite/src/views/wallet/staking/components/StakingDashboard/components/EmptyStakingCard.tsx index a616668c185..534ed99825b 100644 --- a/packages/suite/src/views/wallet/staking/components/StakingDashboard/components/EmptyStakingCard.tsx +++ b/packages/suite/src/views/wallet/staking/components/StakingDashboard/components/EmptyStakingCard.tsx @@ -30,8 +30,7 @@ export const EmptyStakingCard = () => { const { isStakingDisabled, stakingMessageContent } = useMessageSystemStaking(); - const ethApy = useSelector(state => selectPoolStatsApyData(state, account?.symbol)); - // TODO: calc solApy + const apy = useSelector(state => selectPoolStatsApyData(state, account?.symbol)); const dispatch = useDispatch(); const openStakingEthInANutshellModal = () => { @@ -52,8 +51,8 @@ export const EmptyStakingCard = () => { ( { description: , }, ], - [ethApy, displaySymbol], + [apy, displaySymbol], ); return ( diff --git a/packages/suite/src/views/wallet/staking/components/EthStakingDashboard/components/PayoutCard.tsx b/packages/suite/src/views/wallet/staking/components/StakingDashboard/components/PayoutCard.tsx similarity index 93% rename from packages/suite/src/views/wallet/staking/components/EthStakingDashboard/components/PayoutCard.tsx rename to packages/suite/src/views/wallet/staking/components/StakingDashboard/components/PayoutCard.tsx index 35e5a935727..a41a203ea87 100644 --- a/packages/suite/src/views/wallet/staking/components/EthStakingDashboard/components/PayoutCard.tsx +++ b/packages/suite/src/views/wallet/staking/components/StakingDashboard/components/PayoutCard.tsx @@ -4,7 +4,7 @@ import { BigNumber } from '@trezor/utils/src/bigNumber'; import { Card, Column, Icon, Paragraph } from '@trezor/components'; import { spacings } from '@trezor/theme'; import { BACKUP_REWARD_PAYOUT_DAYS } from '@suite-common/wallet-constants'; -import { getAccountAutocompoundBalance } from '@suite-common/wallet-utils'; +import { getStakingDataForNetwork } from '@suite-common/wallet-utils'; import { Translation } from 'src/components/suite'; import { selectSelectedAccount } from 'src/reducers/wallet/selectedAccountReducer'; @@ -23,7 +23,8 @@ export const PayoutCard = ({ }: PayoutCardProps) => { const selectedAccount = useSelector(selectSelectedAccount); - const autocompoundBalance = getAccountAutocompoundBalance(selectedAccount); + const { autocompoundBalance = '0' } = getStakingDataForNetwork(selectedAccount) ?? {}; + const payout = useMemo(() => { if (!nextRewardPayout || !daysToAddToPool) return undefined; diff --git a/packages/suite/src/views/wallet/staking/components/EthStakingDashboard/components/ProgressLabels/ProgressLabel.tsx b/packages/suite/src/views/wallet/staking/components/StakingDashboard/components/ProgressLabels/ProgressLabel.tsx similarity index 100% rename from packages/suite/src/views/wallet/staking/components/EthStakingDashboard/components/ProgressLabels/ProgressLabel.tsx rename to packages/suite/src/views/wallet/staking/components/StakingDashboard/components/ProgressLabels/ProgressLabel.tsx diff --git a/packages/suite/src/views/wallet/staking/components/EthStakingDashboard/components/ProgressLabels/ProgressLabels.tsx b/packages/suite/src/views/wallet/staking/components/StakingDashboard/components/ProgressLabels/ProgressLabels.tsx similarity index 100% rename from packages/suite/src/views/wallet/staking/components/EthStakingDashboard/components/ProgressLabels/ProgressLabels.tsx rename to packages/suite/src/views/wallet/staking/components/StakingDashboard/components/ProgressLabels/ProgressLabels.tsx diff --git a/packages/suite/src/views/wallet/staking/components/EthStakingDashboard/components/ProgressLabels/types.ts b/packages/suite/src/views/wallet/staking/components/StakingDashboard/components/ProgressLabels/types.ts similarity index 100% rename from packages/suite/src/views/wallet/staking/components/EthStakingDashboard/components/ProgressLabels/types.ts rename to packages/suite/src/views/wallet/staking/components/StakingDashboard/components/ProgressLabels/types.ts diff --git a/packages/suite/src/views/wallet/staking/components/EthStakingDashboard/components/StakingCard.tsx b/packages/suite/src/views/wallet/staking/components/StakingDashboard/components/StakingCard.tsx similarity index 86% rename from packages/suite/src/views/wallet/staking/components/EthStakingDashboard/components/StakingCard.tsx rename to packages/suite/src/views/wallet/staking/components/StakingDashboard/components/StakingCard.tsx index 80ff767f307..a5ad8af633f 100644 --- a/packages/suite/src/views/wallet/staking/components/EthStakingDashboard/components/StakingCard.tsx +++ b/packages/suite/src/views/wallet/staking/components/StakingDashboard/components/StakingCard.tsx @@ -16,7 +16,13 @@ import { } from '@trezor/components'; import { spacings } from '@trezor/theme'; import { selectAccountStakeTransactions } from '@suite-common/wallet-core'; -import { getAccountEverstakeStakingPool, isPending } from '@suite-common/wallet-utils'; +import { + calculateSolanaStakingReward, + getStakingAccountCurrentStatus, + getStakingDataForNetwork, + isPending, +} from '@suite-common/wallet-utils'; +import { SOLANA_EPOCH_DAYS } from '@suite-common/wallet-constants'; import { getNetworkDisplaySymbol, type NetworkSymbol } from '@suite-common/wallet-config'; import { FiatValue, Translation, FormattedCryptoAmount } from 'src/components/suite'; @@ -65,12 +71,14 @@ type StakingCardProps = { isValidatorsQueueLoading?: boolean; daysToAddToPool?: number; daysToUnstake?: number; + apy?: number; }; export const StakingCard = ({ isValidatorsQueueLoading, daysToAddToPool, daysToUnstake, + apy, }: StakingCardProps) => { const selectedAccount = useSelector(selectSelectedAccount); const isBelowLaptop = useMediaQuery(`(max-width: ${variables.SCREEN_SIZE.LG})`); @@ -89,7 +97,7 @@ export const StakingCard = ({ totalPendingStakeBalance = '0', withdrawTotalAmount = '0', claimableAmount = '0', - } = getAccountEverstakeStakingPool(selectedAccount) ?? {}; + } = getStakingDataForNetwork(selectedAccount) ?? {}; const canUnstake = new BigNumber(autocompoundBalance).gt(0); const isStakePending = new BigNumber(totalPendingStakeBalance).gt(0); @@ -110,11 +118,15 @@ export const StakingCard = ({ ); const isStakeConfirming = stakeTxs.some(tx => isPending(tx)); - const { progressLabelsData } = useProgressLabelsData({ + const solStakingAccountStatus = getStakingAccountCurrentStatus(selectedAccount); + + const progressLabelsData = useProgressLabelsData({ daysToAddToPool, isDaysToAddToPoolShown, isStakeConfirming, isStakePending, + selectedAccount, + solStakingAccountStatus, }); const dispatch = useDispatch(); @@ -133,6 +145,10 @@ export const StakingCard = ({ return null; } + const solReward = calculateSolanaStakingReward(depositedBalance, apy?.toString()); + + const stakingReward = selectedAccount.networkType === 'solana' ? solReward : restakedReward; + return ( @@ -151,11 +167,10 @@ export const StakingCard = ({ data-testid="@account/staking/pending" /> )} - } iconName="lock" - symbol={selectedAccount.symbol} + symbol={selectedAccount?.symbol} cryptoAmount={depositedBalance} fiatAmount={depositedBalance} data-testid="@account/staking/staked" @@ -164,7 +179,16 @@ export const StakingCard = ({ - + - {isPendingUnstakeShown && ( { + const ethereumProgressLabelsData: ProgressLabelData[] = useMemo( + () => [ + { + id: 0, + progressState: (() => { + if (isStakeConfirming) return 'active'; + + return 'done'; + })(), + children: isStakeConfirming ? ( + + ) : ( + + ), + }, + { + id: 1, + progressState: (() => { + if (!isStakeConfirming && isStakePending) return 'active'; + if (!isStakeConfirming && !isStakePending) return 'done'; + + return 'stale'; + })(), + children: ( + + + {isDaysToAddToPoolShown && ( + + ~ + + + )} + + ), + }, + { + id: 2, + progressState: (() => { + if (!isStakeConfirming && !isStakePending) { + return 'active'; + } + + return 'stale'; + })(), + children: , + }, + ], + [daysToAddToPool, isDaysToAddToPoolShown, isStakeConfirming, isStakePending], + ); + + const solanaProgressLabelsData: ProgressLabelData[] = useMemo( + () => [ + { + id: 0, + progressState: (() => { + if (solStakingAccountStatus === 'inactive') return 'active'; + + return 'done'; + })(), + children: isStakeConfirming ? ( + + ) : ( + + ), + }, + { + id: 1, + progressState: (() => { + if (solStakingAccountStatus === 'activating') return 'active'; + if (solStakingAccountStatus !== 'activating') return 'done'; + + return 'stale'; + })(), + children: ( + + + + + ~ + + + + ), + }, + { + id: 2, + progressState: (() => { + if (solStakingAccountStatus === 'active') { + return 'active'; + } + + return 'stale'; + })(), + children: ( + + + + + ~ + + + + ), + }, + ], + [solStakingAccountStatus, isStakeConfirming], + ); + + switch (selectedAccount?.networkType) { + case 'ethereum': + return ethereumProgressLabelsData; + case 'solana': + return solanaProgressLabelsData; + default: + return []; + } +}; diff --git a/packages/urls/src/urls.ts b/packages/urls/src/urls.ts index 308cbddf7d6..2bf689cb709 100644 --- a/packages/urls/src/urls.ts +++ b/packages/urls/src/urls.ts @@ -96,6 +96,8 @@ export const HELP_CENTER_DEVICE_AUTHENTICATION: Url = 'https://trezor.io/learn/a/trezor-safe-device-authentication-check'; export const HELP_CENTER_ETH_STAKING: Url = 'https://trezor.io/learn/a/stake-ethereum-eth-in-trezor-suite'; +export const HELP_CENTER_SOL_STAKING: Url = + 'https://trezor.io/learn/a/stake-solana-sol-in-trezor-suite'; export const HELP_CENTER_SEED_CARD_URL: Url = 'https://trezor.io/learn/a/recovery-seed-card'; export const HELP_CENTER_MULTI_SHARE_BACKUP_URL: Url = 'https://trezor.io/learn/a/multi-share-backup-on-trezor'; diff --git a/suite-common/wallet-constants/src/solanaStakingConstants.ts b/suite-common/wallet-constants/src/solanaStakingConstants.ts index 02b4d4f12ac..a5dd6362051 100644 --- a/suite-common/wallet-constants/src/solanaStakingConstants.ts +++ b/suite-common/wallet-constants/src/solanaStakingConstants.ts @@ -1,9 +1,9 @@ import { BigNumber } from '@trezor/utils'; -// TODO: change to solana constants -export const MIN_SOL_AMOUNT_FOR_STAKING = new BigNumber(0.1); -export const MAX_SOL_AMOUNT_FOR_STAKING = new BigNumber(1_000_000); -export const MIN_SOL_FOR_WITHDRAWALS = new BigNumber(0.03); +export const BACKUP_SOL_APY = 6.87; +export const MIN_SOL_AMOUNT_FOR_STAKING = new BigNumber(0.01); +export const MAX_SOL_AMOUNT_FOR_STAKING = new BigNumber(10_000_000); +export const MIN_SOL_FOR_WITHDRAWALS = new BigNumber(0.000005); export const MIN_SOL_BALANCE_FOR_STAKING = MIN_SOL_AMOUNT_FOR_STAKING.plus(MIN_SOL_FOR_WITHDRAWALS); -export const LAMPORTS_PER_SOL = 1_000_000_000; +export const SOLANA_EPOCH_DAYS = 3; diff --git a/suite-common/wallet-constants/src/stakingConstants.ts b/suite-common/wallet-constants/src/stakingConstants.ts index f9edbf858a2..98cfac37cdc 100644 --- a/suite-common/wallet-constants/src/stakingConstants.ts +++ b/suite-common/wallet-constants/src/stakingConstants.ts @@ -3,3 +3,5 @@ // It is a constant which allows the SDK to define which app calls its functions. // Each app which integrates the SDK has its own source, e.g. source for Trezor Suite is '1'. export const WALLET_SDK_SOURCE = '1'; + +export const BACKUP_APY = 4.13; diff --git a/suite-common/wallet-core/src/transactions/transactionsReducer.ts b/suite-common/wallet-core/src/transactions/transactionsReducer.ts index 83a4399aed9..e706ba0c737 100644 --- a/suite-common/wallet-core/src/transactions/transactionsReducer.ts +++ b/suite-common/wallet-core/src/transactions/transactionsReducer.ts @@ -374,7 +374,7 @@ export const selectAccountClaimTransactions = createMemoizedSelector( ), ); -export const selectAccountHasStaked = createMemoizedSelector( +export const selectEthAccountHasStaked = createMemoizedSelector( [selectAccountStakeTransactions, selectAccountByKey], (stakeTxs, account) => { if (!account) return false; @@ -386,7 +386,7 @@ export const selectAccountHasStaked = createMemoizedSelector( export const selectAssetAccountsThatStaked = ( state: TransactionsRootState & AccountsRootState, accounts: Account[], -) => accounts.filter(account => selectAccountHasStaked(state, account.key)); +) => accounts.filter(account => selectEthAccountHasStaked(state, account.key)); export const selectAccountTransactionsFetchStatus = ( state: TransactionsRootState, diff --git a/suite-common/wallet-utils/src/solanaStakingUtils.ts b/suite-common/wallet-utils/src/solanaStakingUtils.ts index 2eeb5c12c8c..8a144beed25 100644 --- a/suite-common/wallet-utils/src/solanaStakingUtils.ts +++ b/suite-common/wallet-utils/src/solanaStakingUtils.ts @@ -1,6 +1,6 @@ -import { Solana, SolNetwork } from '@everstake/wallet-sdk'; +import { Solana, SolDelegation, SolNetwork, StakeAccount, StakeState } from '@everstake/wallet-sdk'; -import { NetworkSymbol } from '@suite-common/wallet-config'; +import { getNetworkFeatures, NetworkSymbol } from '@suite-common/wallet-config'; import { BigNumber, isArrayMember } from '@trezor/utils'; import { SolanaStakingAccount } from '@trezor/blockchain-link-types/src/solana'; import { @@ -8,8 +8,10 @@ import { supportedSolanaNetworkSymbols, SupportedSolanaNetworkSymbols, } from '@suite-common/wallet-types'; -import { LAMPORTS_PER_SOL } from '@suite-common/wallet-constants'; import { PartialRecord } from '@trezor/type-utils'; +import { SOLANA_EPOCH_DAYS } from '@suite-common/wallet-constants'; + +import { formatNetworkAmount } from './accountUtils'; export function isSupportedSolStakingNetworkSymbol( symbol: NetworkSymbol, @@ -17,11 +19,17 @@ export function isSupportedSolStakingNetworkSymbol( return isArrayMember(symbol, supportedSolanaNetworkSymbols); } -export const formatSolanaStakingAmount = (amount: string | null) => { - if (!amount) return null; +export const getSolanaStakingSymbols = (networkSymbols: NetworkSymbol[]) => + networkSymbols.reduce((acc, networkSymbol) => { + if ( + isSupportedSolStakingNetworkSymbol(networkSymbol) && + getNetworkFeatures(networkSymbol).includes('staking') + ) { + acc.push(networkSymbol); + } - return new BigNumber(amount).div(LAMPORTS_PER_SOL).toFixed(9); -}; + return acc; + }, [] as SupportedSolanaNetworkSymbols[]); interface SolNetworkConfig { network: SolNetwork; @@ -67,6 +75,108 @@ export const getSolAccountTotalStakingBalance = (account: Account) => { if (!solStakingAccounts) return null; const totalStakingBalance = calculateTotalSolStakingBalance(solStakingAccounts); + if (!totalStakingBalance) return null; + + return formatNetworkAmount(totalStakingBalance, account.symbol); +}; + +export const calculateSolanaStakingReward = (accountBalance?: string, apy?: string) => { + if (!accountBalance || !apy) return '0'; + + return new BigNumber(accountBalance ?? '') + .multipliedBy(apy ?? '0') + .dividedBy(100) + .dividedBy(365) + .multipliedBy(SOLANA_EPOCH_DAYS) + .toFixed(9) + .toString(); +}; + +export const getStakingAccountStatus = ( + solStakingAccount: SolDelegation['account'], + epoch: number, +) => { + const stakeAccountClient = new StakeAccount(solStakingAccount); + + return stakeAccountClient.stakeAccountState(epoch); +}; + +interface StakingAccountWithStatus extends SolDelegation { + status: string; +} + +export const getSolanaStakingAccountsWithStatus = ( + account: Account, +): StakingAccountWithStatus[] | null => { + if (account.networkType !== 'solana') return null; + + const { solStakingAccounts, solEpoch } = account.misc; + if (!solStakingAccounts?.length || !solEpoch) return null; + + const stakingAccountsWithStatus = solStakingAccounts.map(solStakingAccount => { + const status = getStakingAccountStatus(solStakingAccount.account, solEpoch); + + return { + ...solStakingAccount, + status, + }; + }); + + return stakingAccountsWithStatus; +}; - return formatSolanaStakingAmount(totalStakingBalance); +export const getSolanaStakingAccountsByStatus = (account: Account, status: string) => { + const stakingAccountsWithStatus = getSolanaStakingAccountsWithStatus(account); + + if (!stakingAccountsWithStatus) return []; + + return stakingAccountsWithStatus.filter( + solStakingAccount => solStakingAccount.status === status, + ); +}; + +export const getStakingAccountCurrentStatus = (account?: Account) => { + if (account?.networkType !== 'solana') return null; + + const statusesToCheck = [StakeState.inactive, StakeState.activating]; + + for (const status of statusesToCheck) { + const stakingAccounts = getSolanaStakingAccountsByStatus(account, status); + if (stakingAccounts.length) return status; + } + + return null; +}; + +export const getSolStakingAccountTotalBalanceByStatus = (account: Account, status: string) => { + if (account.networkType !== 'solana') return '0'; + + const selectedStakingAccounts = getSolanaStakingAccountsByStatus(account, status); + const stakingBalance = calculateTotalSolStakingBalance(selectedStakingAccounts) ?? '0'; + + return formatNetworkAmount(stakingBalance, account.symbol); +}; + +type StakeStateType = (typeof StakeState)[keyof typeof StakeState]; + +export const getSolStakingAccountsInfo = (account: Account) => { + const balanceResults = Object.entries(StakeState).map(([key, status]) => { + const balance = getSolStakingAccountTotalBalanceByStatus(account, status); + + return [key, balance]; + }); + + const balances: Record = balanceResults.reduce( + (acc, [key, balance]) => ({ ...acc, [key]: balance }), + {}, + ); + + return { + solStakedBalance: balances[StakeState.active], + solClaimableBalance: balances[StakeState.deactivated], + solPendingStakeBalance: balances[StakeState.activating], + solPendingUnstakeBalance: balances[StakeState.deactivating], + canClaimSol: new BigNumber(balances[StakeState.deactivated]).gt(0), + canUnstakeSol: new BigNumber(balances[StakeState.active]).gt(0), + }; };