diff --git a/packages/core-mobile/app/new/common/components/WalletCard.tsx b/packages/core-mobile/app/new/common/components/WalletCard.tsx index 19569036fa..5109741bdb 100644 --- a/packages/core-mobile/app/new/common/components/WalletCard.tsx +++ b/packages/core-mobile/app/new/common/components/WalletCard.tsx @@ -8,8 +8,12 @@ import { } from '@avalabs/k2-alpine' import { useManageWallet } from 'common/hooks/useManageWallet' import { WalletDisplayData } from 'common/types' -import React, { useCallback } from 'react' +import React, { useCallback, useMemo } from 'react' import { StyleProp, ViewStyle } from 'react-native' +import { useSelector } from 'react-redux' +import { selectAccounts } from 'store/account' +import { TotalAccountBalanceForWallet } from 'features/accountSettings/components/ToalAccountBalanceForWallet' +import { isPlatformAccount } from 'store/account/utils' import { DropdownMenu } from './DropdownMenu' const ITEM_HEIGHT = 50 @@ -34,6 +38,16 @@ const WalletCard = ({ } = useTheme() const { getDropdownItems, handleDropdownSelect } = useManageWallet() + const accounts = useSelector(selectAccounts) + + // number of accounts in the wallet (excluding X/P chains) + const accountsCount = useMemo(() => { + return accounts.filter( + account => + account.walletId === wallet.id && !isPlatformAccount(account.id) + ).length + }, [accounts, wallet.id]) + const renderExpansionIcon = useCallback(() => { return ( {renderExpansionIcon()} {renderWalletIcon()} - - {wallet.name} - + + + {wallet.name} + + + {`${accountsCount} ${ + accountsCount > 1 ? 'accounts' : 'account' + } + X/P Chains`} + + + {/* total balance */} + + {showMoreButton && ( void + isFetchingBalance: boolean + balance: number +} => { + const dispatch = useDispatch() + const wallet = useSelector(selectWalletById(walletId)) + const xpBalanceStatus = useSelector(selectXpBalanceStatus) + const isBalanceLoading = xpBalanceStatus !== QueryStatus.IDLE + const [isFetchingBalance, setIsFetchingBalance] = useState(true) + const accountBalance = useSelector( + selectBalanceTotalInCurrencyForXpNetwork(walletId, networkType) + ) + + const isBalanceLoaded = useSelector( + selectIsXpBalanceLoadedForWallet(walletId, networkType) + ) + + const fetchBalance = useCallback(() => { + if (wallet) { + dispatch(fetchXpBalancesForWallet({ wallet })) + setIsFetchingBalance(true) + } + }, [wallet, dispatch]) + + useEffect(() => { + if (!isBalanceLoading && isFetchingBalance) { + setIsFetchingBalance(false) + } + }, [isFetchingBalance, isBalanceLoading, setIsFetchingBalance]) + + return { + balance: accountBalance, + fetchBalance, + isFetchingBalance, + isBalanceLoaded + } +} diff --git a/packages/core-mobile/app/new/common/hooks/useContacts.ts b/packages/core-mobile/app/new/common/hooks/useContacts.ts index 9176133216..51bc785114 100644 --- a/packages/core-mobile/app/new/common/hooks/useContacts.ts +++ b/packages/core-mobile/app/new/common/hooks/useContacts.ts @@ -15,12 +15,12 @@ export const useContacts = (): { contacts: Contact[] } => { const selectedRecentContacts = useSelector(selectRecentContacts) - const accountCollection = useSelector(selectAccounts) + const allAccounts = useSelector(selectAccounts) const { avatar } = useAvatar() const accounts = useMemo( () => - Object.values(accountCollection).map( + allAccounts.map( account => ({ id: account.id, @@ -33,7 +33,7 @@ export const useContacts = (): { type: 'account' } as Contact) ), - [accountCollection, avatar] + [allAccounts, avatar] ) const contactCollection = useSelector(selectContacts) const contacts = useMemo(() => { diff --git a/packages/core-mobile/app/new/common/types.ts b/packages/core-mobile/app/new/common/types.ts index d8b9ca28ba..c9f290be53 100644 --- a/packages/core-mobile/app/new/common/types.ts +++ b/packages/core-mobile/app/new/common/types.ts @@ -17,17 +17,20 @@ export type WalletDisplayData = { id: string name: string type: WalletType - accounts: Array<{ - hideSeparator: boolean - containerSx: { - backgroundColor: string - borderRadius: number - } - title: React.JSX.Element - subtitle: React.JSX.Element - leftIcon: React.JSX.Element - value: React.JSX.Element - onPress: () => void - accessory: React.JSX.Element - }> + accounts: AccountDataForWallet[] +} + +export type AccountDataForWallet = { + id: string + hideSeparator: boolean + containerSx: { + backgroundColor: string + borderRadius: number + } + title: React.JSX.Element + subtitle: React.JSX.Element + leftIcon: React.JSX.Element + value: React.JSX.Element + onPress: () => void + accessory: React.JSX.Element } diff --git a/packages/core-mobile/app/new/features/accountSettings/components/AcccountList.tsx b/packages/core-mobile/app/new/features/accountSettings/components/AcccountList.tsx index 0528aa9ee0..08e126a228 100644 --- a/packages/core-mobile/app/new/features/accountSettings/components/AcccountList.tsx +++ b/packages/core-mobile/app/new/features/accountSettings/components/AcccountList.tsx @@ -8,12 +8,14 @@ import { useDispatch, useSelector } from 'react-redux' import AnalyticsService from 'services/analytics/AnalyticsService' import { Account, - selectAccounts, selectActiveAccount, + selectAllAccounts, setActiveAccount } from 'store/account' +import { isPlatformAccount } from 'store/account/utils' import { useRecentAccounts } from '../store' import { AccountItem } from './AccountItem' +import { XpAccountItem } from './XpAccountItem' const CARD_PADDING = 12 @@ -28,7 +30,7 @@ export const AccountList = (): React.JSX.Element => { const dispatch = useDispatch() const { navigate, dismiss } = useRouter() const activeAccount = useSelector(selectActiveAccount) - const accountCollection = useSelector(selectAccounts) + const accountCollection = useSelector(selectAllAccounts) const flatListRef = useRef(null) const { recentAccountIds, updateRecentAccount } = useRecentAccounts() @@ -41,7 +43,7 @@ export const AccountList = (): React.JSX.Element => { const recentAccounts = useMemo(() => { return recentAccountIds - .map(id => accountCollection[id]) + .map(id => (isPlatformAccount(id) ? undefined : accountCollection[id])) .filter((account): account is Account => account !== undefined) }, [accountCollection, recentAccountIds]) @@ -93,15 +95,27 @@ export const AccountList = (): React.JSX.Element => { }, [recentAccounts.length]) const renderItem = useCallback( - ({ item, index }: { item: Account; index: number }) => ( - - ), + ({ item, index }: { item: Account; index: number }) => { + if (isPlatformAccount(item.id)) { + return ( + + ) + } + return ( + + ) + }, [activeAccount?.id, gotoAccountDetails, onSelectAccount] ) diff --git a/packages/core-mobile/app/new/features/accountSettings/components/AccountBalance.tsx b/packages/core-mobile/app/new/features/accountSettings/components/AccountBalance.tsx new file mode 100644 index 0000000000..79eca4b24b --- /dev/null +++ b/packages/core-mobile/app/new/features/accountSettings/components/AccountBalance.tsx @@ -0,0 +1,84 @@ +import React, { useCallback, useMemo } from 'react' +import { + ActivityIndicator, + alpha, + AnimatedBalance, + Icons, + Pressable, + SCREEN_WIDTH, + useTheme +} from '@avalabs/k2-alpine' +import { useSelector } from 'react-redux' +import { selectIsPrivacyModeEnabled } from 'store/settings/securityPrivacy' +import { useBalanceForAccount } from 'new/common/contexts/useBalanceForAccount' +import { useFormatCurrency } from 'new/common/hooks/useFormatCurrency' +import { UNKNOWN_AMOUNT } from 'consts/amount' +import { HiddenBalanceText } from 'common/components/HiddenBalanceText' + +export const AccountBalance = ({ + isActive, + accountId +}: { + isActive: boolean + accountId: string +}): React.JSX.Element => { + const isPrivacyModeEnabled = useSelector(selectIsPrivacyModeEnabled) + const { + theme: { colors } + } = useTheme() + const { + balance: accountBalance, + fetchBalance, + isFetchingBalance, + isBalanceLoaded + } = useBalanceForAccount(accountId) + const { formatCurrency } = useFormatCurrency() + + const balance = useMemo(() => { + return accountBalance === 0 + ? formatCurrency({ amount: 0 }).replace(/[\d.,]+/g, UNKNOWN_AMOUNT) + : formatCurrency({ amount: accountBalance }) + }, [accountBalance, formatCurrency]) + + const renderMaskView = useCallback(() => { + return ( + + ) + }, [colors.$textPrimary, isActive]) + + if (isFetchingBalance) { + return + } + + if (!isBalanceLoaded) { + return ( + + + + ) + } + + return ( + + ) +} diff --git a/packages/core-mobile/app/new/features/accountSettings/components/ToalAccountBalanceForWallet.tsx b/packages/core-mobile/app/new/features/accountSettings/components/ToalAccountBalanceForWallet.tsx new file mode 100644 index 0000000000..38cccd4fae --- /dev/null +++ b/packages/core-mobile/app/new/features/accountSettings/components/ToalAccountBalanceForWallet.tsx @@ -0,0 +1,74 @@ +import React, { useCallback, useMemo } from 'react' +import { + ActivityIndicator, + alpha, + AnimatedBalance, + SCREEN_WIDTH, + useTheme +} from '@avalabs/k2-alpine' +import { useSelector } from 'react-redux' +import { selectIsPrivacyModeEnabled } from 'store/settings/securityPrivacy' +import { useFormatCurrency } from 'new/common/hooks/useFormatCurrency' +import { UNKNOWN_AMOUNT } from 'consts/amount' +import { HiddenBalanceText } from 'common/components/HiddenBalanceText' +import { + selectBalanceTotalInCurrencyForWallet, + selectIsLoadingBalances +} from 'store/balance' +import { selectTokenVisibility } from 'store/portfolio' + +export const TotalAccountBalanceForWallet = ({ + walletId +}: { + walletId: string +}): React.JSX.Element => { + const isPrivacyModeEnabled = useSelector(selectIsPrivacyModeEnabled) + const { + theme: { colors } + } = useTheme() + const tokenVisibility = useSelector(selectTokenVisibility) + const isFetchingBalance = useSelector(selectIsLoadingBalances) + const totalBalance = useSelector( + selectBalanceTotalInCurrencyForWallet(walletId, tokenVisibility) + ) + + const { formatCurrency } = useFormatCurrency() + + const balance = useMemo(() => { + return totalBalance === 0 + ? formatCurrency({ amount: 0 }).replace(/[\d.,]+/g, UNKNOWN_AMOUNT) + : formatCurrency({ amount: totalBalance }) + }, [totalBalance, formatCurrency]) + + const renderMaskView = useCallback(() => { + return ( + + ) + }, [colors.$textPrimary]) + + if (isFetchingBalance) { + return + } + + return ( + + ) +} diff --git a/packages/core-mobile/app/new/features/accountSettings/components/XpAccountBalance.tsx b/packages/core-mobile/app/new/features/accountSettings/components/XpAccountBalance.tsx new file mode 100644 index 0000000000..bce4f20f08 --- /dev/null +++ b/packages/core-mobile/app/new/features/accountSettings/components/XpAccountBalance.tsx @@ -0,0 +1,87 @@ +import React, { useCallback, useMemo } from 'react' +import { + ActivityIndicator, + alpha, + AnimatedBalance, + Icons, + Pressable, + SCREEN_WIDTH, + useTheme +} from '@avalabs/k2-alpine' +import { useSelector } from 'react-redux' +import { selectIsPrivacyModeEnabled } from 'store/settings/securityPrivacy' +import { useFormatCurrency } from 'new/common/hooks/useFormatCurrency' +import { UNKNOWN_AMOUNT } from 'consts/amount' +import { HiddenBalanceText } from 'common/components/HiddenBalanceText' +import { useBalanceFoXpAccount } from 'common/contexts/useBalanceForXpAccunt' +import { XpNetworkVMType } from 'store/network' + +export const XpAccountBalance = ({ + isActive, + walletId, + networkType +}: { + isActive: boolean + walletId: string + networkType: XpNetworkVMType +}): React.JSX.Element => { + const isPrivacyModeEnabled = useSelector(selectIsPrivacyModeEnabled) + const { + theme: { colors } + } = useTheme() + const { + balance: accountBalance, + fetchBalance, + isFetchingBalance, + isBalanceLoaded + } = useBalanceFoXpAccount(walletId, networkType) + const { formatCurrency } = useFormatCurrency() + + const balance = useMemo(() => { + return accountBalance === 0 + ? formatCurrency({ amount: 0 }).replace(/[\d.,]+/g, UNKNOWN_AMOUNT) + : formatCurrency({ amount: accountBalance }) + }, [accountBalance, formatCurrency]) + + const renderMaskView = useCallback(() => { + return ( + + ) + }, [colors.$textPrimary, isActive]) + + if (isFetchingBalance) { + return + } + + if (!isBalanceLoaded) { + return ( + + + + ) + } + + return ( + + ) +} diff --git a/packages/core-mobile/app/new/features/accountSettings/components/XpAccountItem.tsx b/packages/core-mobile/app/new/features/accountSettings/components/XpAccountItem.tsx new file mode 100644 index 0000000000..40bd5fb6fe --- /dev/null +++ b/packages/core-mobile/app/new/features/accountSettings/components/XpAccountItem.tsx @@ -0,0 +1,163 @@ +import { + ActivityIndicator, + alpha, + AnimatedBalance, + AnimatedPressable, + Icons, + Pressable, + Text, + useTheme, + usePreventParentPress, + View +} from '@avalabs/k2-alpine' +import { HiddenBalanceText } from 'common/components/HiddenBalanceText' +import { useFormatCurrency } from 'common/hooks/useFormatCurrency' +import { getItemEnteringAnimation } from 'common/utils/animations' +import React, { memo, useCallback, useMemo } from 'react' +import Animated, { LinearTransition } from 'react-native-reanimated' +import { useSelector } from 'react-redux' +import { Account } from 'store/account' +import { selectIsPrivacyModeEnabled } from 'store/settings/securityPrivacy' +import { useBalanceFoXpAccount } from 'common/contexts/useBalanceForXpAccunt' +import { XpNetworkVMType } from 'store/network/types' +import { ACCOUNT_CARD_SIZE } from './AcccountList' + +export const XpAccountItem = memo( + ({ + index, + isActive, + account, + onSelectAccount + }: { + index: number + isActive: boolean + account: Account + onSelectAccount: (account: Account) => void + testID?: string + }): React.JSX.Element => { + const { + balance: accountBalance, + fetchBalance, + isFetchingBalance, + isBalanceLoaded + } = useBalanceFoXpAccount(account.walletId, account.id as XpNetworkVMType) + const isPrivacyModeEnabled = useSelector(selectIsPrivacyModeEnabled) + const { + theme: { colors } + } = useTheme() + const { formatCurrency } = useFormatCurrency() + + const { createParentPressHandler, createChildPressHandler } = + usePreventParentPress() + + const handleSelectAccount = createParentPressHandler(() => { + onSelectAccount(account) + }) + + const handleFetchBalance = createChildPressHandler(() => { + fetchBalance() + }) + + const balance = useMemo(() => { + // CP-10570: Balances should never show $0.00 + return accountBalance === 0 + ? '' + : `${formatCurrency({ + amount: accountBalance, + notation: accountBalance < 100000 ? undefined : 'compact' + })}` + }, [accountBalance, formatCurrency]) + + const containerBackgroundColor = isActive + ? colors.$textPrimary + : colors.$surfaceSecondary + + const accountNameColor = isActive + ? colors.$surfacePrimary + : colors.$textPrimary + + const renderMaskView = useCallback(() => { + return ( + + ) + }, [accountNameColor]) + + const renderBalance = useCallback(() => { + if (isFetchingBalance) { + return ( + + ) + } + + if (!isBalanceLoaded) { + return ( + + + + + + ) + } + return ( + + ) + }, [ + isFetchingBalance, + isBalanceLoaded, + balance, + isPrivacyModeEnabled, + renderMaskView, + accountNameColor, + handleFetchBalance, + colors.$textPrimary + ]) + + return ( + + + + + {account.name} + + {renderBalance()} + + + + ) + } +) diff --git a/packages/core-mobile/app/new/features/accountSettings/consts.ts b/packages/core-mobile/app/new/features/accountSettings/consts.ts index 16399d0d3f..cfc103c8ca 100644 --- a/packages/core-mobile/app/new/features/accountSettings/consts.ts +++ b/packages/core-mobile/app/new/features/accountSettings/consts.ts @@ -12,3 +12,6 @@ export enum AddressType { SOLANA = ChainName.SOLANA, SOLANA_DEVNET = ChainName.SOLANA_DEVNET } + +export const IMPORTED_ACCOUNTS_VIRTUAL_WALLET_ID = 'imported-accounts-wallet-id' +export const IMPORTED_ACCOUNTS_VIRTUAL_WALLET_NAME = 'Imported' diff --git a/packages/core-mobile/app/new/features/accountSettings/hooks/useGetAccountData.tsx b/packages/core-mobile/app/new/features/accountSettings/hooks/useGetAccountData.tsx new file mode 100644 index 0000000000..52d872c411 --- /dev/null +++ b/packages/core-mobile/app/new/features/accountSettings/hooks/useGetAccountData.tsx @@ -0,0 +1,236 @@ +import { truncateAddress } from '@avalabs/core-utils-sdk/dist' +import { + alpha, + Icons, + SCREEN_WIDTH, + Text, + TouchableOpacity, + useTheme, + View +} from '@avalabs/k2-alpine' +import { TRUNCATE_ADDRESS_LENGTH } from 'common/consts/text' +import { AccountDataForWallet } from 'common/types' +import React, { useCallback } from 'react' +import { setActiveAccount, selectActiveAccount } from 'store/account' +import { useDispatch, useSelector } from 'react-redux' +import { useRouter } from 'expo-router' +import { NetworkVMType } from '@avalabs/vm-module-types' +import { + P_CHAIN_ACCOUNT_NAME, + X_CHAIN_ACCOUNT_NAME +} from 'store/account/consts' +import { GetAccountDataProps, GetXpAccountDataProps } from '../types' +import { AccountBalance } from '../components/AccountBalance' +import { XpAccountBalance } from '../components/XpAccountBalance' + +export const useGetAccountData = (): { + getAccountData: (props: GetAccountDataProps) => AccountDataForWallet + getXpAccountData: (props: GetXpAccountDataProps) => AccountDataForWallet +} => { + const { + theme: { colors } + } = useTheme() + const dispatch = useDispatch() + const { navigate, dismiss } = useRouter() + const activeAccount = useSelector(selectActiveAccount) + + const handleSetActiveAccount = useCallback( + (accountId: string) => { + if (accountId === activeAccount?.id) { + return + } + dispatch(setActiveAccount(accountId)) + dismiss() + dismiss() + }, + [activeAccount?.id, dispatch, dismiss] + ) + + const gotoAccountDetails = useCallback( + (accountId: string): void => { + navigate({ + // @ts-ignore TODO: make routes typesafe + pathname: '/accountSettings/account', + params: { accountId } + }) + }, + [navigate] + ) + + const getAccountData = useCallback( + ({ + hideSeparator, + isActive, + account, + walletName + }: GetAccountDataProps): AccountDataForWallet => { + return { + id: account.id, + hideSeparator, + containerSx: { + backgroundColor: isActive + ? alpha(colors.$textPrimary, 0.1) + : 'transparent', + borderRadius: 8 + }, + title: ( + + {account.name} + + ), + subtitle: ( + + {truncateAddress(account.addressC, TRUNCATE_ADDRESS_LENGTH)} + + ), + leftIcon: isActive ? ( + + ) : ( + + ), + value: , + onPress: () => handleSetActiveAccount(account.id), + accessory: ( + gotoAccountDetails(account.id)}> + + + ) + } + }, + [ + colors.$textPrimary, + colors.$textSecondary, + gotoAccountDetails, + handleSetActiveAccount + ] + ) + + const getXpAccountData = useCallback( + ({ + hideSeparator, + isActive, + accountId, + numberOfAddresses + }: GetXpAccountDataProps): AccountDataForWallet => { + const isPvm = accountId.includes(NetworkVMType.PVM.toString()) + const accountName = isPvm ? P_CHAIN_ACCOUNT_NAME : X_CHAIN_ACCOUNT_NAME + return { + id: accountId, + hideSeparator, + containerSx: { + backgroundColor: isActive + ? alpha(colors.$textPrimary, 0.1) + : 'transparent', + borderRadius: 8 + }, + title: ( + <> + + + {isPvm ? 'P' : 'X'} + + + + {accountName} + + + ), + subtitle: ( + + {numberOfAddresses} + {numberOfAddresses > 1 ? ' addresses' : ' address'} + + ), + leftIcon: isActive ? ( + + ) : ( + + ), + value: ( + + ), + onPress: () => handleSetActiveAccount(accountId), + accessory: + } + }, + [colors.$borderPrimary, colors.$textPrimary, handleSetActiveAccount] + ) + + return { + getAccountData, + getXpAccountData + } +} diff --git a/packages/core-mobile/app/new/features/accountSettings/hooks/useWalletsDisplayData.ts b/packages/core-mobile/app/new/features/accountSettings/hooks/useWalletsDisplayData.ts new file mode 100644 index 0000000000..1663660043 --- /dev/null +++ b/packages/core-mobile/app/new/features/accountSettings/hooks/useWalletsDisplayData.ts @@ -0,0 +1,171 @@ +import { useMemo } from 'react' +import { useSelector } from 'react-redux' +import { WalletType } from 'services/wallet/types' +import { + Account, + PlatformAccount, + selectAllAccounts, + selectActiveAccount +} from 'store/account' +import { selectWallets } from 'store/wallet/slice' +import { WalletDisplayData } from 'common/types' +import { isPlatformAccount } from 'store/account/utils' +import { + IMPORTED_ACCOUNTS_VIRTUAL_WALLET_ID, + IMPORTED_ACCOUNTS_VIRTUAL_WALLET_NAME +} from '../consts' +import { useGetAccountData } from './useGetAccountData' + +export const useWalletsDisplayData = ( + searchText: string +): WalletDisplayData[] => { + const allWallets = useSelector(selectWallets) + const activeAccount = useSelector(selectActiveAccount) + const { getAccountData, getXpAccountData } = useGetAccountData() + const accountCollection = useSelector(selectAllAccounts) + + const allAccountsArray: Account[] = useMemo( + () => Object.values(accountCollection), + [accountCollection] + ) + + const accountSearchResults = useMemo(() => { + if (!searchText) { + return allAccountsArray + } + return allAccountsArray.filter(account => { + const wallet = allWallets[account.walletId] + if (!wallet) { + return false + } + const walletName = wallet.name.toLowerCase() + + const isPrivateKeyAccount = wallet.type === WalletType.PRIVATE_KEY + const virtualWalletMatches = + isPrivateKeyAccount && + IMPORTED_ACCOUNTS_VIRTUAL_WALLET_NAME.toLowerCase().includes( + searchText.toLowerCase() + ) + const walletNameMatches = + !isPrivateKeyAccount && walletName.includes(searchText.toLowerCase()) + + return ( + virtualWalletMatches || + walletNameMatches || + account.name.toLowerCase().includes(searchText.toLowerCase()) || + account.addressC?.toLowerCase().includes(searchText.toLowerCase()) || + account.addressBTC?.toLowerCase().includes(searchText.toLowerCase()) || + account.addressAVM?.toLowerCase().includes(searchText.toLowerCase()) || + account.addressPVM?.toLowerCase().includes(searchText.toLowerCase()) || + account.addressSVM?.toLowerCase().includes(searchText.toLowerCase()) || + account.addressCoreEth?.toLowerCase().includes(searchText.toLowerCase()) + ) + }) + }, [allAccountsArray, allWallets, searchText]) + + const importedWallets = useMemo(() => { + return Object.values(allWallets).filter( + wallet => wallet.type === WalletType.PRIVATE_KEY + ) + }, [allWallets]) + + const primaryWallets = useMemo(() => { + return Object.values(allWallets).filter( + wallet => wallet.type !== WalletType.PRIVATE_KEY + ) + }, [allWallets]) + + const primaryWalletsDisplayData = useMemo(() => { + return primaryWallets.map(wallet => { + const accountsForWallet = accountSearchResults.filter( + account => account.walletId === wallet.id + ) + + const accountDataForWallet = accountsForWallet.map((account, index) => { + const isActive = account.id === activeAccount?.id + const nextAccount = accountsForWallet[index + 1] + const hideSeparator = isActive || nextAccount?.id === activeAccount?.id + + if (isPlatformAccount(account.id)) { + return getXpAccountData({ + hideSeparator: false, + isActive, + accountId: account.id, + numberOfAddresses: + (account as PlatformAccount).addresses?.length ?? 0 + }) + } + return getAccountData({ + hideSeparator, + isActive, + account, + walletName: wallet.name + }) + }) + + accountDataForWallet.sort((a, b) => { + if (isPlatformAccount(a.id)) { + return -1 + } + if (isPlatformAccount(b.id)) { + return 1 + } + return 0 + }) + + return { + ...wallet, + accounts: accountDataForWallet + } + }) + }, [ + primaryWallets, + accountSearchResults, + getXpAccountData, + activeAccount?.id, + getAccountData + ]) + + const importedWalletsDisplayData = useMemo(() => { + // Get all accounts from private key wallets + const allPrivateKeyAccounts = importedWallets.flatMap(wallet => { + return accountSearchResults.filter( + account => account.walletId === wallet.id + ) + }) + + if (allPrivateKeyAccounts.length === 0) { + return null + } + + // Create virtual "Private Key Accounts" wallet if there are any imported wallets + // Only add the virtual wallet if there are matching accounts (respects search) + const privateKeyAccountData = allPrivateKeyAccounts.map( + (account, index) => { + const isActive = account.id === activeAccount?.id + const nextAccount = allPrivateKeyAccounts[index + 1] + const hideSeparator = isActive || nextAccount?.id === activeAccount?.id + + return getAccountData({ + hideSeparator, + isActive, + account + }) + } + ) + + // Create virtual wallet for private key accounts + return { + id: IMPORTED_ACCOUNTS_VIRTUAL_WALLET_ID, // Virtual ID + name: IMPORTED_ACCOUNTS_VIRTUAL_WALLET_NAME, + type: WalletType.PRIVATE_KEY, + accounts: privateKeyAccountData + } + }, [importedWallets, accountSearchResults, activeAccount?.id, getAccountData]) + + return useMemo(() => { + return [...primaryWalletsDisplayData, importedWalletsDisplayData].filter( + Boolean + ) as WalletDisplayData[] + }, [primaryWalletsDisplayData, importedWalletsDisplayData]) +} diff --git a/packages/core-mobile/app/new/features/accountSettings/screens/manageAccounts/ManageAccounts.tsx b/packages/core-mobile/app/new/features/accountSettings/screens/manageAccounts/ManageAccounts.tsx new file mode 100644 index 0000000000..31f4043cdb --- /dev/null +++ b/packages/core-mobile/app/new/features/accountSettings/screens/manageAccounts/ManageAccounts.tsx @@ -0,0 +1,137 @@ +import { Icons, SearchBar, useTheme } from '@avalabs/k2-alpine' +import { CoreAccountType } from '@avalabs/types' +import { ErrorState } from 'common/components/ErrorState' +import { ListScreen } from 'common/components/ListScreen' +import NavigationBarButton from 'common/components/NavigationBarButton' +import WalletCard from 'common/components/WalletCard' +import { WalletDisplayData } from 'common/types' +import { useRouter } from 'expo-router' +import { IMPORTED_ACCOUNTS_VIRTUAL_WALLET_ID } from 'features/accountSettings/consts' +import { useWalletsDisplayData } from 'features/accountSettings/hooks/useWalletsDisplayData' +import React, { useCallback, useEffect, useMemo, useState } from 'react' +import { useSelector } from 'react-redux' +import { selectActiveAccount } from 'store/account' +import { selectActiveWalletId, selectWallets } from 'store/wallet/slice' + +export const ManageAccounts = (): React.JSX.Element => { + const { + theme: { colors } + } = useTheme() + const { navigate } = useRouter() + const [searchText, setSearchText] = useState('') + const allWallets = useSelector(selectWallets) + const activeWalletId = useSelector(selectActiveWalletId) + const activeAccount = useSelector(selectActiveAccount) + + const walletsDisplayData = useWalletsDisplayData(searchText) + + const [expandedWallets, setExpandedWallets] = useState< + Record + >({}) + + useMemo(() => { + const initialExpansionState: Record = {} + const walletIds = [ + ...Object.keys(allWallets), + IMPORTED_ACCOUNTS_VIRTUAL_WALLET_ID + ] + if (walletIds.length > 0) { + // Expand only the active wallet by default + walletIds.forEach(id => { + initialExpansionState[id] = + id === activeWalletId || + (id === IMPORTED_ACCOUNTS_VIRTUAL_WALLET_ID && + activeAccount?.type === CoreAccountType.IMPORTED) + }) + } + setExpandedWallets(initialExpansionState) + }, [allWallets, activeWalletId, activeAccount?.type]) + + const toggleWalletExpansion = useCallback((walletId: string) => { + setExpandedWallets(prev => ({ + ...prev, + [walletId]: !prev[walletId] + })) + }, []) + + const handleAddAccount = useCallback((): void => { + // @ts-ignore TODO: make routes typesafe + navigate('/accountSettings/importWallet') + }, [navigate]) + + const renderHeaderRight = useCallback(() => { + return ( + + + + ) + }, [colors.$textPrimary, handleAddAccount]) + + const renderHeader = useCallback(() => { + return + }, [searchText]) + + useEffect(() => { + // When searching, expand all wallets + if (searchText.length > 0) { + setExpandedWallets(prev => { + const newState = { ...prev } + Object.keys(newState).forEach(key => { + newState[key] = true + }) + return newState + }) + } + }, [searchText]) + + const renderItem = useCallback( + ({ item }: { item: WalletDisplayData }) => { + if (!item) { + return null + } + const isExpanded = expandedWallets[item.id] ?? false + + if (searchText && item.accounts.length === 0) { + return null + } + + return ( + toggleWalletExpansion(item.id)} + showMoreButton={item.id !== IMPORTED_ACCOUNTS_VIRTUAL_WALLET_ID} + style={{ + marginHorizontal: 16, + marginTop: 12 + }} + /> + ) + }, + [expandedWallets, searchText, toggleWalletExpansion] + ) + + const renderEmpty = useCallback(() => { + return ( + + ) + }, []) + + return ( + item.id} + renderItem={renderItem} + /> + ) +} diff --git a/packages/core-mobile/app/new/features/accountSettings/types.ts b/packages/core-mobile/app/new/features/accountSettings/types.ts new file mode 100644 index 0000000000..17c6a2b1a4 --- /dev/null +++ b/packages/core-mobile/app/new/features/accountSettings/types.ts @@ -0,0 +1,15 @@ +import { Account } from 'store/account' + +export type GetAccountDataProps = { + hideSeparator: boolean + isActive: boolean + account: Account + walletName?: string +} + +export type GetXpAccountDataProps = { + hideSeparator: boolean + isActive: boolean + accountId: string + numberOfAddresses: number +} diff --git a/packages/core-mobile/app/new/features/portfolio/assets/components/PortfolioHeader.tsx b/packages/core-mobile/app/new/features/portfolio/assets/components/PortfolioHeader.tsx new file mode 100644 index 0000000000..f3a2a0f428 --- /dev/null +++ b/packages/core-mobile/app/new/features/portfolio/assets/components/PortfolioHeader.tsx @@ -0,0 +1,211 @@ +import { + BalanceHeader, + PriceChangeStatus, + useTheme, + View +} from '@avalabs/k2-alpine' +import { HiddenBalanceText } from 'common/components/HiddenBalanceText' +import { useErc20ContractTokens } from 'common/hooks/useErc20ContractTokens' +import { useSearchableTokenList } from 'common/hooks/useSearchableTokenList' +import React, { useCallback, useMemo } from 'react' +import { LayoutChangeEvent, LayoutRectangle } from 'react-native' +import { selectIsDeveloperMode } from 'store/settings/advanced/slice' +import { useSelector } from 'react-redux' +import { useWithdraw } from 'features/meld/hooks/useWithdraw' +import { useBuy } from 'features/meld/hooks/useBuy' +import { selectIsMeldOfframpBlocked } from 'store/posthog' +import { useNavigateToSwap } from 'features/swap/hooks/useNavigateToSwap' +import { useFocusedSelector } from 'utils/performance/useFocusedSelector' +import { selectSelectedCurrency } from 'store/settings/currency' +import { selectActiveAccount } from 'store/account' +import Animated, { + SharedValue, + useAnimatedStyle +} from 'react-native-reanimated' +import { useSendSelectedToken } from 'features/send/store' +import { selectIsPrivacyModeEnabled } from 'store/settings/securityPrivacy' +import { useRouter } from 'expo-router' +import { ActionButtonTitle } from '../consts' +import { ActionButton, ActionButtons } from './ActionButtons' + +interface PortfolioHeaderProps { + targetHiddenProgress: SharedValue + setStickyHeaderLayout: (layout: LayoutRectangle) => void + setBalanceHeaderLayout: (layout: LayoutRectangle) => void + formattedBalance: string + balanceAccurate: boolean + isLoading: boolean + formattedPriceChange?: string + indicatorStatus: PriceChangeStatus + formattedPercent?: string + totalPriceChanged: number +} + +export const PortfolioHeader = ({ + targetHiddenProgress, + setStickyHeaderLayout, + setBalanceHeaderLayout, + formattedBalance, + balanceAccurate, + isLoading, + formattedPriceChange, + indicatorStatus, + formattedPercent, + totalPriceChanged +}: PortfolioHeaderProps): JSX.Element => { + const { + theme: { colors } + } = useTheme() + const { navigate } = useRouter() + const isPrivacyModeEnabled = useFocusedSelector(selectIsPrivacyModeEnabled) + const [_, setSelectedToken] = useSendSelectedToken() + const isDeveloperMode = useSelector(selectIsDeveloperMode) + const isMeldOfframpBlocked = useSelector(selectIsMeldOfframpBlocked) + const selectedCurrency = useSelector(selectSelectedCurrency) + const { navigateToBuy } = useBuy() + const { navigateToWithdraw } = useWithdraw() + const { navigateToSwap } = useNavigateToSwap() + const activeAccount = useFocusedSelector(selectActiveAccount) + const erc20ContractTokens = useErc20ContractTokens() + const { filteredTokenList } = useSearchableTokenList({ + tokens: erc20ContractTokens + }) + + const animatedHeaderStyle = useAnimatedStyle(() => ({ + opacity: 1 - targetHiddenProgress.value + })) + + const handleBridge = useCallback(() => { + navigate({ + // @ts-ignore TODO: make routes typesafe + pathname: '/bridge' + }) + }, [navigate]) + + const handleSend = useCallback((): void => { + setSelectedToken(undefined) + // @ts-ignore TODO: make routes typesafe + navigate('/send') + }, [navigate, setSelectedToken]) + + const handleReceive = useCallback((): void => { + // @ts-ignore TODO: make routes typesafe + navigate('/receive') + }, [navigate]) + + const renderMaskView = useCallback((): JSX.Element => { + return + }, []) + + const handleStickyHeaderLayout = useCallback( + (event: LayoutChangeEvent): void => { + setStickyHeaderLayout(event.nativeEvent.layout) + }, + [setStickyHeaderLayout] + ) + + const handleBalanceHeaderLayout = useCallback( + (event: LayoutChangeEvent): void => { + setBalanceHeaderLayout(event.nativeEvent.layout) + }, + [setBalanceHeaderLayout] + ) + + const actionButtons = useMemo(() => { + const buttons: ActionButton[] = [ + { title: ActionButtonTitle.Send, icon: 'send', onPress: handleSend } + ] + if (!isDeveloperMode) { + buttons.push({ + title: ActionButtonTitle.Swap, + icon: 'swap', + onPress: () => navigateToSwap() + }) + } + buttons.push({ + title: ActionButtonTitle.Buy, + icon: 'buy', + onPress: navigateToBuy + }) + buttons.push({ + title: ActionButtonTitle.Receive, + icon: 'receive', + onPress: handleReceive + }) + buttons.push({ + title: ActionButtonTitle.Bridge, + icon: 'bridge', + onPress: handleBridge + }) + if (!isMeldOfframpBlocked) { + buttons.push({ + title: ActionButtonTitle.Withdraw, + icon: 'withdraw', + onPress: navigateToWithdraw + }) + } + return buttons + }, [ + handleSend, + isDeveloperMode, + navigateToBuy, + navigateToWithdraw, + handleReceive, + handleBridge, + navigateToSwap, + isMeldOfframpBlocked + ]) + + return ( + + + + + + + + {filteredTokenList.length > 0 && ( + + )} + + ) +} diff --git a/packages/core-mobile/app/new/features/portfolio/assets/hooks/usePortfolioHeader.ts b/packages/core-mobile/app/new/features/portfolio/assets/hooks/usePortfolioHeader.ts new file mode 100644 index 0000000000..cd91ede623 --- /dev/null +++ b/packages/core-mobile/app/new/features/portfolio/assets/hooks/usePortfolioHeader.ts @@ -0,0 +1,181 @@ +import { useFocusedSelector } from 'utils/performance/useFocusedSelector' +import { selectTokenVisibility } from 'store/portfolio' +import { + selectBalanceForAccountIsAccurate, + selectBalanceTotalInCurrencyForAccount, + selectBalanceTotalInCurrencyForXpNetwork, + selectIsBalanceLoadedForAccount, + selectIsLoadingAccountBalances, + selectIsLoadingXpBalances, + selectIsRefetchingAccountBalances, + selectIsRefetchingXpBalances, + selectIsXpBalanceLoadedForWallet, + selectTokensWithBalanceForAccount, + selectXpBalanceForAccountIsAccurate +} from 'store/balance' +import { selectActiveAccount } from 'store/account' +import { useFormatCurrency } from 'common/hooks/useFormatCurrency' +import { useMemo } from 'react' +import { UNKNOWN_AMOUNT } from 'consts/amount' +import { useWatchlist } from 'hooks/watchlist/useWatchlist' +import { RootState } from 'store/types' +import { PriceChangeStatus } from '@avalabs/k2-alpine' +import { selectActiveWallet } from 'store/wallet/slice' +import { NetworkVMType } from '@avalabs/vm-module-types' +import { isPlatformAccount } from 'store/account/utils' + +export const usePortfolioHeader = (): { + balanceTotalInCurrency: number + formattedBalance: string + balanceAccurate: boolean + isLoading: boolean + formattedPriceChange?: string + indicatorStatus: PriceChangeStatus + formattedPercent?: string + totalPriceChanged: number + // eslint-disable-next-line sonarjs/cognitive-complexity +} => { + const { getMarketTokenBySymbol } = useWatchlist() + const tokens = useFocusedSelector((state: RootState) => + selectTokensWithBalanceForAccount(state, activeAccount?.id ?? '') + ) + const activeWallet = useFocusedSelector(selectActiveWallet) + const activeAccount = useFocusedSelector(selectActiveAccount) + const tokenVisibility = useFocusedSelector(selectTokenVisibility) + + const balanceTotalInCurrencyForAccount = useFocusedSelector( + selectBalanceTotalInCurrencyForAccount( + activeAccount?.id ?? '', + tokenVisibility + ) + ) + + const balanceTotalInCurrencyForXpNetwork = useFocusedSelector( + selectBalanceTotalInCurrencyForXpNetwork( + activeWallet?.id ?? '', + activeAccount?.id.includes(NetworkVMType.AVM) + ? NetworkVMType.AVM + : NetworkVMType.PVM + ) + ) + + const accountBalanceAccurate = useFocusedSelector( + selectBalanceForAccountIsAccurate(activeAccount?.id ?? '') + ) + + const xpBalanceAccurate = useFocusedSelector( + selectXpBalanceForAccountIsAccurate( + activeWallet?.id ?? '', + activeAccount?.id.includes(NetworkVMType.AVM) + ? NetworkVMType.AVM + : NetworkVMType.PVM + ) + ) + + const balanceTotalInCurrency = isPlatformAccount(activeAccount?.id ?? '') + ? balanceTotalInCurrencyForXpNetwork + : balanceTotalInCurrencyForAccount + + const balanceAccurate = isPlatformAccount(activeAccount?.id ?? '') + ? xpBalanceAccurate + : accountBalanceAccurate + + const isAccountBalanceLoading = useFocusedSelector( + selectIsLoadingAccountBalances + ) + const isXpBalanceLoading = useFocusedSelector(selectIsLoadingXpBalances) + const isRefetchingAccountBalance = useFocusedSelector( + selectIsRefetchingAccountBalances + ) + const isRefetchingXpBalance = useFocusedSelector(selectIsRefetchingXpBalances) + const isAccountBalanceLoaded = useFocusedSelector( + selectIsBalanceLoadedForAccount(activeAccount?.id ?? '') + ) + const isXpBalanceLoaded = useFocusedSelector( + selectIsXpBalanceLoadedForWallet( + activeWallet?.id ?? '', + activeAccount?.id.includes(NetworkVMType.AVM) + ? NetworkVMType.AVM + : NetworkVMType.PVM + ) + ) + const isAccountLoading = useMemo( + () => + isAccountBalanceLoading || + isRefetchingAccountBalance || + !isAccountBalanceLoaded, + [ + isAccountBalanceLoading, + isRefetchingAccountBalance, + isAccountBalanceLoaded + ] + ) + + const isXpAccountLoading = useMemo( + () => isXpBalanceLoading || isRefetchingXpBalance || !isXpBalanceLoaded, + [isXpBalanceLoading, isRefetchingXpBalance, isXpBalanceLoaded] + ) + + const isLoading = isPlatformAccount(activeAccount?.id ?? '') + ? isXpAccountLoading + : isAccountLoading + + const { formatCurrency } = useFormatCurrency() + const formattedBalance = useMemo(() => { + // CP-10570: Balances should never show $0.00 + return !balanceAccurate || balanceTotalInCurrency === 0 + ? UNKNOWN_AMOUNT + : formatCurrency({ + amount: balanceTotalInCurrency, + withoutCurrencySuffix: true + }) + }, [balanceAccurate, balanceTotalInCurrency, formatCurrency]) + + const totalPriceChanged = useMemo( + () => + tokens.reduce((acc, token) => { + const marketToken = getMarketTokenBySymbol(token.symbol) + const percentChange = marketToken?.priceChangePercentage24h ?? 0 + const priceChange = token.balanceInCurrency + ? (token.balanceInCurrency * percentChange) / 100 + : 0 + return acc + priceChange + }, 0), + [getMarketTokenBySymbol, tokens] + ) + + const formattedPriceChange = + totalPriceChanged !== 0 + ? formatCurrency({ amount: Math.abs(totalPriceChanged) }) + : undefined + + const indicatorStatus = + totalPriceChanged > 0 + ? PriceChangeStatus.Up + : totalPriceChanged < 0 + ? PriceChangeStatus.Down + : PriceChangeStatus.Neutral + + const totalPriceChangedInPercent = useMemo(() => { + return (totalPriceChanged / balanceTotalInCurrency) * 100 + }, [balanceTotalInCurrency, totalPriceChanged]) + + const formattedPercent = useMemo( + () => + !isFinite(totalPriceChangedInPercent) || totalPriceChangedInPercent === 0 + ? undefined + : totalPriceChangedInPercent.toFixed(2) + '%', + [totalPriceChangedInPercent] + ) + + return { + balanceTotalInCurrency, + formattedBalance, + balanceAccurate, + isLoading, + formattedPriceChange, + indicatorStatus, + formattedPercent, + totalPriceChanged + } +} diff --git a/packages/core-mobile/app/new/features/portfolio/screens/PortfolioScreen.tsx b/packages/core-mobile/app/new/features/portfolio/screens/PortfolioScreen.tsx index 466e793ae7..0e8cca531a 100644 --- a/packages/core-mobile/app/new/features/portfolio/screens/PortfolioScreen.tsx +++ b/packages/core-mobile/app/new/features/portfolio/screens/PortfolioScreen.tsx @@ -1,9 +1,6 @@ import { - BalanceHeader, NavigationTitleHeader, - PriceChangeStatus, SegmentedControl, - useTheme, View } from '@avalabs/k2-alpine' import { useHeaderHeight } from '@react-navigation/elements' @@ -14,28 +11,12 @@ import { CollapsibleTabsRef, OnTabChange } from 'common/components/CollapsibleTabs' -import { HiddenBalanceText } from 'common/components/HiddenBalanceText' - -import { useErc20ContractTokens } from 'common/hooks/useErc20ContractTokens' import { useFadingHeaderNavigation } from 'common/hooks/useFadingHeaderNavigation' -import { useSearchableTokenList } from 'common/hooks/useSearchableTokenList' -import { UNKNOWN_AMOUNT } from 'consts/amount' -import { useFocusEffect, useRouter } from 'expo-router' import { useBuy } from 'features/meld/hooks/useBuy' -import { useWithdraw } from 'features/meld/hooks/useWithdraw' -import { - ActionButton, - ActionButtons -} from 'features/portfolio/assets/components/ActionButtons' import AssetsScreen from 'features/portfolio/assets/components/AssetsScreen' -import { ActionButtonTitle } from 'features/portfolio/assets/consts' import { CollectibleFilterAndSortInitialState } from 'features/portfolio/collectibles/hooks/useCollectiblesFilterAndSort' import { CollectiblesScreen } from 'features/portfolio/collectibles/screens/CollectiblesScreen' import { DeFiScreen } from 'features/portfolio/defi/components/DeFiScreen' -import { useSendSelectedToken } from 'features/send/store' -import { useNavigateToSwap } from 'features/swap/hooks/useNavigateToSwap' -import { useWatchlist } from 'hooks/watchlist/useWatchlist' -import { useFormatCurrency } from 'new/common/hooks/useFormatCurrency' import React, { useCallback, useMemo, useRef, useState } from 'react' import { InteractionManager, @@ -43,31 +24,18 @@ import { LayoutRectangle, Platform } from 'react-native' -import Animated, { - useAnimatedStyle, - useSharedValue -} from 'react-native-reanimated' +import { useSharedValue } from 'react-native-reanimated' import { useSafeAreaFrame } from 'react-native-safe-area-context' -import { useSelector } from 'react-redux' import AnalyticsService from 'services/analytics/AnalyticsService' import { AnalyticsEventName } from 'services/analytics/types' import { selectActiveAccount } from 'store/account' -import { - LocalTokenWithBalance, - selectBalanceForAccountIsAccurate, - selectBalanceTotalInCurrencyForAccount, - selectIsBalanceLoadedForAccount, - selectIsLoadingBalances, - selectIsRefetchingBalances, - selectTokensWithBalanceForAccount -} from 'store/balance' -import { selectTokenVisibility } from 'store/portfolio' -import { selectIsMeldOfframpBlocked } from 'store/posthog' -import { selectIsDeveloperMode } from 'store/settings/advanced' -import { selectSelectedCurrency } from 'store/settings/currency' +import { LocalTokenWithBalance } from 'store/balance' import { selectIsPrivacyModeEnabled } from 'store/settings/securityPrivacy' -import { RootState } from 'store/types' import { useFocusedSelector } from 'utils/performance/useFocusedSelector' +import { useFocusEffect } from '@react-navigation/native' +import { useRouter } from 'expo-router' +import { PortfolioHeader } from '../assets/components/PortfolioHeader' +import { usePortfolioHeader } from '../assets/hooks/usePortfolioHeader' const SEGMENT_ITEMS = [ { title: 'Assets' }, @@ -84,15 +52,10 @@ const SEGMENT_EVENT_MAP: Record = { const PortfolioHomeScreen = (): JSX.Element => { const frame = useSafeAreaFrame() const headerHeight = useHeaderHeight() - const isMeldOfframpBlocked = useSelector(selectIsMeldOfframpBlocked) const { navigateToBuy } = useBuy() - const { navigateToWithdraw } = useWithdraw() const isPrivacyModeEnabled = useFocusedSelector(selectIsPrivacyModeEnabled) - const [_, setSelectedToken] = useSendSelectedToken() - const { theme } = useTheme() const { navigate, push } = useRouter() - const { navigateToSwap } = useNavigateToSwap() const [stickyHeaderLayout, setStickyHeaderLayout] = useState< LayoutRectangle | undefined @@ -104,98 +67,20 @@ const PortfolioHomeScreen = (): JSX.Element => { const [segmentedControlLayout, setSegmentedControlLayout] = useState< LayoutRectangle | undefined >() - const erc20ContractTokens = useErc20ContractTokens() - const { filteredTokenList } = useSearchableTokenList({ - tokens: erc20ContractTokens - }) const selectedSegmentIndex = useSharedValue(0) const activeAccount = useFocusedSelector(selectActiveAccount) - const isBalanceLoading = useFocusedSelector(selectIsLoadingBalances) - const isRefetchingBalance = useFocusedSelector(selectIsRefetchingBalances) - const isDeveloperMode = useFocusedSelector(selectIsDeveloperMode) - const tokenVisibility = useFocusedSelector(selectTokenVisibility) - const balanceTotalInCurrency = useFocusedSelector( - selectBalanceTotalInCurrencyForAccount( - activeAccount?.id ?? '', - tokenVisibility - ) - ) const tabViewRef = useRef(null) - const isBalanceLoaded = useFocusedSelector( - selectIsBalanceLoadedForAccount(activeAccount?.id ?? '') - ) - const isLoading = isBalanceLoading || isRefetchingBalance || !isBalanceLoaded - const balanceAccurate = useFocusedSelector( - selectBalanceForAccountIsAccurate(activeAccount?.id ?? '') - ) - const selectedCurrency = useSelector(selectSelectedCurrency) - const { formatCurrency } = useFormatCurrency() - const formattedBalance = useMemo(() => { - // CP-10570: Balances should never show $0.00 - return !balanceAccurate || balanceTotalInCurrency === 0 - ? UNKNOWN_AMOUNT - : formatCurrency({ - amount: balanceTotalInCurrency, - withoutCurrencySuffix: true - }) - }, [balanceAccurate, balanceTotalInCurrency, formatCurrency]) - - const { getMarketTokenBySymbol } = useWatchlist() - const tokens = useFocusedSelector((state: RootState) => - selectTokensWithBalanceForAccount(state, activeAccount?.id ?? '') - ) - const totalPriceChanged = useMemo( - () => - tokens.reduce((acc, token) => { - const marketToken = getMarketTokenBySymbol(token.symbol) - const percentChange = marketToken?.priceChangePercentage24h ?? 0 - const priceChange = token.balanceInCurrency - ? (token.balanceInCurrency * percentChange) / 100 - : 0 - return acc + priceChange - }, 0), - [getMarketTokenBySymbol, tokens] - ) - - const formattedPriceChange = - totalPriceChanged !== 0 - ? formatCurrency({ amount: Math.abs(totalPriceChanged) }) - : undefined - - const indicatorStatus = - totalPriceChanged > 0 - ? PriceChangeStatus.Up - : totalPriceChanged < 0 - ? PriceChangeStatus.Down - : PriceChangeStatus.Neutral - - const totalPriceChangedInPercent = useMemo(() => { - return (totalPriceChanged / balanceTotalInCurrency) * 100 - }, [balanceTotalInCurrency, totalPriceChanged]) - - const formattedPercent = useMemo( - () => - !isFinite(totalPriceChangedInPercent) || totalPriceChangedInPercent === 0 - ? undefined - : totalPriceChangedInPercent.toFixed(2) + '%', - [totalPriceChangedInPercent] - ) - - const handleStickyHeaderLayout = useCallback( - (event: LayoutChangeEvent): void => { - setStickyHeaderLayout(event.nativeEvent.layout) - }, - [] - ) - - const handleBalanceHeaderLayout = useCallback( - (event: LayoutChangeEvent): void => { - setBalanceHeaderLayout(event.nativeEvent.layout) - }, - [] - ) + const { + formattedBalance, + balanceAccurate, + isLoading, + formattedPriceChange, + totalPriceChanged, + indicatorStatus, + formattedPercent + } = usePortfolioHeader() const handleSegmentedControlLayout = useCallback( (event: LayoutChangeEvent): void => { @@ -204,17 +89,6 @@ const PortfolioHomeScreen = (): JSX.Element => { [] ) - const handleSend = useCallback((): void => { - setSelectedToken(undefined) - // @ts-ignore TODO: make routes typesafe - navigate('/send') - }, [navigate, setSelectedToken]) - - const handleReceive = useCallback((): void => { - // @ts-ignore TODO: make routes typesafe - navigate('/receive') - }, [navigate]) - const header = useMemo( () => ( { shouldDelayBlurOniOS: true }) - const animatedHeaderStyle = useAnimatedStyle(() => ({ - opacity: 1 - targetHiddenProgress.value - })) - - const handleBridge = useCallback(() => { - navigate({ - // @ts-ignore TODO: make routes typesafe - pathname: '/bridge' - }) - }, [navigate]) - - const actionButtons = useMemo(() => { - const buttons: ActionButton[] = [ - { title: ActionButtonTitle.Send, icon: 'send', onPress: handleSend } - ] - if (!isDeveloperMode) { - buttons.push({ - title: ActionButtonTitle.Swap, - icon: 'swap', - onPress: () => navigateToSwap() - }) - } - buttons.push({ - title: ActionButtonTitle.Buy, - icon: 'buy', - onPress: navigateToBuy - }) - buttons.push({ - title: ActionButtonTitle.Receive, - icon: 'receive', - onPress: handleReceive - }) - buttons.push({ - title: ActionButtonTitle.Bridge, - icon: 'bridge', - onPress: handleBridge - }) - if (!isMeldOfframpBlocked) { - buttons.push({ - title: ActionButtonTitle.Withdraw, - icon: 'withdraw', - onPress: navigateToWithdraw - }) - } - return buttons - }, [ - handleSend, - isDeveloperMode, - navigateToBuy, - navigateToWithdraw, - handleReceive, - handleBridge, - navigateToSwap, - isMeldOfframpBlocked - ]) - - const renderMaskView = useCallback((): JSX.Element => { - return - }, []) - - const renderHeader = useCallback((): JSX.Element => { - return ( - - - - - - - - {filteredTokenList.length > 0 && ( - - )} - - ) - }, [ - theme.colors.$surfacePrimary, - handleStickyHeaderLayout, - handleBalanceHeaderLayout, - animatedHeaderStyle, - activeAccount?.name, - formattedBalance, - selectedCurrency, - totalPriceChanged, - formattedPriceChange, - indicatorStatus, - formattedPercent, - balanceAccurate, - isLoading, - isPrivacyModeEnabled, - isDeveloperMode, - renderMaskView, - filteredTokenList.length, - actionButtons - ]) - const handleSelectSegment = useCallback( (index: number): void => { const eventName = SEGMENT_EVENT_MAP[index] @@ -540,7 +280,20 @@ const PortfolioHomeScreen = (): JSX.Element => { ( + + )} renderTabBar={renderEmptyTabBar} onTabChange={handleTabChange} onScrollY={onScroll} diff --git a/packages/core-mobile/app/new/features/rpc/components/SelectAccounts.tsx b/packages/core-mobile/app/new/features/rpc/components/SelectAccounts.tsx index 9e26e4ab11..2749344298 100644 --- a/packages/core-mobile/app/new/features/rpc/components/SelectAccounts.tsx +++ b/packages/core-mobile/app/new/features/rpc/components/SelectAccounts.tsx @@ -12,7 +12,7 @@ import { useTheme, View } from '@avalabs/k2-alpine' -import type { Account, AccountCollection } from 'store/account/types' +import type { Account } from 'store/account/types' import { useFormatCurrency } from 'new/common/hooks/useFormatCurrency' import { useBalanceForAccount } from 'new/common/contexts/useBalanceForAccount' import { TRUNCATE_ADDRESS_LENGTH } from 'common/consts/text' @@ -20,7 +20,7 @@ import { TRUNCATE_ADDRESS_LENGTH } from 'common/consts/text' type Props = { onSelect: (account: Account) => void selectedAccounts: Account[] - accounts: AccountCollection + accounts: Account[] } export const SelectAccounts = ({ @@ -33,8 +33,6 @@ export const SelectAccounts = ({ } = useTheme() const data = useMemo(() => { - const allAccounts = Object.values(accounts) - return [ { // eslint-disable-next-line react/no-unstable-nested-components @@ -75,8 +73,8 @@ export const SelectAccounts = ({ expanded: true, accordion: ( - {allAccounts.map((account, index) => { - const lastItem = index === allAccounts.length - 1 + {accounts.map((account, index) => { + const lastItem = index === accounts.length - 1 const isSelected = selectedAccounts.findIndex( selectedAccount => diff --git a/packages/core-mobile/app/new/routes/(signedIn)/(modals)/accountSettings/manageAccounts.tsx b/packages/core-mobile/app/new/routes/(signedIn)/(modals)/accountSettings/manageAccounts.tsx index 8049274902..2c0764c7cb 100644 --- a/packages/core-mobile/app/new/routes/(signedIn)/(modals)/accountSettings/manageAccounts.tsx +++ b/packages/core-mobile/app/new/routes/(signedIn)/(modals)/accountSettings/manageAccounts.tsx @@ -1,503 +1 @@ -import { truncateAddress } from '@avalabs/core-utils-sdk' -import { - ActivityIndicator, - alpha, - AnimatedBalance, - Icons, - Pressable, - SCREEN_WIDTH, - SearchBar, - Text, - TouchableOpacity, - useTheme, - View -} from '@avalabs/k2-alpine' -import { CoreAccountType } from '@avalabs/types' -import { ErrorState } from 'common/components/ErrorState' -import { HiddenBalanceText } from 'common/components/HiddenBalanceText' -import { ListScreen } from 'common/components/ListScreen' -import NavigationBarButton from 'common/components/NavigationBarButton' -import WalletCard from 'common/components/WalletCard' -import { TRUNCATE_ADDRESS_LENGTH } from 'common/consts/text' -import { useFormatCurrency } from 'common/hooks/useFormatCurrency' -import { WalletDisplayData } from 'common/types' -import { UNKNOWN_AMOUNT } from 'consts/amount' -import { useRouter } from 'expo-router' -import { useBalanceForAccount } from 'new/common/contexts/useBalanceForAccount' -import React, { useCallback, useEffect, useMemo, useState } from 'react' -import { useDispatch, useSelector } from 'react-redux' -import { WalletType } from 'services/wallet/types' -import { - Account, - selectAccounts, - selectActiveAccount, - setActiveAccount -} from 'store/account' -import { selectIsPrivacyModeEnabled } from 'store/settings/securityPrivacy' -import { selectActiveWalletId, selectWallets } from 'store/wallet/slice' - -const IMPORTED_ACCOUNTS_VIRTUAL_WALLET_ID = 'imported-accounts-wallet-id' -const IMPORTED_ACCOUNTS_VIRTUAL_WALLET_NAME = 'Imported' - -const ManageAccountsScreen = (): React.JSX.Element => { - const { - theme: { colors } - } = useTheme() - const dispatch = useDispatch() - const { navigate, dismiss } = useRouter() - const [searchText, setSearchText] = useState('') - const accountCollection = useSelector(selectAccounts) - const allWallets = useSelector(selectWallets) - const activeWalletId = useSelector(selectActiveWalletId) - const activeAccount = useSelector(selectActiveAccount) - - const [expandedWallets, setExpandedWallets] = useState< - Record - >({}) - - useMemo(() => { - const initialExpansionState: Record = {} - const walletIds = [ - ...Object.keys(allWallets), - IMPORTED_ACCOUNTS_VIRTUAL_WALLET_ID - ] - if (walletIds.length > 0) { - // Expand only the active wallet by default - walletIds.forEach(id => { - initialExpansionState[id] = - id === activeWalletId || - (id === IMPORTED_ACCOUNTS_VIRTUAL_WALLET_ID && - activeAccount?.type === CoreAccountType.IMPORTED) - }) - } - setExpandedWallets(initialExpansionState) - }, [allWallets, activeWalletId, activeAccount?.type]) - - const allAccountsArray: Account[] = useMemo( - () => Object.values(accountCollection), - [accountCollection] - ) - - const gotoAccountDetails = useCallback( - (accountId: string): void => { - navigate({ - // @ts-ignore TODO: make routes typesafe - pathname: '/accountSettings/account', - params: { accountId } - }) - }, - [navigate] - ) - - const accountSearchResults = useMemo(() => { - if (!searchText) { - return allAccountsArray - } - return allAccountsArray.filter(account => { - const wallet = allWallets[account.walletId] - if (!wallet) { - return false - } - const walletName = wallet.name.toLowerCase() - - const isPrivateKeyAccount = wallet.type === WalletType.PRIVATE_KEY - const virtualWalletMatches = - isPrivateKeyAccount && - IMPORTED_ACCOUNTS_VIRTUAL_WALLET_NAME.toLowerCase().includes( - searchText.toLowerCase() - ) - const walletNameMatches = - !isPrivateKeyAccount && walletName.includes(searchText.toLowerCase()) - - return ( - virtualWalletMatches || - walletNameMatches || - account.name.toLowerCase().includes(searchText.toLowerCase()) || - account.addressC?.toLowerCase().includes(searchText.toLowerCase()) || - account.addressBTC?.toLowerCase().includes(searchText.toLowerCase()) || - account.addressAVM?.toLowerCase().includes(searchText.toLowerCase()) || - account.addressPVM?.toLowerCase().includes(searchText.toLowerCase()) || - account.addressSVM?.toLowerCase().includes(searchText.toLowerCase()) || - account.addressCoreEth?.toLowerCase().includes(searchText.toLowerCase()) - ) - }) - }, [allAccountsArray, allWallets, searchText]) - - const handleSetActiveAccount = useCallback( - (accountId: string) => { - if (accountId === activeAccount?.id) { - return - } - dispatch(setActiveAccount(accountId)) - - dismiss() - dismiss() - }, - [activeAccount?.id, dispatch, dismiss] - ) - - const importedWallets = useMemo(() => { - return Object.values(allWallets).filter( - wallet => wallet.type === WalletType.PRIVATE_KEY - ) - }, [allWallets]) - - const primaryWallets = useMemo(() => { - return Object.values(allWallets).filter( - wallet => wallet.type !== WalletType.PRIVATE_KEY - ) - }, [allWallets]) - - const primaryWalletsDisplayData = useMemo(() => { - return primaryWallets.map(wallet => { - const accountsForWallet = accountSearchResults.filter( - account => account.walletId === wallet.id - ) - - if (accountsForWallet.length === 0) { - return null - } - - const accountDataForWallet = accountsForWallet.map((account, index) => { - const isActive = account.id === activeAccount?.id - const nextAccount = accountsForWallet[index + 1] - const hideSeparator = isActive || nextAccount?.id === activeAccount?.id - - return { - hideSeparator, - containerSx: { - backgroundColor: isActive - ? alpha(colors.$textPrimary, 0.1) - : 'transparent', - borderRadius: 8 - }, - title: ( - - {account.name} - - ), - subtitle: ( - - {truncateAddress(account.addressC, TRUNCATE_ADDRESS_LENGTH)} - - ), - leftIcon: isActive ? ( - - ) : ( - - ), - value: , - onPress: () => handleSetActiveAccount(account.id), - accessory: ( - gotoAccountDetails(account.id)}> - - - ) - } - }) - - return { - ...wallet, - accounts: accountDataForWallet - } - }) - }, [ - primaryWallets, - accountSearchResults, - activeAccount?.id, - colors.$textPrimary, - colors.$textSecondary, - handleSetActiveAccount, - gotoAccountDetails - ]) - - const importedWalletsDisplayData = useMemo(() => { - // Get all accounts from private key wallets - const allPrivateKeyAccounts = importedWallets.flatMap(wallet => { - return accountSearchResults.filter( - account => account.walletId === wallet.id - ) - }) - - if (allPrivateKeyAccounts.length === 0) { - return null - } - - // Create virtual "Private Key Accounts" wallet if there are any imported wallets - // Only add the virtual wallet if there are matching accounts (respects search) - const privateKeyAccountData = allPrivateKeyAccounts.map( - (account, index) => { - const isActive = account.id === activeAccount?.id - const nextAccount = allPrivateKeyAccounts[index + 1] - const hideSeparator = isActive || nextAccount?.id === activeAccount?.id - - return { - hideSeparator, - containerSx: { - backgroundColor: isActive - ? alpha(colors.$textPrimary, 0.1) - : 'transparent', - borderRadius: 8 - }, - title: ( - - {account.name} - - ), - subtitle: ( - - {truncateAddress(account.addressC, TRUNCATE_ADDRESS_LENGTH)} - - ), - leftIcon: isActive ? ( - - ) : ( - - ), - value: , - onPress: () => handleSetActiveAccount(account.id), - accessory: ( - gotoAccountDetails(account.id)}> - - - ) - } - } - ) - - // Create virtual wallet for private key accounts - return { - id: IMPORTED_ACCOUNTS_VIRTUAL_WALLET_ID, // Virtual ID - name: IMPORTED_ACCOUNTS_VIRTUAL_WALLET_NAME, - type: WalletType.PRIVATE_KEY, - accounts: privateKeyAccountData - } - }, [ - importedWallets, - accountSearchResults, - activeAccount?.id, - colors.$textPrimary, - colors.$textSecondary, - handleSetActiveAccount, - gotoAccountDetails - ]) - - const walletsDisplayData: (WalletDisplayData | null)[] = useMemo(() => { - return [...primaryWalletsDisplayData, importedWalletsDisplayData] - }, [primaryWalletsDisplayData, importedWalletsDisplayData]) - - const toggleWalletExpansion = useCallback((walletId: string) => { - setExpandedWallets(prev => ({ - ...prev, - [walletId]: !prev[walletId] - })) - }, []) - - const handleAddAccount = useCallback((): void => { - // @ts-ignore TODO: make routes typesafe - navigate('/accountSettings/importWallet') - }, [navigate]) - - const renderHeaderRight = useCallback(() => { - return ( - - - - ) - }, [colors.$textPrimary, handleAddAccount]) - - const renderHeader = useCallback(() => { - return - }, [searchText]) - - useEffect(() => { - // When searching, expand all wallets - if (searchText.length > 0) { - setExpandedWallets(prev => { - const newState = { ...prev } - Object.keys(newState).forEach(key => { - newState[key] = true - }) - return newState - }) - } - }, [searchText]) - - const renderItem = useCallback( - ({ item }: { item: WalletDisplayData }) => { - if (!item) { - return null - } - const isExpanded = expandedWallets[item.id] ?? false - - if (searchText && item.accounts.length === 0) { - return null - } - - return ( - toggleWalletExpansion(item.id)} - showMoreButton={item.id !== IMPORTED_ACCOUNTS_VIRTUAL_WALLET_ID} - style={{ - marginHorizontal: 16, - marginTop: 12 - }} - /> - ) - }, - [expandedWallets, searchText, toggleWalletExpansion] - ) - - const renderEmpty = useCallback(() => { - return ( - - ) - }, []) - - return ( - item.id} - renderItem={renderItem} - /> - ) -} - -export default ManageAccountsScreen - -const AccountBalance = ({ - isActive, - accountId -}: { - isActive: boolean - accountId: string -}): React.JSX.Element => { - const isPrivacyModeEnabled = useSelector(selectIsPrivacyModeEnabled) - const { - theme: { colors } - } = useTheme() - const { - balance: accountBalance, - fetchBalance, - isFetchingBalance, - isBalanceLoaded - } = useBalanceForAccount(accountId) - const { formatCurrency } = useFormatCurrency() - - const balance = useMemo(() => { - return accountBalance === 0 - ? formatCurrency({ amount: 0 }).replace(/[\d.,]+/g, UNKNOWN_AMOUNT) - : formatCurrency({ amount: accountBalance }) - }, [accountBalance, formatCurrency]) - - const renderMaskView = useCallback(() => { - return ( - - ) - }, [colors.$textPrimary, isActive]) - - if (isFetchingBalance) { - return - } - - if (!isBalanceLoaded) { - return ( - - - - ) - } - - return ( - - ) -} +export { ManageAccounts as default } from 'features/accountSettings/screens/manageAccounts/ManageAccounts' diff --git a/packages/core-mobile/app/services/account/AccountsService.tsx b/packages/core-mobile/app/services/account/AccountsService.tsx index f9d31775a9..fe2a886840 100644 --- a/packages/core-mobile/app/services/account/AccountsService.tsx +++ b/packages/core-mobile/app/services/account/AccountsService.tsx @@ -29,14 +29,14 @@ class AccountsService { walletId, walletType }: { - accounts: AccountCollection + accounts: Account[] isTestnet: boolean walletId: string walletType: WalletType - }): Promise { - const reloadedAccounts: AccountCollection = {} + }): Promise { + const reloadedAccounts: Account[] = [] - for (const [key, account] of Object.entries(accounts)) { + for (const account of accounts) { const addresses = await this.getAddresses({ walletId, walletType, @@ -49,7 +49,7 @@ class AccountsService { ? await SeedlessService.getAccountName(account.index) : account.name - reloadedAccounts[key] = { + reloadedAccounts.push({ id: account.id, name: title ?? account.name, type: account.type, @@ -61,7 +61,7 @@ class AccountsService { addressPVM: addresses[NetworkVMType.PVM], addressCoreEth: addresses[NetworkVMType.CoreEth], addressSVM: addresses[NetworkVMType.SVM] - } as Account + } as Account) } return reloadedAccounts } diff --git a/packages/core-mobile/app/services/earn/EarnService.ts b/packages/core-mobile/app/services/earn/EarnService.ts index cb0247d89b..a201f602d3 100644 --- a/packages/core-mobile/app/services/earn/EarnService.ts +++ b/packages/core-mobile/app/services/earn/EarnService.ts @@ -1,4 +1,4 @@ -import { Account, AccountCollection } from 'store/account/types' +import { Account } from 'store/account/types' import { importPWithBalanceCheck } from 'services/earn/importP' import Big from 'big.js' import { FujiParams, MainnetParams } from 'utils/NetworkParams' @@ -352,7 +352,7 @@ class EarnService { }: { walletId: string walletType: WalletType - accounts: AccountCollection + accounts: Account[] isTestnet: boolean startTimestamp?: number }): Promise< @@ -365,10 +365,8 @@ class EarnService { }[] | undefined > => { - const accountsArray = Object.values(accounts) - try { - const currentNetworkAddresses = accountsArray + const currentNetworkAddresses = accounts .map(account => account.addressPVM) .filter((address): address is string => address !== undefined) const currentNetworkTransactions = await getTransformedTransactions( @@ -379,7 +377,7 @@ class EarnService { const oppositeNetworkAddresses = ( await Promise.all( - accountsArray.map(account => + accounts.map(account => AccountsService.getAddresses({ walletId, walletType, @@ -400,9 +398,7 @@ class EarnService { .concat(oppositeNetworkTransactions) .flatMap(transaction => { // find account that matches the transaction's index - const account = accountsArray.find( - acc => acc.index === transaction.index - ) + const account = accounts.find(acc => acc.index === transaction.index) // flat map will remove this if (!account) return [] diff --git a/packages/core-mobile/app/store/account/consts.ts b/packages/core-mobile/app/store/account/consts.ts new file mode 100644 index 0000000000..b8b54dcdf7 --- /dev/null +++ b/packages/core-mobile/app/store/account/consts.ts @@ -0,0 +1,2 @@ +export const X_CHAIN_ACCOUNT_NAME = 'X-Chain accounts' +export const P_CHAIN_ACCOUNT_NAME = 'P-Chain accounts' diff --git a/packages/core-mobile/app/store/account/listeners.ts b/packages/core-mobile/app/store/account/listeners.ts index 076aa5193b..f360e33f40 100644 --- a/packages/core-mobile/app/store/account/listeners.ts +++ b/packages/core-mobile/app/store/account/listeners.ts @@ -21,12 +21,15 @@ import BiometricsSDK from 'utils/BiometricsSDK' import Logger from 'utils/Logger' import KeystoneService from 'features/keystone/services/KeystoneService' import { pendingSeedlessWalletNameStore } from 'features/onboarding/store' +import { CoreAccountType } from '@avalabs/types' +import { NetworkVMType } from '@avalabs/vm-module-types' import { selectAccounts, setAccounts, setActiveAccountId, selectAccountsByWalletId, - selectActiveAccount + selectActiveAccount, + selectPlatformAccountsByWalletId } from './slice' import { AccountCollection } from './types' import { @@ -35,6 +38,7 @@ import { migrateRemainingActiveAccounts, shouldMigrateActiveAccounts } from './utils' +import { P_CHAIN_ACCOUNT_NAME, X_CHAIN_ACCOUNT_NAME } from './consts' const initAccounts = async ( _action: AnyAction, @@ -95,15 +99,39 @@ const initAccounts = async ( activeWallet.type === WalletType.SEEDLESS ) { accounts[acc.id] = acc - listenerApi.dispatch(setAccounts(accounts)) const firstAccountId = Object.keys(accounts)[0] if (!firstAccountId) { throw new Error('No accounts created') } + + // set platform accounts + ;[NetworkVMType.AVM, NetworkVMType.PVM].forEach(networkType => { + accounts[`${activeWallet.id}-${networkType}`] = { + index: 0, + id: `${activeWallet.id}-${networkType}`, + walletId: activeWallet.id, + name: + networkType === NetworkVMType.PVM + ? P_CHAIN_ACCOUNT_NAME + : X_CHAIN_ACCOUNT_NAME, + type: CoreAccountType.PRIMARY, + addressC: '', + addressBTC: '', + addressAVM: '', + addressPVM: '', + addressCoreEth: '', + addressSVM: '', + addresses: [ + networkType === NetworkVMType.PVM ? acc.addressPVM : acc.addressAVM + ] + } + }) + + listenerApi.dispatch(setAccounts(accounts)) listenerApi.dispatch(setActiveAccountId(firstAccountId)) } - const accountValues = Object.values(accounts) + const accountValues = selectAccountsByWalletId(state, activeWallet.id) if (activeWallet.type === WalletType.SEEDLESS) { // setting wallet name const { pendingSeedlessWalletName } = @@ -167,19 +195,23 @@ const reloadAccounts = async ( const wallets = selectWallets(state) for (const wallet of Object.values(wallets)) { const accounts = selectAccountsByWalletId(state, wallet.id) - //convert accounts to AccountCollection - const accountsCollection: AccountCollection = {} - for (const account of accounts) { - accountsCollection[account.id] = account - } - const reloadedAccounts = await AccountsService.reloadAccounts({ - accounts: accountsCollection, + accounts, isTestnet: isDeveloperMode, walletId: wallet.id, walletType: wallet.type }) - listenerApi.dispatch(setAccounts(reloadedAccounts)) + + const allAccounts = [ + ...reloadedAccounts, + ...selectPlatformAccountsByWalletId(state, wallet.id) + ] + //convert accounts to AccountCollection + const accountsCollection: AccountCollection = {} + for (const account of allAccounts) { + accountsCollection[account.id] = account + } + listenerApi.dispatch(setAccounts(accountsCollection)) } } @@ -229,9 +261,11 @@ const migrateSolanaAddressesIfNeeded = async ( const state = getState() const isSolanaSupportBlocked = selectIsSolanaSupportBlocked(state) const accounts = selectAccounts(state) - const entries = Object.values(accounts) // Only migrate Solana addresses if Solana support is enabled - if (!isSolanaSupportBlocked && entries.some(account => !account.addressSVM)) { + if ( + !isSolanaSupportBlocked && + accounts.some(account => !account.addressSVM) + ) { const seedlessWallet = selectSeedlessWallet(state) if (seedlessWallet) { await deriveMissingSeedlessSessionKeys(seedlessWallet.id) diff --git a/packages/core-mobile/app/store/account/slice.ts b/packages/core-mobile/app/store/account/slice.ts index c2cb1176f6..c2c45cc858 100644 --- a/packages/core-mobile/app/store/account/slice.ts +++ b/packages/core-mobile/app/store/account/slice.ts @@ -7,6 +7,7 @@ import { AccountCollection, PrimaryAccount } from './types' +import { isPlatformAccount } from './utils' export const reducerName = 'account' @@ -56,13 +57,26 @@ const accountsSlice = createSlice({ }) // selectors -export const selectAccounts = (state: RootState): AccountCollection => +export const selectAllAccounts = (state: RootState): AccountCollection => state.account.accounts +// select all accounts except platform accounts +export const selectAccounts = createSelector( + [selectAllAccounts], + (accounts): Account[] => + Object.values(accounts).filter(account => !isPlatformAccount(account.id)) +) + +const _selectPlatformAccounts = createSelector( + [selectAllAccounts], + (accounts): Account[] => + Object.values(accounts).filter(account => isPlatformAccount(account.id)) +) + export const selectAccountByAddress = (address: string) => (state: RootState): Account | undefined => { - const accounts: Account[] = Object.values(state.account.accounts) + const accounts = selectAccounts(state) const givenAddress = address.toLowerCase() return accounts.find(acc => { @@ -91,19 +105,27 @@ export const selectActiveAccount = (state: RootState): Account | undefined => { export const selectAccountsByWalletId = createSelector( [selectAccounts, (_: RootState, walletId: string) => walletId], (accounts, walletId) => { - return Object.values(accounts) + return accounts .filter(account => account.walletId === walletId) .sort((a, b) => a.index - b.index) } ) +export const selectPlatformAccountsByWalletId = createSelector( + [_selectPlatformAccounts, (_: RootState, walletId: string) => walletId], + (accounts, walletId) => { + return accounts.filter(account => account.walletId === walletId) + } +) + export const selectAccountByIndex = (walletId: string, index: number) => (state: RootState): Account | undefined => { - const accounts = Object.values(state.account.accounts).filter( + const accounts = selectAccounts(state) + const accountsByWalletId = accounts.filter( account => account.walletId === walletId ) - const primaryAccount = accounts.find( + const primaryAccount = accountsByWalletId.find( (account): account is PrimaryAccount => 'index' in account && account.index === index ) diff --git a/packages/core-mobile/app/store/account/thunks.ts b/packages/core-mobile/app/store/account/thunks.ts index 41364dde00..a31e45270b 100644 --- a/packages/core-mobile/app/store/account/thunks.ts +++ b/packages/core-mobile/app/store/account/thunks.ts @@ -33,7 +33,7 @@ export const addAccount = createAsyncThunk( throw new Error('Wallet not found') } - const allAccountsCount = Object.keys(allAccounts).length + const allAccountsCount = allAccounts.length const accountsByWalletId = selectAccountsByWalletId(state, walletId) const acc = await AccountsService.createNextAccount({ @@ -96,7 +96,7 @@ export const removeAccountWithActiveCheck = createAsyncThunk< if (activeAccount?.id === accountId) { // Find another account from a different wallet to set as active const allAccounts = selectAccounts(state) - const otherAccount = Object.values(allAccounts).find( + const otherAccount = allAccounts.find( acc => acc.walletId !== accountToRemove.walletId ) diff --git a/packages/core-mobile/app/store/account/types.ts b/packages/core-mobile/app/store/account/types.ts index 6fa5201b7b..bdae0c14d1 100644 --- a/packages/core-mobile/app/store/account/types.ts +++ b/packages/core-mobile/app/store/account/types.ts @@ -13,7 +13,11 @@ export type ImportedAccount = Omit & { index: 0 } -export type Account = PrimaryAccount | ImportedAccount +export type PlatformAccount = PrimaryAccount & { + addresses: string[] +} + +export type Account = PrimaryAccount | ImportedAccount | PlatformAccount export type AccountCollection = { [id: string]: Account } diff --git a/packages/core-mobile/app/store/account/utils.ts b/packages/core-mobile/app/store/account/utils.ts index 7af5809019..1750aa7f06 100644 --- a/packages/core-mobile/app/store/account/utils.ts +++ b/packages/core-mobile/app/store/account/utils.ts @@ -1,5 +1,9 @@ import { AVM, EVM, PVM, VM } from '@avalabs/avalanchejs' -import { Account, AccountCollection } from 'store/account/types' +import { + Account, + AccountCollection, + PlatformAccount +} from 'store/account/types' import { Network, NetworkVMType } from '@avalabs/core-chains-sdk' import WalletFactory from 'services/wallet/WalletFactory' import { WalletType } from 'services/wallet/types' @@ -17,7 +21,11 @@ import { StorageKey } from 'resources/Constants' import { appendToStoredArray, loadArrayFromStorage } from 'utils/mmkv/storages' import { setIsMigratingActiveAccounts } from 'store/wallet/slice' import WalletService from 'services/wallet/WalletService' -import { setAccounts, setNonActiveAccounts } from './slice' +import { + selectPlatformAccountsByWalletId, + setAccounts, + setNonActiveAccounts +} from './slice' export function getAddressByVM( vm: VM, @@ -103,7 +111,9 @@ export const migrateRemainingActiveAccounts = async ({ walletId: string walletType: WalletType.SEEDLESS | WalletType.MNEMONIC | WalletType.KEYSTONE startIndex: number + // eslint-disable-next-line sonarjs/cognitive-complexity }): Promise => { + const state = listenerApi.getState() listenerApi.dispatch(setIsMigratingActiveAccounts(true)) try { @@ -125,9 +135,38 @@ export const migrateRemainingActiveAccounts = async ({ // set accounts for seedless wallet, which trigger balance update // * seedless wallet fetches xp balances by iterating over xp addresses over all accounts // * so we need to wait for all accounts to be fetched to update balances - walletType === WalletType.SEEDLESS - ? listenerApi.dispatch(setAccounts(accounts)) - : listenerApi.dispatch(setNonActiveAccounts(accounts)) + if (walletType === WalletType.SEEDLESS) { + // add xp addresses to the platform accounts + const avmAddresses = Object.values(accounts).map( + account => account.addressAVM + ) + const pvmAddresses = Object.values(accounts).map( + account => account.addressPVM + ) + const platformAccounts = selectPlatformAccountsByWalletId( + state, + walletId + ) + + for (const platformAccount of platformAccounts as PlatformAccount[]) { + if (platformAccount.id === `${walletId}-${NetworkVMType.AVM}`) { + accounts[`${walletId}-${NetworkVMType.AVM}`] = { + ...platformAccount, + addresses: [...platformAccount.addresses, ...avmAddresses] + } + } + if (platformAccount.id === `${walletId}-${NetworkVMType.PVM}`) { + accounts[`${walletId}-${NetworkVMType.PVM}`] = { + ...platformAccount, + addresses: [...platformAccount.addresses, ...pvmAddresses] + } + } + } + + listenerApi.dispatch(setAccounts(accounts)) + } else { + listenerApi.dispatch(setNonActiveAccounts(accounts)) + } recentAccountsStore.getState().addRecentAccounts(accountIds) @@ -252,3 +291,6 @@ export async function getAddressesForXP({ throw new Error('Failed to get addresses for XP') } } + +export const isPlatformAccount = (accountId: string): boolean => + accountId.includes(NetworkVMType.AVM) || accountId.includes(NetworkVMType.PVM) diff --git a/packages/core-mobile/app/store/balance/listeners.ts b/packages/core-mobile/app/store/balance/listeners.ts index e521302ec8..ad8c52038d 100644 --- a/packages/core-mobile/app/store/balance/listeners.ts +++ b/packages/core-mobile/app/store/balance/listeners.ts @@ -17,6 +17,7 @@ import { addCustomToken, selectAllCustomTokens } from 'store/customToken' import { addCustomNetwork, selectEnabledNetworks, + selectEnabledNetworksWithoutXP, toggleEnabledChainId } from 'store/network/slice' import { @@ -51,9 +52,11 @@ import { queryClient } from 'contexts/ReactQueryProvider' import { selectIsSolanaSupportBlocked } from 'store/posthog' import { runAfterInteractions } from 'utils/runAfterInteractions' import { getAddressesForXP } from 'store/account/utils' -import { selectActiveWallet, selectActiveWalletId } from 'store/wallet/slice' import { NetworkVMType } from '@avalabs/vm-module-types' import { WalletType } from 'services/wallet/types' +import { Wallet as StoreWallet } from 'store/wallet/types' +import { selectActiveWallet } from 'store/wallet/slice' +import { XpNetworkVMType } from 'store/network' import { Balances, LocalTokenWithBalance, @@ -62,6 +65,7 @@ import { } from './types' import { fetchBalanceForAccount, + fetchXpBalancesForWallet, getKey, refetchBalance, selectAllBalanceStatus, @@ -105,9 +109,11 @@ const onBalanceUpdate = async ( ): Promise => { const { getState } = listenerApi const state = getState() - const account = selectActiveAccount(state) const isDeveloperMode = selectIsDeveloperMode(state) - const enabledNetworks = selectEnabledNetworks(state) + const enabledNetworks = selectEnabledNetworksWithoutXP(state) + const account = selectActiveAccount(state) + const wallet = selectActiveWallet(state) + const networks = getNetworksToFetch({ isDeveloperMode, enabledNetworks, @@ -125,6 +131,7 @@ const onBalanceUpdate = async ( }) onXpBalanceUpdateCore({ queryStatus, + wallet, listenerApi }) } @@ -146,7 +153,8 @@ const onBalancePolling = async ({ const state = getState() const account = selectActiveAccount(state) const isDeveloperMode = selectIsDeveloperMode(state) - const enabledNetworks = selectEnabledNetworks(state) + const enabledNetworks = selectEnabledNetworksWithoutXP(state) + const networks = getNetworksToFetch({ isDeveloperMode, enabledNetworks, @@ -231,18 +239,25 @@ const onBalanceUpdateCore = async ({ const onXpBalanceUpdateCore = async ({ queryStatus, + wallet, listenerApi }: { queryStatus: QueryStatus + wallet?: StoreWallet listenerApi: AppListenerEffectAPI }): Promise => { + if (wallet === undefined) { + Logger.error( + 'onXpBalanceUpdateCore: wallet is undefined, skipping xp balance update' + ) + return + } + const { getState, dispatch } = listenerApi const state = getState() const currentStatus = selectXpBalanceStatus(state) const enabledNetworks = selectEnabledNetworks(state) const isDeveloperMode = selectIsDeveloperMode(state) - const walletId = selectActiveWalletId(state) - const wallet = selectActiveWallet(state) const accounts = selectAccounts(state) const networks = enabledNetworks.filter( @@ -270,25 +285,27 @@ const onXpBalanceUpdateCore = async ({ const xpPromises: { addresses: string[] + networkType: XpNetworkVMType promise: Promise }[] = [] for (const n of networks) { let addresses: string[] = [] - if (wallet?.type === WalletType.SEEDLESS) { - addresses = Object.values(accounts).map(a => + if (wallet.type === WalletType.SEEDLESS) { + addresses = accounts.map(a => n.vmName === NetworkVMType.PVM ? a.addressPVM : a.addressAVM ) } else { addresses = await getAddressesForXP({ networkType: n.vmName, isDeveloperMode, - walletId, + walletId: wallet.id, walletType: wallet?.type, onlyWithActivity: true }) } xpPromises.push({ addresses, + networkType: n.vmName, promise: BalanceService.getXPBalances({ network: n, currency, @@ -297,11 +314,11 @@ const onXpBalanceUpdateCore = async ({ }) } - const xpBalances = await fetchBalanceForXpNetworks(xpPromises) + // store the xp balances by `${walletId}-${networkType}-${accountAddress}` + const xpBalances = await fetchBalanceForXpNetworks(xpPromises, wallet.id) dispatch(setBalances(xpBalances)) dispatch(setStatus({ queryType: QueryType.XP, status: QueryStatus.IDLE })) - Logger.info('finished fetching xp balances') span?.end() } @@ -378,9 +395,10 @@ const handleAllNetworksPolling = async ({ }: { listenerApi: AppListenerEffectAPI }): Promise> => { - const state = listenerApi.getState() + const { getState } = listenerApi + const state = getState() const isDeveloperMode = selectIsDeveloperMode(state) - const enabledNetworks = selectEnabledNetworks(state) + const enabledNetworks = selectEnabledNetworksWithoutXP(state) let iteration = 0 let nonPrimaryNetworksIteration = 0 @@ -442,7 +460,8 @@ const handleFetchBalanceForAccount = async ( ): Promise => { const state = listenerApi.getState() const isDeveloperMode = selectIsDeveloperMode(state) - const enabledNetworks = selectEnabledNetworks(state) + const enabledNetworks = selectEnabledNetworksWithoutXP(state) + const networks = getNetworksToFetch({ isDeveloperMode, enabledNetworks, @@ -460,6 +479,17 @@ const handleFetchBalanceForAccount = async ( }).catch(Logger.error) } +const handleFetchXpBalancesForWallet = async ( + listenerApi: AppListenerEffectAPI, + wallet: StoreWallet +): Promise => { + onXpBalanceUpdateCore({ + queryStatus: QueryStatus.LOADING, + listenerApi, + wallet + }) +} + const fetchBalanceForNetworks = async ( keyedPromises: { promise: Promise; key: string }[] ): Promise => { @@ -546,8 +576,10 @@ const fetchBalanceForNetworks = async ( const fetchBalanceForXpNetworks = async ( promises: { addresses: string[] + networkType: XpNetworkVMType promise: Promise - }[] + }[], + walletId: string ): Promise => { const allSettledResults = await Promise.allSettled( promises.map(p => p.promise) @@ -556,12 +588,12 @@ const fetchBalanceForXpNetworks = async ( const allBalances: Balances = {} allSettledResults.forEach((result, index) => { const addresses = promises[index]?.addresses ?? [] - + const networkType = promises[index]?.networkType // if the promise is rejected, set the balances for the active addresses if (result.status === 'rejected') { Logger.warn('failed to get balance', result.reason) addresses.forEach(address => { - allBalances[address] = { + allBalances[`${walletId}-${networkType}-${address}`] = { dataAccurate: false, accountId: undefined, chainId: 0, @@ -574,8 +606,8 @@ const fetchBalanceForXpNetworks = async ( // add the balances for the active addresses to allBalances result.value.forEach(balance => { - allBalances[balance.accountAddress] = { - accountId: undefined, + allBalances[`${walletId}-${networkType}-${balance.accountAddress}`] = { + accountId: `${walletId}-${networkType}`, dataAccurate: true, chainId: balance.chainId, tokens: balance.tokens.map(token => ({ @@ -685,6 +717,12 @@ export const addBalanceListeners = ( handleFetchBalanceForAccount(listenerApi, action.payload.account) }) + startListening({ + actionCreator: fetchXpBalancesForWallet, + effect: async (action, listenerApi) => + handleFetchXpBalancesForWallet(listenerApi, action.payload.wallet) + }) + startListening({ matcher: isAnyOf(onAppUnlocked, setAccounts), effect: addXChainToEnabledChainIdsIfNeeded diff --git a/packages/core-mobile/app/store/balance/slice.ts b/packages/core-mobile/app/store/balance/slice.ts index 6eb2e910a9..1f4c36f60f 100644 --- a/packages/core-mobile/app/store/balance/slice.ts +++ b/packages/core-mobile/app/store/balance/slice.ts @@ -5,19 +5,27 @@ import { PayloadAction } from '@reduxjs/toolkit' import { RootState } from 'store/types' -import { Account, selectActiveAccount } from 'store/account' +import { + Account, + selectAccountsByWalletId, + selectActiveAccount, + selectPlatformAccountsByWalletId +} from 'store/account' import { selectAllNetworks, selectEnabledChainIds, - selectNetworks + selectNetworks, + XpNetworkVMType } from 'store/network' import { selectIsDeveloperMode } from 'store/settings/advanced' -import { TokenType } from '@avalabs/vm-module-types' +import { NetworkVMType, TokenType } from '@avalabs/vm-module-types' import { isTokenWithBalanceAVM, isTokenWithBalancePVM } from '@avalabs/avalanche-module' import { TokenVisibility } from 'store/portfolio' +import { Wallet } from 'store/wallet/types' +import { isPlatformAccount } from 'store/account/utils' import { Balance, Balances, @@ -90,18 +98,50 @@ export const selectIsBalanceLoadedForAccount = return !!foundBalance } -export const selectIsPollingBalances = (state: RootState): boolean => - state.balance.status[QueryType.ALL] === QueryStatus.POLLING || +export const selectIsXpBalanceLoadedForWallet = + (walletId: string, networkType: XpNetworkVMType) => (state: RootState) => { + const key = Object.keys(state.balance.balances).find( + k => k.includes(walletId) && k.includes(networkType) + ) + return key !== undefined && !!state.balance.balances[key] + } + +export const selectIsPollingAccountBalances = (state: RootState): boolean => + state.balance.status[QueryType.ALL] === QueryStatus.POLLING + +export const selectIsPollingXpBalances = (state: RootState): boolean => state.balance.status[QueryType.XP] === QueryStatus.POLLING -export const selectIsLoadingBalances = (state: RootState): boolean => - state.balance.status[QueryType.ALL] === QueryStatus.LOADING || +export const selectIsPollingBalances = createSelector( + [selectIsPollingAccountBalances, selectIsPollingXpBalances], + (isPollingAccountBalances, isPollingXpBalances) => + isPollingAccountBalances || isPollingXpBalances +) + +export const selectIsLoadingAccountBalances = (state: RootState): boolean => + state.balance.status[QueryType.ALL] === QueryStatus.LOADING + +export const selectIsLoadingXpBalances = (state: RootState): boolean => state.balance.status[QueryType.XP] === QueryStatus.LOADING -export const selectIsRefetchingBalances = (state: RootState): boolean => - state.balance.status[QueryType.ALL] === QueryStatus.REFETCHING || +export const selectIsLoadingBalances = createSelector( + [selectIsLoadingAccountBalances, selectIsLoadingXpBalances], + (isLoadingAccountBalances, isLoadingXpBalances) => + isLoadingAccountBalances || isLoadingXpBalances +) + +export const selectIsRefetchingAccountBalances = (state: RootState): boolean => + state.balance.status[QueryType.ALL] === QueryStatus.REFETCHING + +export const selectIsRefetchingXpBalances = (state: RootState): boolean => state.balance.status[QueryType.XP] === QueryStatus.REFETCHING +export const selectIsRefetchingBalances = createSelector( + [selectIsRefetchingAccountBalances, selectIsRefetchingXpBalances], + (isRefetchingAccountBalances, isRefetchingXpBalances) => + isRefetchingAccountBalances || isRefetchingXpBalances +) + const _selectAllBalances = (state: RootState): Balances => { return state.balance.balances } @@ -212,6 +252,19 @@ const _selectBalancesByAccountId = createSelector( } ) +const _selectBalancesByXpNetwork = + (walletId: string, xpNetworkType: XpNetworkVMType) => (state: RootState) => { + const balances = _selectAllBalances(state) + + const matchingKeys = Object.keys(balances).filter( + key => key.includes(walletId) && key.includes(xpNetworkType) + ) + + return matchingKeys + .map(key => balances[key]) + .filter(balance => balance !== undefined) + } + export const selectTokensWithBalanceForAccount = createSelector( [selectIsDeveloperMode, selectAllNetworks, _selectBalancesByAccountId], (isDeveloperMode, networks, balancesByAccountId) => { @@ -259,6 +312,84 @@ export const selectBalanceTotalInCurrencyForAccount = .reduce((acc, token) => acc + (token.balanceInCurrency ?? 0), 0) } +export const selectTokensWithXpBalanceForWallet = + (walletId: string, networkType: XpNetworkVMType) => (state: RootState) => { + const balances = _selectAllBalances(state) + const matchingKeys = Object.keys(balances).filter( + key => key.includes(walletId) && key.includes(networkType) + ) + const balancesToSum: Balance[] = [] + for (const key of matchingKeys) { + balances[key] && balancesToSum.push(balances[key]) + } + return balancesToSum + } + +export const selectXPBalanceTotalByWallet = + (walletId: string, networkType: XpNetworkVMType) => (state: RootState) => { + const balances = selectTokensWithXpBalanceForWallet( + walletId, + networkType + )(state) + + if (balances.length === 0) { + return 0n + } + + return balances.reduce( + (acc, balance) => + acc + + balance.tokens.reduce((a, token) => a + (token.balance ?? 0n), 0n), + 0n + ) + } + +export const selectBalanceTotalInCurrencyForXpNetwork = + (walletId: string, networkType: XpNetworkVMType) => (state: RootState) => { + const balances = selectTokensWithXpBalanceForWallet( + walletId, + networkType + )(state) + + if (balances.length === 0) { + return 0 + } + + return balances.reduce( + (acc, balance) => + acc + + balance.tokens.reduce( + (a, token) => a + (token.balanceInCurrency ?? 0), + 0 + ), + 0 + ) + } + +export const selectBalanceTotalInCurrencyForWallet = + (walletId: string, tokenVisibility: TokenVisibility) => + (state: RootState) => { + const accounts = selectAccountsByWalletId(state, walletId) + const platformAccounts = selectPlatformAccountsByWalletId(state, walletId) + + return [...accounts, ...platformAccounts].reduce((acc, account) => { + if (isPlatformAccount(account.id)) { + const balance = selectBalanceTotalInCurrencyForXpNetwork( + walletId, + account.id.includes(NetworkVMType.AVM) + ? NetworkVMType.AVM + : NetworkVMType.PVM + )(state) + return acc + balance + } + const balance = selectBalanceTotalInCurrencyForAccount( + account.id, + tokenVisibility + )(state) + return acc + balance + }, 0) + } + export const selectBalanceForAccountIsAccurate = (accountId: string) => (state: RootState) => { const tokens = selectTokensWithBalanceForAccount(state, accountId) @@ -269,6 +400,13 @@ export const selectBalanceForAccountIsAccurate = .some(balance => !balance.dataAccurate) } +export const selectXpBalanceForAccountIsAccurate = + (walletId: string, xpNetworkType: XpNetworkVMType) => (state: RootState) => { + const tokens = _selectBalancesByXpNetwork(walletId, xpNetworkType)(state) + if (tokens.length === 0) return false + return !tokens.some(balance => !balance.dataAccurate) + } + const _selectBalanceKeyForNetworkAndAccount = ( _state: RootState, chainId: number | undefined, @@ -353,4 +491,9 @@ export const refetchBalance = createAction(`${reducerName}/refetchBalance`) export const fetchBalanceForAccount = createAction<{ account: Account }>( `${reducerName}/fetchBalanceForAccount` ) + +export const fetchXpBalancesForWallet = createAction<{ wallet: Wallet }>( + `${reducerName}/fetchXpBalancesForWallet` +) + export const balanceReducer = balanceSlice.reducer diff --git a/packages/core-mobile/app/store/network/slice.ts b/packages/core-mobile/app/store/network/slice.ts index 6b78ce45df..1033498b40 100644 --- a/packages/core-mobile/app/store/network/slice.ts +++ b/packages/core-mobile/app/store/network/slice.ts @@ -12,6 +12,7 @@ import { import { getNetworksFromCache } from 'hooks/networks/utils/getNetworksFromCache' import { selectIsDeveloperMode } from 'store/settings/advanced' import { selectIsSolanaSupportBlocked } from 'store/posthog' +import { isAvmNetwork, isPvmNetwork } from 'utils/network/isAvalancheNetwork' import { RootState } from '../types' import { ChainID, Networks, NetworkState } from './types' @@ -220,6 +221,13 @@ export const selectEnabledNetworks = createSelector( } ) +export const selectEnabledNetworksWithoutXP = createSelector( + [selectEnabledNetworks], + enabledNetworks => { + return enabledNetworks.filter(n => !isPvmNetwork(n) && !isAvmNetwork(n)) + } +) + export const selectEnabledNetworksByTestnet = (isTestnet: boolean) => (state: RootState) => { const networks = selectNetworks(state) diff --git a/packages/core-mobile/app/store/network/types.ts b/packages/core-mobile/app/store/network/types.ts index 77f7138aa7..fc24cf691c 100644 --- a/packages/core-mobile/app/store/network/types.ts +++ b/packages/core-mobile/app/store/network/types.ts @@ -1,4 +1,5 @@ import { Network } from '@avalabs/core-chains-sdk' +import { NetworkVMType } from '@avalabs/vm-module-types' export type ChainID = number @@ -58,3 +59,5 @@ export enum TokenSymbol { OP = 'OP', BASE = 'BASE' } + +export type XpNetworkVMType = NetworkVMType.AVM | NetworkVMType.PVM diff --git a/packages/core-mobile/app/store/notifications/listeners/subscribeBalanceChangeNotifications.test.ts b/packages/core-mobile/app/store/notifications/listeners/subscribeBalanceChangeNotifications.test.ts index 69b790bc4a..32ca140074 100644 --- a/packages/core-mobile/app/store/notifications/listeners/subscribeBalanceChangeNotifications.test.ts +++ b/packages/core-mobile/app/store/notifications/listeners/subscribeBalanceChangeNotifications.test.ts @@ -62,7 +62,7 @@ describe('subscribeBalanceChangeNotifications', () => { }) it('should skip subscription if there are no accounts', async () => { - ;(selectAccounts as jest.Mock).mockReturnValue({}) + ;(selectAccounts as unknown as jest.Mock).mockReturnValue([]) await subscribeBalanceChangeNotifications(listenerApi) @@ -82,9 +82,9 @@ describe('subscribeBalanceChangeNotifications', () => { }) it('should unsubscribe if balance change notifications are blocked', async () => { - ;(selectAccounts as jest.Mock).mockReturnValue({ - account1: { addressC: 'address1' } - }) + ;(selectAccounts as unknown as jest.Mock).mockReturnValue([ + { addressC: 'address1' } + ]) ;(FCMService.getFCMToken as jest.Mock).mockResolvedValue('fcmToken') ;(registerDeviceToNotificationSender as jest.Mock).mockResolvedValue( 'deviceArn' @@ -102,9 +102,9 @@ describe('subscribeBalanceChangeNotifications', () => { }) it('should subscribe for balance change notifications if not blocked', async () => { - ;(selectAccounts as jest.Mock).mockReturnValue({ - account1: { addressC: 'address1' } - }) + ;(selectAccounts as unknown as jest.Mock).mockReturnValue([ + { addressC: 'address1' } + ]) ;(FCMService.getFCMToken as jest.Mock).mockResolvedValue('fcmToken') ;(registerDeviceToNotificationSender as jest.Mock).mockResolvedValue( 'deviceArn' @@ -129,9 +129,9 @@ describe('subscribeBalanceChangeNotifications', () => { }) it('should log error and throw if subscription response is not "ok"', async () => { - ;(selectAccounts as jest.Mock).mockReturnValue({ - account1: { addressC: 'address1' } - }) + ;(selectAccounts as unknown as jest.Mock).mockReturnValue([ + { addressC: 'address1' } + ]) ;(FCMService.getFCMToken as jest.Mock).mockResolvedValue('fcmToken') ;(registerDeviceToNotificationSender as jest.Mock).mockResolvedValue( 'deviceArn' @@ -153,9 +153,9 @@ describe('subscribeBalanceChangeNotifications', () => { }) it('should handle error during device registration and not proceed with subscription', async () => { - ;(selectAccounts as jest.Mock).mockReturnValue({ - account1: { addressC: 'address1' } - }) + ;(selectAccounts as unknown as jest.Mock).mockReturnValue([ + { addressC: 'address1' } + ]) ;(FCMService.getFCMToken as jest.Mock).mockResolvedValue('fcmToken') ;(registerDeviceToNotificationSender as jest.Mock).mockRejectedValue( new Error('Device registration error') diff --git a/packages/core-mobile/app/store/notifications/listeners/subscribeBalanceChangeNotifications.ts b/packages/core-mobile/app/store/notifications/listeners/subscribeBalanceChangeNotifications.ts index ac37a51488..2ba92dc479 100644 --- a/packages/core-mobile/app/store/notifications/listeners/subscribeBalanceChangeNotifications.ts +++ b/packages/core-mobile/app/store/notifications/listeners/subscribeBalanceChangeNotifications.ts @@ -27,7 +27,7 @@ export async function subscribeBalanceChangeNotifications( } const accounts = selectAccounts(state) - const addresses = Object.values(accounts).map(account => account.addressC) + const addresses = accounts.map(account => account.addressC) if (addresses.length === 0) { // skip if no addresses, means wallet is not yet created diff --git a/packages/core-mobile/app/store/notifications/listeners/subscribeNewsNotifications.test.ts b/packages/core-mobile/app/store/notifications/listeners/subscribeNewsNotifications.test.ts index ebf7915873..e9a48930e2 100644 --- a/packages/core-mobile/app/store/notifications/listeners/subscribeNewsNotifications.test.ts +++ b/packages/core-mobile/app/store/notifications/listeners/subscribeNewsNotifications.test.ts @@ -95,9 +95,9 @@ describe('subscribeNewsNotifications', () => { ;(selectEnabledNewsNotificationSubscriptions as jest.Mock).mockReturnValue([ ChannelId.MARKET_NEWS ]) - ;(selectAccounts as jest.Mock).mockReturnValue({ - account1: { addressC: 'address1' } - }) + ;(selectAccounts as unknown as jest.Mock).mockReturnValue([ + { addressC: 'address1' } + ]) ;(FCMService.getFCMToken as jest.Mock).mockResolvedValue('fcmToken') ;(registerDeviceToNotificationSender as jest.Mock).mockResolvedValue( 'deviceArn' @@ -133,9 +133,9 @@ describe('subscribeNewsNotifications', () => { ChannelId.PRICE_ALERTS, ChannelId.PRODUCT_ANNOUNCEMENTS ]) - ;(selectAccounts as jest.Mock).mockReturnValue({ - account1: { addressC: 'address1' } - }) + ;(selectAccounts as unknown as jest.Mock).mockReturnValue([ + { addressC: 'address1' } + ]) ;(FCMService.getFCMToken as jest.Mock).mockResolvedValue('fcmToken') ;(registerDeviceToNotificationSender as jest.Mock).mockResolvedValue( 'deviceArn' @@ -168,9 +168,9 @@ describe('subscribeNewsNotifications', () => { ChannelId.PRICE_ALERTS, ChannelId.PRODUCT_ANNOUNCEMENTS ]) - ;(selectAccounts as jest.Mock).mockReturnValue({ - account1: { addressC: 'address1' } - }) + ;(selectAccounts as unknown as jest.Mock).mockReturnValue([ + { addressC: 'address1' } + ]) ;(FCMService.getFCMToken as jest.Mock).mockResolvedValue('fcmToken') ;(registerDeviceToNotificationSender as jest.Mock).mockResolvedValue( 'deviceArn' @@ -198,9 +198,9 @@ describe('subscribeNewsNotifications', () => { ChannelId.PRICE_ALERTS, ChannelId.PRODUCT_ANNOUNCEMENTS ]) - ;(selectAccounts as jest.Mock).mockReturnValue({ - account1: { addressC: 'address1' } - }) + ;(selectAccounts as unknown as jest.Mock).mockReturnValue([ + { addressC: 'address1' } + ]) ;(FCMService.getFCMToken as jest.Mock).mockResolvedValue('fcmToken') ;(registerDeviceToNotificationSender as jest.Mock).mockRejectedValue( new Error('Device registration error') diff --git a/packages/core-mobile/app/store/notifications/listeners/subscribeNewsNotifications.ts b/packages/core-mobile/app/store/notifications/listeners/subscribeNewsNotifications.ts index e57166f7dd..3e4d8a0d4f 100644 --- a/packages/core-mobile/app/store/notifications/listeners/subscribeNewsNotifications.ts +++ b/packages/core-mobile/app/store/notifications/listeners/subscribeNewsNotifications.ts @@ -25,7 +25,7 @@ export async function subscribeNewsNotifications( } const accounts = selectAccounts(state) - const addresses = Object.values(accounts).map(account => account.addressC) + const addresses = accounts.map(account => account.addressC) if (addresses.length === 0) { // skip if no addresses, means wallet is not yet created diff --git a/packages/core-mobile/app/store/rpc/handlers/account/avalanche_getAccounts/avalanche_getAccounts.test.ts b/packages/core-mobile/app/store/rpc/handlers/account/avalanche_getAccounts/avalanche_getAccounts.test.ts index 330dafe65e..3744c55a99 100644 --- a/packages/core-mobile/app/store/rpc/handlers/account/avalanche_getAccounts/avalanche_getAccounts.test.ts +++ b/packages/core-mobile/app/store/rpc/handlers/account/avalanche_getAccounts/avalanche_getAccounts.test.ts @@ -7,7 +7,7 @@ jest.mock('store/account/slice', () => { const actual = jest.requireActual('store/account/slice') return { ...actual, - selectAccounts: () => mockAccounts, + selectAccounts: () => Object.values(mockAccounts), selectActiveAccount: () => mockAccounts['0'] } }) diff --git a/packages/core-mobile/app/store/rpc/handlers/account/avalanche_getAccounts/avalanche_getAccounts.ts b/packages/core-mobile/app/store/rpc/handlers/account/avalanche_getAccounts/avalanche_getAccounts.ts index f7deb78518..b99e5e9c9e 100644 --- a/packages/core-mobile/app/store/rpc/handlers/account/avalanche_getAccounts/avalanche_getAccounts.ts +++ b/packages/core-mobile/app/store/rpc/handlers/account/avalanche_getAccounts/avalanche_getAccounts.ts @@ -44,7 +44,7 @@ class AvalancheGetAccountsHandler // Process accounts and add xpubXP where available const accountsArray = await Promise.all( - Object.values(accounts).map(async account => { + accounts.map(async account => { const wallet = selectWalletById(account.walletId)(state) const xpubXP = wallet ? await getXpubXP(account.walletId, wallet.type) diff --git a/packages/core-mobile/app/store/wallet/thunks.ts b/packages/core-mobile/app/store/wallet/thunks.ts index c4b61c21fb..2139e11e0e 100644 --- a/packages/core-mobile/app/store/wallet/thunks.ts +++ b/packages/core-mobile/app/store/wallet/thunks.ts @@ -4,9 +4,9 @@ import AnalyticsService from 'services/analytics/AnalyticsService' import { WalletType } from 'services/wallet/types' import { Account, + AccountCollection, ImportedAccount, selectAccounts, - setAccount, setActiveAccount } from 'store/account' import { ThunkApi } from 'store/types' @@ -18,9 +18,16 @@ import { selectIsDeveloperMode } from 'store/settings/advanced' import { removeAccount, selectAccountsByWalletId, + selectPlatformAccountsByWalletId, + setAccounts, setActiveAccountId } from 'store/account/slice' import AccountsService from 'services/account/AccountsService' +import { NetworkVMType } from '@avalabs/vm-module-types' +import { + P_CHAIN_ACCOUNT_NAME, + X_CHAIN_ACCOUNT_NAME +} from 'store/account/consts' import { generateWalletName } from './utils' import { _removeWallet, selectActiveWalletId } from './slice' @@ -61,7 +68,7 @@ export const importPrivateKeyWalletAndAccount = createAsyncThunk< const dispatch = thunkApi.dispatch const state = thunkApi.getState() const isDeveloperMode = selectIsDeveloperMode(state) - + const accounts: AccountCollection = {} const newWalletId = uuid() await dispatch( @@ -90,8 +97,32 @@ export const importPrivateKeyWalletAndAccount = createAsyncThunk< addressSVM: addresses.SVM, addressCoreEth: addresses.CoreEth } + accounts[accountToImport.id] = accountToImport + + // set platform accounts + ;[NetworkVMType.AVM, NetworkVMType.PVM].forEach(networkType => { + accounts[`${newWalletId}-${networkType}`] = { + index: 0, + id: `${newWalletId}-${networkType}`, + walletId: newWalletId, + name: + networkType === NetworkVMType.PVM + ? P_CHAIN_ACCOUNT_NAME + : X_CHAIN_ACCOUNT_NAME, + type: CoreAccountType.PRIMARY, + addressC: '', + addressBTC: '', + addressAVM: '', + addressPVM: '', + addressCoreEth: '', + addressSVM: '', + addresses: [ + networkType === NetworkVMType.PVM ? addresses.PVM : addresses.AVM + ] + } + }) - thunkApi.dispatch(setAccount(accountToImport)) + thunkApi.dispatch(setAccounts(accounts)) thunkApi.dispatch(setActiveAccount(accountToImport.id)) } ) @@ -106,7 +137,7 @@ export const importMnemonicWalletAndAccount = createAsyncThunk< const dispatch = thunkApi.dispatch const state = thunkApi.getState() const isDeveloperMode = selectIsDeveloperMode(state) - + const accounts: AccountCollection = {} const newWalletId = uuid() await dispatch( @@ -120,7 +151,7 @@ export const importMnemonicWalletAndAccount = createAsyncThunk< thunkApi.dispatch(setActiveWallet(newWalletId)) const allAccounts = selectAccounts(state) - const allAccountsCount = Object.keys(allAccounts).length + const allAccountsCount = allAccounts.length const addresses = await AccountsService.getAddresses({ walletId: newWalletId, @@ -143,8 +174,32 @@ export const importMnemonicWalletAndAccount = createAsyncThunk< addressSVM: addresses.SVM, addressCoreEth: addresses.CoreEth } + accounts[newAccountId] = newAccount + + // set platform accounts + ;[NetworkVMType.AVM, NetworkVMType.PVM].forEach(networkType => { + accounts[`${newWalletId}-${networkType}`] = { + index: 0, + id: `${newWalletId}-${networkType}`, + walletId: newWalletId, + name: + networkType === NetworkVMType.PVM + ? P_CHAIN_ACCOUNT_NAME + : X_CHAIN_ACCOUNT_NAME, + type: CoreAccountType.PRIMARY, + addressC: '', + addressBTC: '', + addressAVM: '', + addressPVM: '', + addressCoreEth: '', + addressSVM: '', + addresses: [ + networkType === NetworkVMType.PVM ? addresses.PVM : addresses.AVM + ] + } + }) - dispatch(setAccount(newAccount)) + dispatch(setAccounts(accounts)) dispatch(setActiveAccount(newAccountId)) AnalyticsService.capture('MnemonicWalletImported', { @@ -165,7 +220,10 @@ export const removeWallet = createAsyncThunk( selectWallets(stateBefore) ).indexOf(activeWalletIdBefore) - const accountsToRemove = selectAccountsByWalletId(stateBefore, walletId) + const accountsToRemove = [ + ...selectAccountsByWalletId(stateBefore, walletId), + ...selectPlatformAccountsByWalletId(stateBefore, walletId) + ] accountsToRemove.forEach(account => { thunkApi.dispatch(removeAccount(account.id)) })