From fd47fd6dd1955e032de6c15a017bf8d66eded637 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Such=C3=BD?= Date: Mon, 30 Sep 2024 11:11:57 +0200 Subject: [PATCH] feat(mobile): staking view-only (#14561) --- suite-common/wallet-utils/src/stakingUtils.ts | 6 + suite-native/accounts/package.json | 2 + .../src/components/AccountSectionTitle.tsx | 19 +- .../components/AccountSelectBottomSheet.tsx | 36 ++- .../components/AccountsList/AccountsList.tsx | 4 +- .../AccountsList/AccountsListItem.tsx | 16 +- .../AccountsList/AccountsListStakingItem.tsx | 48 ++++ .../components/AccountsList/StakingBadge.tsx | 13 + .../src/components/TokenSelectBottomSheet.tsx | 3 + suite-native/accounts/src/index.ts | 1 + suite-native/accounts/src/selectors.ts | 19 +- suite-native/accounts/src/types.ts | 2 + suite-native/accounts/tsconfig.json | 2 + suite-native/app/package.json | 1 + .../app/src/navigation/RootStackNavigator.tsx | 6 + suite-native/app/tsconfig.json | 3 + suite-native/assets/package.json | 2 +- suite-native/assets/src/assetsSelectors.ts | 9 +- .../assets/src/components/AssetItem.tsx | 18 +- suite-native/assets/src/components/Assets.tsx | 18 +- .../components/NetworkAssetsBottomSheet.tsx | 1 + suite-native/assets/tsconfig.json | 4 +- suite-native/atoms/src/AlertBox.tsx | 23 +- suite-native/atoms/src/RoundedIcon.tsx | 13 +- suite-native/intl/src/en.ts | 21 ++ .../screens/AccountsScreen/AccountsScreen.tsx | 1 + .../module-staking-management/package.json | 28 +++ .../src/components/StakePendingCard.tsx | 114 +++++++++ .../StakingBalancesOverviewCard.tsx | 139 +++++++++++ .../components/StakingDetailScreenHeader.tsx | 61 +++++ .../src/components/StakingInfo.tsx | 77 ++++++ .../StakingUnavailableBottomSheet.tsx | 58 +++++ .../module-staking-management/src/index.ts | 1 + .../src/screens/StakingDetailScreen.tsx | 17 ++ .../module-staking-management/tsconfig.json | 19 ++ suite-native/navigation/src/navigators.ts | 1 + suite-native/navigation/src/routes.ts | 1 + suite-native/staking/package.json | 19 ++ .../staking/src/ethereumStakingSelectors.ts | 101 ++++++++ suite-native/staking/src/index.ts | 7 + suite-native/staking/src/selectors.ts | 228 ++++++++++++++++++ suite-native/staking/src/types.ts | 11 + suite-native/staking/src/utils.ts | 10 + suite-native/staking/tsconfig.json | 18 ++ tsconfig.json | 4 + yarn.lock | 36 ++- 46 files changed, 1195 insertions(+), 46 deletions(-) create mode 100644 suite-native/accounts/src/components/AccountsList/AccountsListStakingItem.tsx create mode 100644 suite-native/accounts/src/components/AccountsList/StakingBadge.tsx create mode 100644 suite-native/module-staking-management/package.json create mode 100644 suite-native/module-staking-management/src/components/StakePendingCard.tsx create mode 100644 suite-native/module-staking-management/src/components/StakingBalancesOverviewCard.tsx create mode 100644 suite-native/module-staking-management/src/components/StakingDetailScreenHeader.tsx create mode 100644 suite-native/module-staking-management/src/components/StakingInfo.tsx create mode 100644 suite-native/module-staking-management/src/components/StakingUnavailableBottomSheet.tsx create mode 100644 suite-native/module-staking-management/src/index.ts create mode 100644 suite-native/module-staking-management/src/screens/StakingDetailScreen.tsx create mode 100644 suite-native/module-staking-management/tsconfig.json create mode 100644 suite-native/staking/package.json create mode 100644 suite-native/staking/src/ethereumStakingSelectors.ts create mode 100644 suite-native/staking/src/index.ts create mode 100644 suite-native/staking/src/selectors.ts create mode 100644 suite-native/staking/src/types.ts create mode 100644 suite-native/staking/src/utils.ts create mode 100644 suite-native/staking/tsconfig.json diff --git a/suite-common/wallet-utils/src/stakingUtils.ts b/suite-common/wallet-utils/src/stakingUtils.ts index 3e7c97ac1bf..c742af6c505 100644 --- a/suite-common/wallet-utils/src/stakingUtils.ts +++ b/suite-common/wallet-utils/src/stakingUtils.ts @@ -52,3 +52,9 @@ export const getAccountTotalStakingBalance = (account?: Account) => { .plus(pool?.withdrawTotalAmount ?? '0') .toFixed(); }; + +export const getEthereumCryptoBalanceWithStaking = (account: Account) => { + const stakingBalance = getAccountTotalStakingBalance(account); + + return new BigNumber(account.formattedBalance).plus(stakingBalance).toString(); +}; diff --git a/suite-native/accounts/package.json b/suite-native/accounts/package.json index dc005112381..4fe2085c925 100644 --- a/suite-native/accounts/package.json +++ b/suite-native/accounts/package.json @@ -32,6 +32,8 @@ "@suite-native/intl": "workspace:*", "@suite-native/navigation": "workspace:*", "@suite-native/settings": "workspace:*", + "@suite-native/staking": "workspace:*", + "@suite-native/toasts": "workspace:*", "@suite-native/tokens": "workspace:*", "@trezor/styles": "workspace:*", "jotai": "1.9.1", diff --git a/suite-native/accounts/src/components/AccountSectionTitle.tsx b/suite-native/accounts/src/components/AccountSectionTitle.tsx index 2842ccd59ea..3ef9a28837a 100644 --- a/suite-native/accounts/src/components/AccountSectionTitle.tsx +++ b/suite-native/accounts/src/components/AccountSectionTitle.tsx @@ -1,12 +1,16 @@ import React, { useMemo } from 'react'; import { useSelector } from 'react-redux'; -import { HStack, VStack, Text } from '@suite-native/atoms'; -import { FiatAmountFormatter } from '@suite-native/formatters'; +import { selectCurrentFiatRates } from '@suite-common/wallet-core'; import { Account } from '@suite-common/wallet-types'; import { getAccountFiatBalance } from '@suite-common/wallet-utils'; +import { HStack, Text, VStack } from '@suite-native/atoms'; +import { CryptoAmountFormatter, FiatAmountFormatter } from '@suite-native/formatters'; import { selectFiatCurrencyCode } from '@suite-native/settings'; -import { selectCurrentFiatRates } from '@suite-common/wallet-core'; +import { + NativeStakingRootState, + selectAccountCryptoBalanceWithStaking, +} from '@suite-native/staking'; type AccountSectionTitleProps = { account: Account; @@ -20,6 +24,9 @@ export const AccountSectionTitle: React.FC = ({ }) => { const localCurrency = useSelector(selectFiatCurrencyCode); const rates = useSelector(selectCurrentFiatRates); + const cryptoBalanceWithStaking = useSelector((state: NativeStakingRootState) => + selectAccountCryptoBalanceWithStaking(state, account.key), + ); const fiatBalance = useMemo(() => { return getAccountFiatBalance({ account, localCurrency, rates }); @@ -36,6 +43,12 @@ export const AccountSectionTitle: React.FC = ({ adjustsFontSizeToFit value={fiatBalance} /> + )} diff --git a/suite-native/accounts/src/components/AccountSelectBottomSheet.tsx b/suite-native/accounts/src/components/AccountSelectBottomSheet.tsx index ab844372627..c603e46aeb0 100644 --- a/suite-native/accounts/src/components/AccountSelectBottomSheet.tsx +++ b/suite-native/accounts/src/components/AccountSelectBottomSheet.tsx @@ -4,15 +4,18 @@ import { FlashList } from '@shopify/flash-list'; import { BottomSheet } from '@suite-native/atoms'; import { prepareNativeStyle, useNativeStyles } from '@trezor/styles'; +import { useToast } from '@suite-native/toasts'; import { AccountSelectBottomSheetSection, OnSelectAccount } from '../types'; import { AccountsListItem } from './AccountsList/AccountsListItem'; import { AccountSectionTitle } from './AccountSectionTitle'; import { AccountsListTokenItem } from './AccountsList/AccountsListTokenItem'; +import { AccountsListStakingItem } from './AccountsList/AccountsListStakingItem'; type AccountSelectBottomSheetProps = { data: AccountSelectBottomSheetSection[]; onSelectAccount: OnSelectAccount; + isStakingPressable?: boolean; onClose: () => void; }; @@ -21,8 +24,14 @@ const contentContainerStyle = prepareNativeStyle(utils => ({ })); export const AccountSelectBottomSheet = React.memo( - ({ data, onSelectAccount, onClose }: AccountSelectBottomSheetProps) => { + ({ + data, + onSelectAccount, + isStakingPressable = false, + onClose, + }: AccountSelectBottomSheetProps) => { const { applyStyle } = useNativeStyles(); + const { showToast } = useToast(); const renderItem = useCallback( ({ item }: { item: AccountSelectBottomSheetSection }) => { @@ -35,12 +44,31 @@ export const AccountSelectBottomSheet = React.memo( {...item} hasBackground showDivider + isInModal={true} onPress={() => onSelectAccount(item)} /> ); case 'staking': - // TODO: Implement staking section - return null; + return ( + { + if (isStakingPressable) { + onSelectAccount({ + account: item.account, + isStaking: true, + hasAnyKnownTokens: false, + }); + } else { + showToast({ + variant: 'warning', + message: 'Staking is not available in this context.', + }); + } + }} + /> + ); case 'token': const { token, account } = item; @@ -61,7 +89,7 @@ export const AccountSelectBottomSheet = React.memo( return null; } }, - [onSelectAccount], + [isStakingPressable, onSelectAccount, showToast], ); return ( diff --git a/suite-native/accounts/src/components/AccountsList/AccountsList.tsx b/suite-native/accounts/src/components/AccountsList/AccountsList.tsx index 7a773b06ffa..7f50e214246 100644 --- a/suite-native/accounts/src/components/AccountsList/AccountsList.tsx +++ b/suite-native/accounts/src/components/AccountsList/AccountsList.tsx @@ -20,12 +20,14 @@ type AccountsListProps = { onSelectAccount: OnSelectAccount; filterValue?: string; hideTokensIntoModal?: boolean; + isStakingPressable?: boolean; }; export const AccountsList = ({ onSelectAccount, filterValue = '', hideTokensIntoModal = false, + isStakingPressable = false, }: AccountsListProps) => { const groupedAccounts = useSelector((state: NativeAccountsRootState) => selectFilteredDeviceAccountsGroupedByNetworkAccountType(state, filterValue), @@ -61,7 +63,6 @@ export const AccountsList = ({ ))} @@ -71,6 +72,7 @@ export const AccountsList = ({ ); diff --git a/suite-native/accounts/src/components/AccountsList/AccountsListItem.tsx b/suite-native/accounts/src/components/AccountsList/AccountsListItem.tsx index 46c802f07b0..517ddf32bf7 100644 --- a/suite-native/accounts/src/components/AccountsList/AccountsListItem.tsx +++ b/suite-native/accounts/src/components/AccountsList/AccountsListItem.tsx @@ -17,14 +17,16 @@ import { selectNumberOfAccountTokensWithFiatRates, TokensRootState, } from '@suite-native/tokens'; +import { NativeStakingRootState, selectAccountHasStaking } from '@suite-native/staking'; import { NativeAccountsRootState, selectAccountFiatBalance } from '../../selectors'; import { OnSelectAccount } from '../../types'; import { AccountsListItemBase } from './AccountsListItemBase'; +import { StakingBadge } from './StakingBadge'; export type AccountListItemProps = { account: Account; - hideTokens?: boolean; + isInModal?: boolean; onPress?: OnSelectAccount; disabled?: boolean; @@ -53,7 +55,7 @@ export const AccountsListItem = ({ account, onPress, disabled, - hideTokens = false, + isInModal = false, hasBackground = false, isFirst = false, isLast = false, @@ -69,6 +71,10 @@ export const AccountsListItem = ({ selectAccountHasAnyKnownToken(state, account.key), ); + const accountHasStaking = useSelector((state: NativeStakingRootState) => + selectAccountHasStaking(state, account.key), + ); + const fiatBalance = useSelector((state: NativeAccountsRootState) => selectAccountFiatBalance(state, account.key), ); @@ -81,8 +87,9 @@ export const AccountsListItem = ({ }, [account, accountHasAnyTokens, onPress]); const doesCoinSupportTokens = isCoinWithTokens(account.symbol); - const shouldShowAccountLabel = !doesCoinSupportTokens || hideTokens; - const shouldShowTokenBadge = accountHasAnyTokens && hideTokens; + const shouldShowAccountLabel = !doesCoinSupportTokens || !isInModal; + const shouldShowTokenBadge = accountHasAnyTokens && !isInModal; + const shouldShowStakingBadge = accountHasStaking && !isInModal; return ( )} + {shouldShowStakingBadge && } {shouldShowTokenBadge && } } diff --git a/suite-native/accounts/src/components/AccountsList/AccountsListStakingItem.tsx b/suite-native/accounts/src/components/AccountsList/AccountsListStakingItem.tsx new file mode 100644 index 00000000000..b524f70c1f1 --- /dev/null +++ b/suite-native/accounts/src/components/AccountsList/AccountsListStakingItem.tsx @@ -0,0 +1,48 @@ +import { Account } from '@suite-common/wallet-types'; +import { RoundedIcon } from '@suite-native/atoms'; +import { CryptoAmountFormatter, CryptoToFiatAmountFormatter } from '@suite-native/formatters'; +import { Translation } from '@suite-native/intl'; + +import { AccountsListItemBase } from './AccountsListItemBase'; + +type AccountsListStakingItemProps = { + account: Account; + stakingCryptoBalance: string; + onPress: () => void; + + hasBackground?: boolean; + isFirst?: boolean; + isLast?: boolean; +}; + +export const AccountsListStakingItem = ({ + account, + stakingCryptoBalance, + isLast, + ...props +}: AccountsListStakingItemProps) => { + return ( + } + title={} + mainValue={ + + } + secondaryValue={ + + } + /> + ); +}; diff --git a/suite-native/accounts/src/components/AccountsList/StakingBadge.tsx b/suite-native/accounts/src/components/AccountsList/StakingBadge.tsx new file mode 100644 index 00000000000..2a2c77db05f --- /dev/null +++ b/suite-native/accounts/src/components/AccountsList/StakingBadge.tsx @@ -0,0 +1,13 @@ +import { RoundedIcon, RoundedIconProps } from '@suite-native/atoms'; + +export const StakingBadge = (props: Partial) => { + return ( + + ); +}; diff --git a/suite-native/accounts/src/components/TokenSelectBottomSheet.tsx b/suite-native/accounts/src/components/TokenSelectBottomSheet.tsx index 0330ac35d86..320b941f943 100644 --- a/suite-native/accounts/src/components/TokenSelectBottomSheet.tsx +++ b/suite-native/accounts/src/components/TokenSelectBottomSheet.tsx @@ -12,11 +12,13 @@ import { AccountSelectBottomSheet } from './AccountSelectBottomSheet'; type TokenSelectBottomSheetProps = { bottomSheetAccountAtom: WritableAtom; onSelectAccount: OnSelectAccount; + isStakingPressable?: boolean; }; export const TokenSelectBottomSheet = ({ bottomSheetAccountAtom, onSelectAccount, + isStakingPressable = false, }: TokenSelectBottomSheetProps) => { const [selectedAccount, setSelectedAccount] = useAtom(bottomSheetAccountAtom); @@ -44,6 +46,7 @@ export const TokenSelectBottomSheet = ({ onSelectAccount={handleSelectAccount} data={data} onClose={handleClose} + isStakingPressable={isStakingPressable} /> ); }; diff --git a/suite-native/accounts/src/index.ts b/suite-native/accounts/src/index.ts index be8a7acb8a0..0a64a51e382 100644 --- a/suite-native/accounts/src/index.ts +++ b/suite-native/accounts/src/index.ts @@ -2,6 +2,7 @@ export * from './components/AddAccountsButton'; export * from './components/AccountsList/AccountsList'; export * from './components/AccountsList/AccountsListItem'; export * from './components/AccountsList/AccountsListItemBase'; +export * from './components/AccountsList/StakingBadge'; export * from './components/SearchableAccountsListScreenHeader'; export * from './components/SelectableNetworkItem'; export * from './components/AccountsList/AccountsListTokenItem'; diff --git a/suite-native/accounts/src/selectors.ts b/suite-native/accounts/src/selectors.ts index 8eaa334f874..653f57ec1dd 100644 --- a/suite-native/accounts/src/selectors.ts +++ b/suite-native/accounts/src/selectors.ts @@ -16,7 +16,7 @@ import { selectVisibleDeviceAccounts, } from '@suite-common/wallet-core'; import { AccountKey, TokenInfoBranded } from '@suite-common/wallet-types'; -import { getAccountFiatBalance } from '@suite-common/wallet-utils'; +import { getAccountFiatBalance, getAccountTotalStakingBalance } from '@suite-common/wallet-utils'; import { SettingsSliceRootState, selectFiatCurrencyCode } from '@suite-native/settings'; import { isCoinWithTokens } from '@suite-native/tokens'; @@ -78,12 +78,10 @@ export const selectAccountFiatBalance = (state: NativeAccountsRootState, account return '0'; } - // Staking should be true once we support it in Trezor Suite Lite const totalBalance = getAccountFiatBalance({ account, rates: fiatRates, localCurrency, - shouldIncludeStaking: false, }); return totalBalance; @@ -92,7 +90,7 @@ export const selectAccountFiatBalance = (state: NativeAccountsRootState, account const EMPTY_ARRAY: any[] = []; export const selectAccountListSections = memoizeWithArgs( - (state: NativeAccountsRootState, accountKey?: AccountKey | null) => { + (state: NativeAccountsRootState, accountKey?: AccountKey | null, hideStaking?: boolean) => { if (!accountKey) return EMPTY_ARRAY; const account = selectAccountByKey(state, accountKey); if (!account) return EMPTY_ARRAY; @@ -102,6 +100,8 @@ export const selectAccountListSections = memoizeWithArgs( const canHasTokens = isCoinWithTokens(account.symbol); const tokens = selectFilterKnownTokens(state, account.symbol, account.tokens ?? []); const hasAnyKnownTokens = canHasTokens && !!tokens.length; + const stakingBalance = getAccountTotalStakingBalance(account); + const hasStaking = stakingBalance !== '0' && !hideStaking; if (canHasTokens) { sections.push({ @@ -113,12 +113,19 @@ export const selectAccountListSections = memoizeWithArgs( sections.push({ type: 'account', account, - isLast: !hasAnyKnownTokens, + isLast: !hasAnyKnownTokens && !hasStaking, isFirst: true, hasAnyKnownTokens, }); - // TODO: staking here + if (hasStaking) { + sections.push({ + type: 'staking', + account, + stakingCryptoBalance: stakingBalance, + isLast: !hasAnyKnownTokens, + }); + } if (hasAnyKnownTokens) { tokens.forEach((token, index) => { diff --git a/suite-native/accounts/src/types.ts b/suite-native/accounts/src/types.ts index d3c4fea994d..99ecc423b8b 100644 --- a/suite-native/accounts/src/types.ts +++ b/suite-native/accounts/src/types.ts @@ -4,6 +4,7 @@ export type GroupedByTypeAccounts = Record; export type OnSelectAccount = (params: { account: Account; + isStaking?: boolean; tokenAddress?: TokenAddress; hasAnyKnownTokens: boolean; }) => void; @@ -23,6 +24,7 @@ export type AccountSelectBottomSheetSection = ( | { type: 'staking'; account: Account; + stakingCryptoBalance: string; } | { type: 'token'; diff --git a/suite-native/accounts/tsconfig.json b/suite-native/accounts/tsconfig.json index 8128c10e529..d4b06c7edee 100644 --- a/suite-native/accounts/tsconfig.json +++ b/suite-native/accounts/tsconfig.json @@ -35,6 +35,8 @@ { "path": "../intl" }, { "path": "../navigation" }, { "path": "../settings" }, + { "path": "../staking" }, + { "path": "../toasts" }, { "path": "../tokens" }, { "path": "../../packages/styles" } ] diff --git a/suite-native/app/package.json b/suite-native/app/package.json index 88427102b24..dd06171a07f 100644 --- a/suite-native/app/package.json +++ b/suite-native/app/package.json @@ -65,6 +65,7 @@ "@suite-native/module-receive": "workspace:*", "@suite-native/module-send": "workspace:*", "@suite-native/module-settings": "workspace:*", + "@suite-native/module-staking-management": "workspace:*", "@suite-native/navigation": "workspace:*", "@suite-native/notifications": "workspace:*", "@suite-native/receive": "workspace:*", diff --git a/suite-native/app/src/navigation/RootStackNavigator.tsx b/suite-native/app/src/navigation/RootStackNavigator.tsx index 1b137c1d5dc..63080800c50 100644 --- a/suite-native/app/src/navigation/RootStackNavigator.tsx +++ b/suite-native/app/src/navigation/RootStackNavigator.tsx @@ -23,6 +23,7 @@ import { DeviceInfoModalScreen, useHandleDeviceConnection } from '@suite-native/ import { SendStackNavigator } from '@suite-native/module-send'; import { CoinEnablingInitScreen } from '@suite-native/coin-enabling'; import { ConnectPopupScreen, useConnectPopupNavigation } from '@suite-native/module-connect-popup'; +import { StakingDetailScreen } from '@suite-native/module-staking-management'; import { AppTabNavigator } from './AppTabNavigator'; import { useCoinEnablingInitialCheck } from '../hooks/useCoinEnablingInitialCheck'; @@ -73,6 +74,11 @@ export const RootStackNavigator = () => { name={RootStackRoutes.AccountDetail} component={AccountDetailScreen} /> + { account, localCurrency: fiatCurrencyCode, rates, - // TODO: this should be removed once Trezor Suite Lite supports staking - shouldIncludeStaking: false, }); return { symbol: account.symbol, fiatValue, - cryptoValue: account.formattedBalance, + cryptoValue: selectAccountCryptoBalanceWithStaking(state, account.key), }; }); diff --git a/suite-native/assets/src/components/AssetItem.tsx b/suite-native/assets/src/components/AssetItem.tsx index e9d6e94dd15..3c41c163fe6 100644 --- a/suite-native/assets/src/components/AssetItem.tsx +++ b/suite-native/assets/src/components/AssetItem.tsx @@ -9,7 +9,7 @@ import { useSelectorDeepComparison } from '@suite-common/redux-utils'; import { TokenDefinitionsRootState } from '@suite-common/token-definitions'; import { NetworkSymbol } from '@suite-common/wallet-config'; import { AccountsRootState, DeviceRootState } from '@suite-common/wallet-core'; -import { AccountsListItemBase } from '@suite-native/accounts'; +import { AccountsListItemBase, StakingBadge } from '@suite-native/accounts'; import { Badge, Box, Text } from '@suite-native/atoms'; import { CryptoAmountFormatter, FiatAmountFormatter } from '@suite-native/formatters'; import { Translation } from '@suite-native/intl'; @@ -21,7 +21,10 @@ import { TabToStackCompositeNavigationProp, } from '@suite-native/navigation'; import { selectHasDeviceAnyTokens } from '@suite-native/tokens'; -import { prepareNativeStyle, useNativeStyles } from '@trezor/styles'; +import { + selectHasAnyDeviceAccountsWithStaking, + NativeStakingRootState, +} from '@suite-native/staking'; import { AssetsRootState, @@ -36,10 +39,6 @@ type AssetItemProps = { onPress?: (symbol: NetworkSymbol) => void; }; -const tokenBadgeStyle = prepareNativeStyle(() => ({ - marginLeft: 10, -})); - type NavigationType = TabToStackCompositeNavigationProp< AppTabsParamList, AppTabsRoutes.HomeStack, @@ -83,7 +82,6 @@ const PercentageIcon = React.memo(({ network }: { network: NetworkSymbol }) => { }); export const AssetItem = React.memo(({ cryptoCurrencySymbol, onPress }: AssetItemProps) => { - const { applyStyle } = useNativeStyles(); const navigation = useNavigation(); const { NetworkNameFormatter } = useFormatters(); const accountsKeysForNetworkSymbol = useSelectorDeepComparison((state: AssetsRootState) => @@ -95,6 +93,9 @@ export const AssetItem = React.memo(({ cryptoCurrencySymbol, onPress }: AssetIte (state: AccountsRootState & DeviceRootState & TokenDefinitionsRootState) => selectHasDeviceAnyTokens(state, cryptoCurrencySymbol), ); + const hasAnyAccountsWithStaking = useSelector((state: NativeStakingRootState) => + selectHasAnyDeviceAccountsWithStaking(state, cryptoCurrencySymbol), + ); const handleAssetPress = () => { if (accountsPerAsset === 1 && !hasAnyTokens) { @@ -121,10 +122,9 @@ export const AssetItem = React.memo(({ cryptoCurrencySymbol, onPress }: AssetIte {accountsPerAsset} - + {hasAnyAccountsWithStaking && } {hasAnyTokens && ( } diff --git a/suite-native/assets/src/components/Assets.tsx b/suite-native/assets/src/components/Assets.tsx index 2057bf04f68..70c31cfd9c6 100644 --- a/suite-native/assets/src/components/Assets.tsx +++ b/suite-native/assets/src/components/Assets.tsx @@ -40,12 +40,18 @@ export const Assets = () => { const [selectedAssetSymbol, setSelectedAssetSymbol] = useState(null); const handleSelectAssetsAccount: OnSelectAccount = useCallback( - ({ account, tokenAddress }) => { - navigation.navigate(RootStackRoutes.AccountDetail, { - accountKey: account.key, - tokenContract: tokenAddress, - closeActionType: 'back', - }); + ({ account, tokenAddress, isStaking }) => { + if (isStaking) { + navigation.navigate(RootStackRoutes.StakingDetail, { + accountKey: account.key, + }); + } else { + navigation.navigate(RootStackRoutes.AccountDetail, { + accountKey: account.key, + tokenContract: tokenAddress, + closeActionType: 'back', + }); + } setSelectedAssetSymbol(null); }, [navigation, setSelectedAssetSymbol], diff --git a/suite-native/assets/src/components/NetworkAssetsBottomSheet.tsx b/suite-native/assets/src/components/NetworkAssetsBottomSheet.tsx index ed6be8c3f10..61e7ce6104e 100644 --- a/suite-native/assets/src/components/NetworkAssetsBottomSheet.tsx +++ b/suite-native/assets/src/components/NetworkAssetsBottomSheet.tsx @@ -24,6 +24,7 @@ export const NetworkAssetsBottomSheet = React.memo( data={items} onClose={onClose} onSelectAccount={onSelectAccount} + isStakingPressable /> ); }, diff --git a/suite-native/assets/tsconfig.json b/suite-native/assets/tsconfig.json index a2c327d159e..baf9b8a7599 100644 --- a/suite-native/assets/tsconfig.json +++ b/suite-native/assets/tsconfig.json @@ -31,7 +31,7 @@ { "path": "../intl" }, { "path": "../navigation" }, { "path": "../settings" }, - { "path": "../tokens" }, - { "path": "../../packages/styles" } + { "path": "../staking" }, + { "path": "../tokens" } ] } diff --git a/suite-native/atoms/src/AlertBox.tsx b/suite-native/atoms/src/AlertBox.tsx index 165a87d55c0..ef71eedfce6 100644 --- a/suite-native/atoms/src/AlertBox.tsx +++ b/suite-native/atoms/src/AlertBox.tsx @@ -1,4 +1,5 @@ import { ReactNode } from 'react'; +import { ActivityIndicator } from 'react-native'; import { prepareNativeStyle, useNativeStyles } from '@trezor/styles'; import { Color, nativeBorders } from '@trezor/theme'; @@ -7,7 +8,7 @@ import { Icon, IconName } from '@suite-common/icons-deprecated'; import { Box } from './Box'; import { Text } from './Text'; -export type AlertBoxVariant = 'info' | 'success' | 'warning' | 'error'; +export type AlertBoxVariant = 'info' | 'success' | 'warning' | 'loading' | 'error'; type AlertBoxStyle = { backgroundColor: Color; @@ -56,6 +57,11 @@ const variantToColorMap = { contentColor: 'iconAlertYellow', borderColor: 'backgroundAlertYellowSubtleOnElevationNegative', }, + loading: { + backgroundColor: 'backgroundAlertYellowSubtleOnElevation1', + contentColor: 'iconAlertYellow', + borderColor: 'backgroundAlertYellowSubtleOnElevationNegative', + }, error: { backgroundColor: 'backgroundAlertRedSubtleOnElevation1', contentColor: 'textAlertRed', @@ -67,6 +73,7 @@ const variantToIconName = { info: 'info', success: 'checkCircle', warning: 'warningTriangle', + loading: 'warningTriangle', error: 'warningCircle', } as const satisfies Record; @@ -76,6 +83,14 @@ export type AlertBoxProps = { borderRadius?: number; }; +const AlertSpinner = ({ color }: { color: Color }) => { + const { + utils: { colors }, + } = useNativeStyles(); + + return ; +}; + export const AlertBox = ({ title, variant = 'info', @@ -92,7 +107,11 @@ export const AlertBox = ({ backgroundColor, })} > - + {variant === 'loading' ? ( + + ) : ( + + )} {title} diff --git a/suite-native/atoms/src/RoundedIcon.tsx b/suite-native/atoms/src/RoundedIcon.tsx index 7db42848569..66a16208172 100644 --- a/suite-native/atoms/src/RoundedIcon.tsx +++ b/suite-native/atoms/src/RoundedIcon.tsx @@ -11,15 +11,15 @@ import { import { prepareNativeStyle, useNativeStyles } from '@trezor/styles'; import { Color } from '@trezor/theme'; -import { Box } from './Box'; +import { Box, BoxProps } from './Box'; -type RoundedIconProps = { +export type RoundedIconProps = { name: IconName | CoinSymbol; color?: Color; iconSize?: IconSize; containerSize?: number; backgroundColor?: Color; -}; +} & BoxProps; const DEFAULT_CONTAINER_SIZE = 48; @@ -47,11 +47,16 @@ export const RoundedIcon = ({ iconSize, backgroundColor, containerSize, + style, + ...boxProps }: RoundedIconProps) => { const { applyStyle } = useNativeStyles(); return ( - + {name in icons ? ( ) : ( diff --git a/suite-native/intl/src/en.ts b/suite-native/intl/src/en.ts index ec49cb5e52e..f7e12f79696 100644 --- a/suite-native/intl/src/en.ts +++ b/suite-native/intl/src/en.ts @@ -87,6 +87,7 @@ export const en = { accountList: { numberOfTokens: '+{numberOfTokens, plural, one{1 Token} other{# Tokens}}', tokens: 'Tokens', + staking: 'Staking', }, assets: { dashboard: { @@ -945,6 +946,26 @@ export const en = { }, }, }, + staking: { + stakingDetailScreen: { + title: 'Staking', + }, + staked: 'Staked', + rewards: 'Rewards', + apy: 'Annual percentage yield', + stakingCanBeManaged: 'Staking can be currently managed only in', + trezorDesktop: 'Trezor Suite for desktop.', + stakePendingCard: { + totalStakePending: 'Total stake pending', + addingToStakingPool: 'Adding to staking pool', + transactionPending: 'Transaction pending', + unknownStatus: 'Unknown status', + }, + stakingBottomSheet: { + title: 'To manage your staked funds, please use Trezor Suite for desktop.', + description: 'We currently support staking as view-only in Trezor Suite Lite.', + }, + }, }; export type Translations = typeof en; diff --git a/suite-native/module-accounts-management/src/screens/AccountsScreen/AccountsScreen.tsx b/suite-native/module-accounts-management/src/screens/AccountsScreen/AccountsScreen.tsx index 05ff0ca82b9..f3da492a200 100644 --- a/suite-native/module-accounts-management/src/screens/AccountsScreen/AccountsScreen.tsx +++ b/suite-native/module-accounts-management/src/screens/AccountsScreen/AccountsScreen.tsx @@ -48,6 +48,7 @@ export const AccountsScreen = () => { onSelectAccount={handleSelectAccount} filterValue={accountsFilterValue} hideTokensIntoModal + isStakingPressable /> ); diff --git a/suite-native/module-staking-management/package.json b/suite-native/module-staking-management/package.json new file mode 100644 index 00000000000..00b2c98437f --- /dev/null +++ b/suite-native/module-staking-management/package.json @@ -0,0 +1,28 @@ +{ + "name": "@suite-native/module-staking-management", + "version": "1.0.0", + "private": true, + "license": "See LICENSE.md in repo root", + "sideEffects": false, + "main": "src/index", + "scripts": { + "depcheck": "yarn g:depcheck", + "lint:js": "yarn g:eslint '**/*.{ts,tsx,js}'", + "type-check": "yarn g:tsc --build" + }, + "dependencies": { + "@react-navigation/native": "6.1.18", + "@suite-common/icons-deprecated": "workspace:*", + "@suite-common/wallet-core": "workspace:*", + "@suite-native/atoms": "workspace:*", + "@suite-native/formatters": "workspace:*", + "@suite-native/intl": "workspace:*", + "@suite-native/link": "workspace:*", + "@suite-native/navigation": "workspace:*", + "@suite-native/staking": "workspace:*", + "@trezor/styles": "workspace:*", + "react": "18.2.0", + "react-native": "0.75.2", + "react-redux": "8.0.7" + } +} diff --git a/suite-native/module-staking-management/src/components/StakePendingCard.tsx b/suite-native/module-staking-management/src/components/StakePendingCard.tsx new file mode 100644 index 00000000000..b63a1f1cf00 --- /dev/null +++ b/suite-native/module-staking-management/src/components/StakePendingCard.tsx @@ -0,0 +1,114 @@ +import { TouchableOpacity } from 'react-native'; +import { useMemo } from 'react'; +import { useSelector } from 'react-redux'; + +import { AccountsRootState, selectAccountNetworkSymbol } from '@suite-common/wallet-core'; +import { Box, Card, Text } from '@suite-native/atoms'; +import { CryptoAmountFormatter, CryptoToFiatAmountFormatter } from '@suite-native/formatters'; +import { Translation } from '@suite-native/intl'; +import { prepareNativeStyle, useNativeStyles } from '@trezor/styles'; +import { + selectIsStakeConfirmingByAccountKey, + selectIsStakePendingByAccountKey, + selectTotalStakePendingByAccountKey, +} from '@suite-native/staking'; +import { NativeStakingRootState } from '@suite-native/staking/src/types'; + +const stakingItemStyle = prepareNativeStyle(utils => ({ + flexDirection: 'row', + alignItems: 'center', + gap: utils.spacings.extraSmall, +})); + +const valuesContainerStyle = prepareNativeStyle(utils => ({ + maxWidth: '40%', + flexShrink: 0, + alignItems: 'flex-end', + paddingLeft: utils.spacings.small, +})); + +const getCardAlertProps = (isStakeConfirming: boolean, isStakePending: boolean) => { + if (isStakeConfirming && !isStakePending) { + return { + alertTitle: , + alertVariant: 'loading', + } as const; + } + if (!isStakeConfirming && isStakePending) { + return { + alertTitle: , + alertVariant: 'loading', + } as const; + } + + return { + alertTitle: undefined, + alertVariant: undefined, + } as const; +}; + +type StakePendingCardProps = { + accountKey: string; + handleToggleBottomSheet: (value: boolean) => void; +}; + +export const StakePendingCard = ({ + accountKey, + handleToggleBottomSheet, +}: StakePendingCardProps) => { + const { applyStyle } = useNativeStyles(); + + const networkSymbol = useSelector((state: AccountsRootState) => + selectAccountNetworkSymbol(state, accountKey), + ); + + const totalStakePending = useSelector((state: NativeStakingRootState) => + selectTotalStakePendingByAccountKey(state, accountKey), + ); + + const isStakePending = useSelector((state: NativeStakingRootState) => + selectIsStakePendingByAccountKey(state, accountKey), + ); + const isStakeConfirming = useSelector((state: NativeStakingRootState) => + selectIsStakeConfirmingByAccountKey(state, accountKey), + ); + + const cardAlertProps = useMemo( + () => getCardAlertProps(isStakeConfirming, isStakePending), + [isStakeConfirming, isStakePending], + ); + + if (!networkSymbol || !cardAlertProps.alertVariant) return null; + + return ( + handleToggleBottomSheet(true)}> + + + + + + + + + + + + + + + + + + ); +}; diff --git a/suite-native/module-staking-management/src/components/StakingBalancesOverviewCard.tsx b/suite-native/module-staking-management/src/components/StakingBalancesOverviewCard.tsx new file mode 100644 index 00000000000..ca348369797 --- /dev/null +++ b/suite-native/module-staking-management/src/components/StakingBalancesOverviewCard.tsx @@ -0,0 +1,139 @@ +import { TouchableOpacity } from 'react-native'; +import { useSelector } from 'react-redux'; + +import { Icon } from '@suite-common/icons-deprecated'; +import { AccountsRootState, selectAccountNetworkSymbol } from '@suite-common/wallet-core'; +import { Box, Card, Text } from '@suite-native/atoms'; +import { CryptoAmountFormatter, CryptoToFiatAmountFormatter } from '@suite-native/formatters'; +import { Translation } from '@suite-native/intl'; +import { prepareNativeStyle, useNativeStyles } from '@trezor/styles'; +import { + NativeStakingRootState, + selectAPYByAccountKey, + selectRewardsBalanceByAccountKey, + selectStakedBalanceByAccountKey, +} from '@suite-native/staking'; + +const stakingItemStyle = prepareNativeStyle(utils => ({ + flexDirection: 'row', + alignItems: 'center', + gap: utils.spacings.extraSmall, + paddingBottom: utils.spacings.small, +})); + +const stakingCardStyle = prepareNativeStyle(utils => ({ + marginTop: utils.spacings.medium, +})); + +const stakingWrapperStyle = prepareNativeStyle(utils => ({ + display: 'flex', + flexDirection: 'row', + justifyContent: 'flex-start', + paddingBottom: utils.spacings.medium, +})); + +const separatorStyle = prepareNativeStyle(utils => ({ + borderBottomWidth: utils.borders.widths.small, + borderBottomColor: utils.colors.borderElevation1, +})); + +type StakingBalancesCardProps = { + accountKey: string; + handleToggleBottomSheet: (value: boolean) => void; +}; + +const CRYPTO_BALANCE_DECIMALS = 5; + +export const StakingBalancesOverviewCard = ({ + accountKey, + handleToggleBottomSheet, +}: StakingBalancesCardProps) => { + const { applyStyle } = useNativeStyles(); + + const networkSymbol = useSelector((state: AccountsRootState) => + selectAccountNetworkSymbol(state, accountKey), + ); + + const apy = useSelector((state: NativeStakingRootState) => + selectAPYByAccountKey(state, accountKey), + ); + + const stakedBalance = useSelector((state: NativeStakingRootState) => + selectStakedBalanceByAccountKey(state, accountKey), + ); + const rewardsBalance = useSelector((state: NativeStakingRootState) => + selectRewardsBalanceByAccountKey(state, accountKey), + ); + + if (!networkSymbol) return null; + + return ( + handleToggleBottomSheet(true)}> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {apy}% + + + + ); +}; diff --git a/suite-native/module-staking-management/src/components/StakingDetailScreenHeader.tsx b/suite-native/module-staking-management/src/components/StakingDetailScreenHeader.tsx new file mode 100644 index 00000000000..4ec0f608053 --- /dev/null +++ b/suite-native/module-staking-management/src/components/StakingDetailScreenHeader.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { useSelector } from 'react-redux'; + +import { RouteProp, useRoute } from '@react-navigation/native'; + +import { HStack, Text } from '@suite-native/atoms'; +import { + RootStackParamList, + RootStackRoutes, + ScreenSubHeader, + GoBackIcon, +} from '@suite-native/navigation'; +import { Translation } from '@suite-native/intl'; +import { prepareNativeStyle, useNativeStyles } from '@trezor/styles'; +import { Icon } from '@suite-common/icons-deprecated'; +import { AccountsRootState, selectAccountLabel } from '@suite-common/wallet-core'; + +const headerStyle = prepareNativeStyle(utils => ({ + flexShrink: 1, + alignItems: 'center', + gap: utils.spacings.small, +})); + +const headerTextStyle = prepareNativeStyle(() => ({ + flexShrink: 1, +})); + +export const StakingDetailScreenHeader = () => { + const { applyStyle } = useNativeStyles(); + + const route = useRoute>(); + const { accountKey } = route.params; + + const accountLabel = useSelector((state: AccountsRootState) => + selectAccountLabel(state, accountKey), + ); + + return ( + + + + + + + + + {accountLabel} + + + } + leftIcon={} + /> + ); +}; diff --git a/suite-native/module-staking-management/src/components/StakingInfo.tsx b/suite-native/module-staking-management/src/components/StakingInfo.tsx new file mode 100644 index 00000000000..28d500893f7 --- /dev/null +++ b/suite-native/module-staking-management/src/components/StakingInfo.tsx @@ -0,0 +1,77 @@ +import { useCallback, useState } from 'react'; +import { TouchableOpacity } from 'react-native'; + +import { Box, Text } from '@suite-native/atoms'; +import { Translation } from '@suite-native/intl'; +import { useOpenLink } from '@suite-native/link'; +import { prepareNativeStyle, useNativeStyles } from '@trezor/styles'; + +import { StakePendingCard } from './StakePendingCard'; +import { StakingBalancesOverviewCard } from './StakingBalancesOverviewCard'; +import { StakingUnavailableBottomSheet } from './StakingUnavailableBottomSheet'; + +const sectionStyle = prepareNativeStyle(utils => ({ + paddingHorizontal: utils.spacings.small, + paddingVertical: utils.spacings.large, + flex: 1, +})); + +const linkStyle = prepareNativeStyle(() => ({ + textDecorationLine: 'underline', +})); + +type StakingInfoProps = { + accountKey: string; +}; + +export const StakingInfo = ({ accountKey }: StakingInfoProps) => { + const { applyStyle } = useNativeStyles(); + const openLink = useOpenLink(); + + const [isCardSelected, setIsCardSelected] = useState(false); + + const handleDesktopClick = () => { + openLink('https://trezor.io/trezor-suite'); + }; + + const handleToggleBottomSheet = useCallback(() => { + setIsCardSelected(prev => !prev); + }, [setIsCardSelected]); + + return ( + + + + + + + {/* TODO: replace with new icon once we have new package ready */} + {/* */} + + + + + + + + + + + + + + + + + ); +}; diff --git a/suite-native/module-staking-management/src/components/StakingUnavailableBottomSheet.tsx b/suite-native/module-staking-management/src/components/StakingUnavailableBottomSheet.tsx new file mode 100644 index 00000000000..b2c6ab5d2ad --- /dev/null +++ b/suite-native/module-staking-management/src/components/StakingUnavailableBottomSheet.tsx @@ -0,0 +1,58 @@ +import { BottomSheet, Button, Text } from '@suite-native/atoms'; +import { Translation } from '@suite-native/intl'; +import { prepareNativeStyle, useNativeStyles } from '@trezor/styles'; + +const bottomSheetElementStyle = prepareNativeStyle(utils => ({ + marginVertical: utils.spacings.small, +})); + +type StakingUnavailableBottomSheet = { + isCardSelected: boolean; + handleToggleBottomSheet: () => void; + handleDesktopClick: () => void; +}; + +export const StakingUnavailableBottomSheet = ({ + isCardSelected, + handleToggleBottomSheet, + handleDesktopClick, +}: StakingUnavailableBottomSheet) => { + const { applyStyle } = useNativeStyles(); + + if (!isCardSelected) return null; + + return ( + + + + } + isVisible + isCloseDisplayed={false} + onClose={handleToggleBottomSheet} + paddingHorizontal="large" + > + + + + + + + + + ); +}; diff --git a/suite-native/module-staking-management/src/index.ts b/suite-native/module-staking-management/src/index.ts new file mode 100644 index 00000000000..669bb9549f9 --- /dev/null +++ b/suite-native/module-staking-management/src/index.ts @@ -0,0 +1 @@ +export { StakingDetailScreen } from './screens/StakingDetailScreen'; diff --git a/suite-native/module-staking-management/src/screens/StakingDetailScreen.tsx b/suite-native/module-staking-management/src/screens/StakingDetailScreen.tsx new file mode 100644 index 00000000000..329915bb77a --- /dev/null +++ b/suite-native/module-staking-management/src/screens/StakingDetailScreen.tsx @@ -0,0 +1,17 @@ +import { RouteProp, useRoute } from '@react-navigation/native'; + +import { RootStackParamList, RootStackRoutes, Screen } from '@suite-native/navigation'; + +import { StakingDetailScreenHeader } from '../components/StakingDetailScreenHeader'; +import { StakingInfo } from '../components/StakingInfo'; + +export const StakingDetailScreen = () => { + const route = useRoute>(); + const { accountKey } = route.params; + + return ( + }> + + + ); +}; diff --git a/suite-native/module-staking-management/tsconfig.json b/suite-native/module-staking-management/tsconfig.json new file mode 100644 index 00000000000..e2f68e93300 --- /dev/null +++ b/suite-native/module-staking-management/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "libDev" }, + "references": [ + { + "path": "../../suite-common/icons-deprecated" + }, + { + "path": "../../suite-common/wallet-core" + }, + { "path": "../atoms" }, + { "path": "../formatters" }, + { "path": "../intl" }, + { "path": "../link" }, + { "path": "../navigation" }, + { "path": "../staking" }, + { "path": "../../packages/styles" } + ] +} diff --git a/suite-native/navigation/src/navigators.ts b/suite-native/navigation/src/navigators.ts index 6512a3636ad..d824302175d 100644 --- a/suite-native/navigation/src/navigators.ts +++ b/suite-native/navigation/src/navigators.ts @@ -168,6 +168,7 @@ export type RootStackParamList = { }; [RootStackRoutes.DevUtilsStack]: undefined; [RootStackRoutes.AccountDetail]: AccountDetailParams; + [RootStackRoutes.StakingDetail]: { accountKey: AccountKey }; [RootStackRoutes.DeviceInfo]: undefined; [RootStackRoutes.AddCoinAccountStack]: NavigatorScreenParams; [RootStackRoutes.SendStack]: NavigatorScreenParams; diff --git a/suite-native/navigation/src/routes.ts b/suite-native/navigation/src/routes.ts index e1ca4f4b8d5..fb5cc674181 100644 --- a/suite-native/navigation/src/routes.ts +++ b/suite-native/navigation/src/routes.ts @@ -4,6 +4,7 @@ export enum RootStackRoutes { AccountsImport = 'AccountsImport', AuthorizeDeviceStack = 'AuthorizeDeviceStack', AccountDetail = 'AccountDetail', + StakingDetail = 'StakingDetail', DevUtilsStack = 'DevUtilsStack', AccountSettings = 'AccountSettings', TransactionDetail = 'TransactionDetail', diff --git a/suite-native/staking/package.json b/suite-native/staking/package.json new file mode 100644 index 00000000000..52629a60cdf --- /dev/null +++ b/suite-native/staking/package.json @@ -0,0 +1,19 @@ +{ + "name": "@suite-native/staking", + "version": "1.0.0", + "private": true, + "license": "See LICENSE.md in repo root", + "sideEffects": false, + "main": "src/index", + "scripts": { + "depcheck": "yarn g:depcheck", + "lint:js": "yarn g:eslint '**/*.{ts,tsx,js}'", + "type-check": "yarn g:tsc --build" + }, + "dependencies": { + "@suite-common/wallet-config": "workspace:*", + "@suite-common/wallet-core": "workspace:*", + "@suite-common/wallet-types": "workspace:*", + "@suite-common/wallet-utils": "workspace:*" + } +} diff --git a/suite-native/staking/src/ethereumStakingSelectors.ts b/suite-native/staking/src/ethereumStakingSelectors.ts new file mode 100644 index 00000000000..630bdb1dcf9 --- /dev/null +++ b/suite-native/staking/src/ethereumStakingSelectors.ts @@ -0,0 +1,101 @@ +import { NetworkSymbol } from '@suite-common/wallet-config'; +import { + AccountsRootState, + selectAccountByKey, + selectAccountNetworkSymbol, + selectAccountStakeTransactions, + selectDeviceAccounts, + selectPoolStatsApyData, + StakeRootState, + TransactionsRootState, +} from '@suite-common/wallet-core'; +import { AccountKey } from '@suite-common/wallet-types'; +import { getAccountEverstakeStakingPool, isPending } from '@suite-common/wallet-utils'; + +import { NativeStakingRootState } from './types'; + +export const selectVisibleDeviceEthereumAccountsWithStakingByNetworkSymbol = ( + state: NativeStakingRootState, + networkSymbol: NetworkSymbol | null, +) => { + const accounts = selectDeviceAccounts(state); + + return accounts.filter( + account => + account.symbol === networkSymbol && + account.visible && + !!getAccountEverstakeStakingPool(account), + ); +}; + +export const selectEthereumStakingPoolByAccountKey = ( + state: AccountsRootState, + accountKey: string, +) => { + const account = selectAccountByKey(state, accountKey); + if (!account) return null; + + return getAccountEverstakeStakingPool(account); +}; + +export const selectEthereumAccountHasStaking = ( + state: NativeStakingRootState, + accountKey: AccountKey, +) => !!selectEthereumStakingPoolByAccountKey(state, accountKey); + +export const selectEthereumIsStakePendingByAccountKey = ( + state: AccountsRootState, + accountKey: string, +) => { + const stakingPool = selectEthereumStakingPoolByAccountKey(state, accountKey); + const isStakePending = Number(stakingPool?.totalPendingStakeBalance ?? 0) > 0; + + return isStakePending; +}; + +export const selectEthereumIsStakeConfirmingByAccountKey = ( + state: TransactionsRootState, + accountKey: string, +) => { + const stakeTxs = selectAccountStakeTransactions(state, accountKey); + const isStakeConfirming = stakeTxs.some(tx => isPending(tx)); + + return isStakeConfirming; +}; + +export const selectEthereumAPYByAccountKey = ( + state: StakeRootState & AccountsRootState, + accountKey: string, +) => { + const networkSymbol = selectAccountNetworkSymbol(state, accountKey); + if (!networkSymbol) return null; + + return selectPoolStatsApyData(state, networkSymbol); +}; + +export const selectEthereumStakedBalanceByAccountKey = ( + state: AccountsRootState, + accountKey: string, +) => { + const stakingPool = selectEthereumStakingPoolByAccountKey(state, accountKey); + + return stakingPool?.depositedBalance ?? '0'; +}; + +export const selectEthereumRewardsBalanceByAccountKey = ( + state: AccountsRootState, + accountKey: string, +) => { + const stakingPool = selectEthereumStakingPoolByAccountKey(state, accountKey); + + return stakingPool?.restakedReward ?? '0'; +}; + +export const selectEthereumTotalStakePendingByAccountKey = ( + state: AccountsRootState, + accountKey: string, +) => { + const stakingPool = selectEthereumStakingPoolByAccountKey(state, accountKey); + + return stakingPool?.totalPendingStakeBalance ?? '0'; +}; diff --git a/suite-native/staking/src/index.ts b/suite-native/staking/src/index.ts new file mode 100644 index 00000000000..a29a07b2a85 --- /dev/null +++ b/suite-native/staking/src/index.ts @@ -0,0 +1,7 @@ +// Do not export anything from coin specific files like ethereumSelectors.ts etc. +// This package should be expose only coin agnostic staking functionality. +// Exposing coin specific selectors is not a good practice and should be last resort. + +export * from './utils'; +export * from './selectors'; +export * from './types'; diff --git a/suite-native/staking/src/selectors.ts b/suite-native/staking/src/selectors.ts new file mode 100644 index 00000000000..beaedc6203c --- /dev/null +++ b/suite-native/staking/src/selectors.ts @@ -0,0 +1,228 @@ +import { NetworkSymbol } from '@suite-common/wallet-config'; +import { selectAccountByKey } from '@suite-common/wallet-core'; +import { Account, AccountKey } from '@suite-common/wallet-types'; +import { getEthereumCryptoBalanceWithStaking } from '@suite-common/wallet-utils'; + +import { + selectEthereumAccountHasStaking, + selectEthereumAPYByAccountKey, + selectEthereumIsStakeConfirmingByAccountKey, + selectEthereumIsStakePendingByAccountKey, + selectEthereumRewardsBalanceByAccountKey, + selectEthereumStakedBalanceByAccountKey, + selectEthereumTotalStakePendingByAccountKey, + selectVisibleDeviceEthereumAccountsWithStakingByNetworkSymbol, +} from './ethereumStakingSelectors'; +import { NativeStakingRootState } from './types'; +import { doesCoinSupportStaking } from './utils'; + +// create empty array in advance so it will be always same on shallow comparison +const EMPTY_ACCOUNT_ARRAY: Account[] = []; + +export const selectDeviceAccountsWithStaking = ( + state: NativeStakingRootState, + networkSymbol: NetworkSymbol, +) => { + if (!doesCoinSupportStaking(networkSymbol)) { + return EMPTY_ACCOUNT_ARRAY; + } + + switch (networkSymbol) { + case 'eth': + case 'thol': + case 'tsep': + return selectVisibleDeviceEthereumAccountsWithStakingByNetworkSymbol(state, 'eth'); + default: + // This throws error if any networkSymbol is not handled. + networkSymbol satisfies never; + + return EMPTY_ACCOUNT_ARRAY; + } +}; + +export const selectHasAnyDeviceAccountsWithStaking = ( + state: NativeStakingRootState, + networkSymbol: NetworkSymbol, +) => { + return selectDeviceAccountsWithStaking(state, networkSymbol).length > 0; +}; + +export const selectAccountCryptoBalanceWithStaking = ( + state: NativeStakingRootState, + accountKey: AccountKey, +) => { + const account = selectAccountByKey(state, accountKey); + if (!account) return '0'; + + if (!doesCoinSupportStaking(account.symbol)) { + return account.formattedBalance; + } + + switch (account.symbol) { + case 'eth': + case 'thol': + case 'tsep': + return getEthereumCryptoBalanceWithStaking(account); + default: + // This is to make sure that all cases are handled. + account.symbol satisfies never; + + return account.formattedBalance; + } +}; + +export const selectAccountHasStaking = (state: NativeStakingRootState, accountKey: AccountKey) => { + const account = selectAccountByKey(state, accountKey); + const accountSymbol = account?.symbol; + + if (!accountSymbol || !doesCoinSupportStaking(accountSymbol)) { + return false; + } + + switch (accountSymbol) { + case 'eth': + case 'thol': + case 'tsep': + return selectEthereumAccountHasStaking(state, accountKey); + default: + // This throws error if any networkSymbol is not handled. + accountSymbol satisfies never; + + return false; + } +}; + +export const selectIsStakePendingByAccountKey = ( + state: NativeStakingRootState, + accountKey: AccountKey, +) => { + const account = selectAccountByKey(state, accountKey); + const accountSymbol = account?.symbol; + if (!accountSymbol || !doesCoinSupportStaking(accountSymbol)) { + return false; + } + + switch (accountSymbol) { + case 'eth': + case 'thol': + case 'tsep': + return selectEthereumIsStakePendingByAccountKey(state, accountKey); + default: + // This throws error if any networkSymbol is not handled. + accountSymbol satisfies never; + + return false; + } +}; + +export const selectIsStakeConfirmingByAccountKey = ( + state: NativeStakingRootState, + accountKey: AccountKey, +) => { + const account = selectAccountByKey(state, accountKey); + const accountSymbol = account?.symbol; + if (!accountSymbol || !doesCoinSupportStaking(accountSymbol)) { + return false; + } + + switch (accountSymbol) { + case 'eth': + case 'thol': + case 'tsep': + return selectEthereumIsStakeConfirmingByAccountKey(state, accountKey); + default: + // This throws error if any networkSymbol is not handled. + accountSymbol satisfies never; + + return false; + } +}; + +export const selectAPYByAccountKey = (state: NativeStakingRootState, accountKey: AccountKey) => { + const account = selectAccountByKey(state, accountKey); + const accountSymbol = account?.symbol; + if (!accountSymbol || !doesCoinSupportStaking(accountSymbol)) { + return null; + } + + switch (accountSymbol) { + case 'eth': + case 'thol': + case 'tsep': + return selectEthereumAPYByAccountKey(state, accountKey); + default: + // This throws error if any networkSymbol is not handled. + accountSymbol satisfies never; + + return null; + } +}; + +export const selectStakedBalanceByAccountKey = ( + state: NativeStakingRootState, + accountKey: AccountKey, +) => { + const account = selectAccountByKey(state, accountKey); + const accountSymbol = account?.symbol; + if (!accountSymbol || !doesCoinSupportStaking(accountSymbol)) { + return '0'; + } + + switch (accountSymbol) { + case 'eth': + case 'thol': + case 'tsep': + return selectEthereumStakedBalanceByAccountKey(state, accountKey); + default: + // This throws error if any networkSymbol is not handled. + accountSymbol satisfies never; + + return '0'; + } +}; + +export const selectRewardsBalanceByAccountKey = ( + state: NativeStakingRootState, + accountKey: AccountKey, +) => { + const account = selectAccountByKey(state, accountKey); + const accountSymbol = account?.symbol; + if (!accountSymbol || !doesCoinSupportStaking(accountSymbol)) { + return '0'; + } + + switch (accountSymbol) { + case 'eth': + case 'thol': + case 'tsep': + return selectEthereumRewardsBalanceByAccountKey(state, accountKey); + default: + // This throws error if any networkSymbol is not handled. + accountSymbol satisfies never; + + return '0'; + } +}; + +export const selectTotalStakePendingByAccountKey = ( + state: NativeStakingRootState, + accountKey: AccountKey, +) => { + const account = selectAccountByKey(state, accountKey); + const accountSymbol = account?.symbol; + if (!accountSymbol || !doesCoinSupportStaking(accountSymbol)) { + return '0'; + } + + switch (accountSymbol) { + case 'eth': + case 'thol': + case 'tsep': + return selectEthereumTotalStakePendingByAccountKey(state, accountKey); + default: + // This throws error if any networkSymbol is not handled. + accountSymbol satisfies never; + + return '0'; + } +}; diff --git a/suite-native/staking/src/types.ts b/suite-native/staking/src/types.ts new file mode 100644 index 00000000000..883b4bc0bcd --- /dev/null +++ b/suite-native/staking/src/types.ts @@ -0,0 +1,11 @@ +import { + AccountsRootState, + DeviceRootState, + StakeRootState, + TransactionsRootState, +} from '@suite-common/wallet-core'; + +export type NativeStakingRootState = AccountsRootState & + DeviceRootState & + StakeRootState & + TransactionsRootState; diff --git a/suite-native/staking/src/utils.ts b/suite-native/staking/src/utils.ts new file mode 100644 index 00000000000..bce1cb00327 --- /dev/null +++ b/suite-native/staking/src/utils.ts @@ -0,0 +1,10 @@ +import { NetworkSymbol } from '@suite-common/wallet-config'; + +const stakingCoins = ['eth', 'thol', 'tsep'] as const satisfies NetworkSymbol[]; +type NetworkSymbolWithStaking = (typeof stakingCoins)[number]; + +export const doesCoinSupportStaking = ( + symbol: NetworkSymbol, +): symbol is NetworkSymbolWithStaking => { + return stakingCoins.includes(symbol as any); +}; diff --git a/suite-native/staking/tsconfig.json b/suite-native/staking/tsconfig.json new file mode 100644 index 00000000000..5732d803b07 --- /dev/null +++ b/suite-native/staking/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "libDev" }, + "references": [ + { + "path": "../../suite-common/wallet-config" + }, + { + "path": "../../suite-common/wallet-core" + }, + { + "path": "../../suite-common/wallet-types" + }, + { + "path": "../../suite-common/wallet-utils" + } + ] +} diff --git a/tsconfig.json b/tsconfig.json index 6f4d9e835ae..b93626b2dcc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -98,6 +98,9 @@ { "path": "suite-native/module-settings" }, + { + "path": "suite-native/module-staking-management" + }, { "path": "suite-native/navigation" }, { "path": "suite-native/notifications" }, { "path": "suite-native/qr-code" }, @@ -106,6 +109,7 @@ }, { "path": "suite-native/receive" }, { "path": "suite-native/settings" }, + { "path": "suite-native/staking" }, { "path": "suite-native/state" }, { "path": "suite-native/storage" }, { "path": "suite-native/test-utils" }, diff --git a/yarn.lock b/yarn.lock index a46dc7995f2..8bd1cac2709 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9490,6 +9490,8 @@ __metadata: "@suite-native/intl": "workspace:*" "@suite-native/navigation": "workspace:*" "@suite-native/settings": "workspace:*" + "@suite-native/staking": "workspace:*" + "@suite-native/toasts": "workspace:*" "@suite-native/tokens": "workspace:*" "@trezor/styles": "workspace:*" jotai: "npm:1.9.1" @@ -9585,6 +9587,7 @@ __metadata: "@suite-native/module-receive": "workspace:*" "@suite-native/module-send": "workspace:*" "@suite-native/module-settings": "workspace:*" + "@suite-native/module-staking-management": "workspace:*" "@suite-native/navigation": "workspace:*" "@suite-native/notifications": "workspace:*" "@suite-native/receive": "workspace:*" @@ -9665,8 +9668,8 @@ __metadata: "@suite-native/intl": "workspace:*" "@suite-native/navigation": "workspace:*" "@suite-native/settings": "workspace:*" + "@suite-native/staking": "workspace:*" "@suite-native/tokens": "workspace:*" - "@trezor/styles": "workspace:*" proxy-memoize: "npm:2.0.2" react: "npm:^18.2.0" react-native: "npm:0.75.2" @@ -10415,6 +10418,26 @@ __metadata: languageName: unknown linkType: soft +"@suite-native/module-staking-management@workspace:*, @suite-native/module-staking-management@workspace:suite-native/module-staking-management": + version: 0.0.0-use.local + resolution: "@suite-native/module-staking-management@workspace:suite-native/module-staking-management" + dependencies: + "@react-navigation/native": "npm:6.1.18" + "@suite-common/icons-deprecated": "workspace:*" + "@suite-common/wallet-core": "workspace:*" + "@suite-native/atoms": "workspace:*" + "@suite-native/formatters": "workspace:*" + "@suite-native/intl": "workspace:*" + "@suite-native/link": "workspace:*" + "@suite-native/navigation": "workspace:*" + "@suite-native/staking": "workspace:*" + "@trezor/styles": "workspace:*" + react: "npm:18.2.0" + react-native: "npm:0.75.2" + react-redux: "npm:8.0.7" + languageName: unknown + linkType: soft + "@suite-native/navigation@workspace:*, @suite-native/navigation@workspace:suite-native/navigation": version: 0.0.0-use.local resolution: "@suite-native/navigation@workspace:suite-native/navigation" @@ -10554,6 +10577,17 @@ __metadata: languageName: unknown linkType: soft +"@suite-native/staking@workspace:*, @suite-native/staking@workspace:suite-native/staking": + version: 0.0.0-use.local + resolution: "@suite-native/staking@workspace:suite-native/staking" + dependencies: + "@suite-common/wallet-config": "workspace:*" + "@suite-common/wallet-core": "workspace:*" + "@suite-common/wallet-types": "workspace:*" + "@suite-common/wallet-utils": "workspace:*" + languageName: unknown + linkType: soft + "@suite-native/state@workspace:*, @suite-native/state@workspace:suite-native/state": version: 0.0.0-use.local resolution: "@suite-native/state@workspace:suite-native/state"