diff --git a/.changeset/young-zoos-fry.md b/.changeset/young-zoos-fry.md new file mode 100644 index 000000000..dfce6de3d --- /dev/null +++ b/.changeset/young-zoos-fry.md @@ -0,0 +1,5 @@ +--- +'alephium-desktop-wallet': patch +--- + +Fetch token prices & ALPH history using new Explorer BE endpoints diff --git a/apps/desktop-wallet/src/App.tsx b/apps/desktop-wallet/src/App.tsx index 45e976eec..b5a9d46e1 100644 --- a/apps/desktop-wallet/src/App.tsx +++ b/apps/desktop-wallet/src/App.tsx @@ -17,6 +17,7 @@ along with the library. If not, see . */ import { AddressHash } from '@alephium/shared' +import { ALPH } from '@alephium/token-list' import { AnimatePresence } from 'framer-motion' import { difference } from 'lodash' import { usePostHog } from 'posthog-js/react' @@ -34,15 +35,20 @@ import { WalletConnectContextProvider } from '@/contexts/walletconnect' import { useAppDispatch, useAppSelector } from '@/hooks/redux' import UpdateWalletModal from '@/modals/UpdateWalletModal' import Router from '@/routes' -import { syncAddressesData, syncAddressesHistoricBalances } from '@/storage/addresses/addressesActions' -import { makeSelectAddressesUnknownTokens, selectAddressIds } from '@/storage/addresses/addressesSelectors' -import { syncNetworkTokensInfo, syncUnknownTokensInfo } from '@/storage/assets/assetsActions' -import { selectIsTokensMetadataUninitialized } from '@/storage/assets/assetsSelectors' +import { syncAddressesAlphHistoricBalances, syncAddressesData } from '@/storage/addresses/addressesActions' +import { + makeSelectAddressesUnknownTokens, + selectAddressIds, + selectAllAddressVerifiedFungibleTokenSymbols +} from '@/storage/addresses/addressesSelectors' +import { syncUnknownTokensInfo, syncVerifiedFungibleTokens } from '@/storage/assets/assetsActions' +import { selectDoVerifiedFungibleTokensNeedInitialization } from '@/storage/assets/assetsSelectors' import { devModeShortcutDetected, localStorageDataMigrated, localStorageDataMigrationFailed } from '@/storage/global/globalActions' +import { syncTokenCurrentPrices, syncTokenPriceHistories } from '@/storage/prices/pricesActions' import { apiClientInitFailed, apiClientInitSucceeded } from '@/storage/settings/networkActions' import { systemLanguageMatchFailed, systemLanguageMatchSucceeded } from '@/storage/settings/settingsActions' import { makeSelectAddressesHashesWithPendingTransactions } from '@/storage/transactions/transactionsSelectors' @@ -57,6 +63,8 @@ import { useInterval } from '@/utils/hooks' import { migrateGeneralSettings, migrateNetworkSettings, migrateWalletData } from '@/utils/migration' import { languageOptions } from '@/utils/settings' +const PRICES_REFRESH_INTERVAL = 60000 + const App = () => { const { newVersion, newVersionDownloadTriggered } = useGlobalContext() const dispatch = useAppDispatch() @@ -65,7 +73,6 @@ const App = () => { const addressesWithPendingTxs = useAppSelector(selectAddressesHashesWithPendingTransactions) const network = useAppSelector((s) => s.network) const theme = useAppSelector((s) => s.global.theme) - const assetsInfo = useAppSelector((s) => s.assetsInfo) const loading = useAppSelector((s) => s.global.loading) const settings = useAppSelector((s) => s.settings) const wallets = useAppSelector((s) => s.global.wallets) @@ -74,12 +81,14 @@ const App = () => { const addressesStatus = useAppSelector((s) => s.addresses.status) const isSyncingAddressData = useAppSelector((s) => s.addresses.syncingAddressData) - const isTokensMetadataUninitialized = useAppSelector(selectIsTokensMetadataUninitialized) - const isLoadingTokensMetadata = useAppSelector((s) => s.assetsInfo.loading) + const verifiedFungibleTokensNeedInitialization = useAppSelector(selectDoVerifiedFungibleTokensNeedInitialization) + const isLoadingVerifiedFungibleTokens = useAppSelector((s) => s.fungibleTokens.loadingVerified) + const isLoadingUnverifiedFungibleTokens = useAppSelector((s) => s.fungibleTokens.loadingUnverified) + const verifiedFungibleTokenSymbols = useAppSelector(selectAllAddressVerifiedFungibleTokenSymbols) const selectAddressesUnknownTokens = useMemo(makeSelectAddressesUnknownTokens, []) const unknownTokens = useAppSelector(selectAddressesUnknownTokens) - const checkedUnknownTokenIds = useAppSelector((s) => s.assetsInfo.checkedUnknownTokenIds) + const checkedUnknownTokenIds = useAppSelector((s) => s.fungibleTokens.checkedUnknownTokenIds) const unknownTokenIds = unknownTokens.map((token) => token.id) const newUnknownTokens = difference(unknownTokenIds, checkedUnknownTokenIds) @@ -175,9 +184,6 @@ const App = () => { useEffect(() => { if (network.status === 'online') { - if (assetsInfo.status === 'uninitialized' && !isLoadingTokensMetadata) { - dispatch(syncNetworkTokensInfo()) - } if (addressesStatus === 'uninitialized') { if (!isSyncingAddressData && addressHashes.length > 0) { const storedPendingTxs = getStoredPendingTransactions() @@ -189,10 +195,15 @@ const App = () => { restorePendingTransactions(mempoolTxHashes, storedPendingTxs) }) - dispatch(syncAddressesHistoricBalances()) + + dispatch(syncAddressesAlphHistoricBalances()) } } else if (addressesStatus === 'initialized') { - if (!isTokensMetadataUninitialized && !isLoadingTokensMetadata && newUnknownTokens.length > 0) { + if ( + !verifiedFungibleTokensNeedInitialization && + !isLoadingUnverifiedFungibleTokens && + newUnknownTokens.length > 0 + ) { dispatch(syncUnknownTokensInfo(newUnknownTokens)) } } @@ -200,15 +211,64 @@ const App = () => { }, [ addressHashes.length, addressesStatus, - assetsInfo.status, + verifiedFungibleTokensNeedInitialization, dispatch, + isLoadingUnverifiedFungibleTokens, isSyncingAddressData, - isLoadingTokensMetadata, - isTokensMetadataUninitialized, network.status, newUnknownTokens ]) + // Fetch verified tokens from GitHub token-list and sync current and historical prices for each verified fungible + // token found in each address + useEffect(() => { + if (network.status === 'online' && !isLoadingVerifiedFungibleTokens) { + if (verifiedFungibleTokensNeedInitialization) { + dispatch(syncVerifiedFungibleTokens()) + } else if (verifiedFungibleTokenSymbols.uninitialized.length > 0) { + const symbols = verifiedFungibleTokenSymbols.uninitialized + + dispatch(syncTokenCurrentPrices({ verifiedFungibleTokenSymbols: symbols, currency: settings.fiatCurrency })) + dispatch(syncTokenPriceHistories({ verifiedFungibleTokenSymbols: symbols, currency: settings.fiatCurrency })) + } + } + }, [ + dispatch, + isLoadingVerifiedFungibleTokens, + network.status, + settings.fiatCurrency, + verifiedFungibleTokenSymbols.uninitialized, + verifiedFungibleTokensNeedInitialization + ]) + + useEffect(() => { + if ( + network.status === 'online' && + !isLoadingVerifiedFungibleTokens && + verifiedFungibleTokenSymbols.uninitialized.length > 1 + ) { + console.log( + 'TODO: Sync address verified tokens balance histories for', + verifiedFungibleTokenSymbols.uninitialized.filter((symbol) => symbol !== ALPH.symbol) + ) + } + }, [isLoadingVerifiedFungibleTokens, network.status, verifiedFungibleTokenSymbols.uninitialized]) + + const refreshTokensLatestPrice = useCallback(() => { + dispatch( + syncTokenCurrentPrices({ + verifiedFungibleTokenSymbols: verifiedFungibleTokenSymbols.withPriceHistory, + currency: settings.fiatCurrency + }) + ) + }, [dispatch, settings.fiatCurrency, verifiedFungibleTokenSymbols.withPriceHistory]) + + useInterval( + refreshTokensLatestPrice, + PRICES_REFRESH_INTERVAL, + network.status !== 'online' || verifiedFungibleTokenSymbols.withPriceHistory.length === 0 + ) + const refreshAddressesData = useCallback(() => { dispatch(syncAddressesData(addressesWithPendingTxs)) }, [dispatch, addressesWithPendingTxs]) diff --git a/apps/desktop-wallet/src/api/addresses.ts b/apps/desktop-wallet/src/api/addresses.ts index 00b1f2faa..2b6ea9bbd 100644 --- a/apps/desktop-wallet/src/api/addresses.ts +++ b/apps/desktop-wallet/src/api/addresses.ts @@ -30,7 +30,9 @@ import { const PAGE_LIMIT = 100 -export const fetchAddressesTokens = async (addressHashes: AddressHash[]): Promise => { +export const fetchAddressesTokensBalances = async ( + addressHashes: AddressHash[] +): Promise => { const results = [] for (const hash of addressHashes) { diff --git a/apps/desktop-wallet/src/components/AssetBadge.tsx b/apps/desktop-wallet/src/components/AssetBadge.tsx index 29dd62f8a..3dcac752f 100644 --- a/apps/desktop-wallet/src/components/AssetBadge.tsx +++ b/apps/desktop-wallet/src/components/AssetBadge.tsx @@ -22,7 +22,7 @@ import styled, { css } from 'styled-components' import Amount from '@/components/Amount' import AssetLogo from '@/components/AssetLogo' import { useAppSelector } from '@/hooks/redux' -import { selectAssetInfoById, selectNFTById } from '@/storage/assets/assetsSelectors' +import { selectFungibleTokenById, selectNFTById } from '@/storage/assets/assetsSelectors' interface AssetBadgeProps { assetId: Asset['id'] @@ -34,28 +34,28 @@ interface AssetBadgeProps { } const AssetBadge = ({ assetId, amount, simple, className }: AssetBadgeProps) => { - const assetInfo = useAppSelector((s) => selectAssetInfoById(s, assetId)) + const fungibleToken = useAppSelector((s) => selectFungibleTokenById(s, assetId)) const nftInfo = useAppSelector((s) => selectNFTById(s, assetId)) return (
{nftInfo?.name ? ( {nftInfo?.name} ) : amount !== undefined ? ( - + ) : ( - !simple && assetInfo?.symbol && {assetInfo.symbol} + !simple && fungibleToken?.symbol && {fungibleToken.symbol} )}
) diff --git a/apps/desktop-wallet/src/components/AssetLogo.tsx b/apps/desktop-wallet/src/components/AssetLogo.tsx index 865e98bf7..adc1ce8e8 100644 --- a/apps/desktop-wallet/src/components/AssetLogo.tsx +++ b/apps/desktop-wallet/src/components/AssetLogo.tsx @@ -16,7 +16,7 @@ You should have received a copy of the GNU Lesser General Public License along with the library. If not, see . */ -import { AssetInfo, NFT } from '@alephium/shared' +import { FungibleToken, NFT } from '@alephium/shared' import { ALPH } from '@alephium/token-list' import { HelpCircle } from 'lucide-react' import styled, { css } from 'styled-components' @@ -24,10 +24,10 @@ import styled, { css } from 'styled-components' import AlephiumLogoSVG from '@/images/alephium_logo_monochrome.svg' interface AssetLogoProps { - assetId: AssetInfo['id'] - assetImageUrl: AssetInfo['logoURI'] | NFT['image'] + assetId: FungibleToken['id'] + assetImageUrl: FungibleToken['logoURI'] | NFT['image'] size: number - assetName?: AssetInfo['name'] + assetName?: FungibleToken['name'] isNft?: boolean className?: string } diff --git a/apps/desktop-wallet/src/components/HistoricWorthChart.tsx b/apps/desktop-wallet/src/components/HistoricWorthChart.tsx index 50d89c171..28e56544a 100644 --- a/apps/desktop-wallet/src/components/HistoricWorthChart.tsx +++ b/apps/desktop-wallet/src/components/HistoricWorthChart.tsx @@ -29,12 +29,10 @@ import { selectHaveHistoricBalancesLoaded, selectIsStateUninitialized } from '@/storage/addresses/addressesSelectors' -import { useGetHistoricalPriceQuery } from '@/storage/assets/priceApiSlice' +import { selectAlphPriceHistory } from '@/storage/prices/pricesSelectors' import { ChartLength, DataPoint, LatestAmountPerAddress } from '@/types/chart' -import { Currency } from '@/types/settings' interface HistoricWorthChartProps { - currency: Currency length: ChartLength onDataPointHover: (dataPoint?: DataPoint) => void onWorthInBeginningOfChartChange: (worthInBeginningOfChart?: DataPoint['worth']) => void @@ -57,7 +55,6 @@ const startingDates: Record = { const HistoricWorthChart = memo(function HistoricWorthChart({ addressHash, latestWorth, - currency, length = '1y', onDataPointHover, onWorthInBeginningOfChartChange @@ -66,8 +63,7 @@ const HistoricWorthChart = memo(function HistoricWorthChart({ const addresses = useAppSelector((s) => selectAddresses(s, addressHash ?? (s.addresses.ids as AddressHash[]))) const haveHistoricBalancesLoaded = useAppSelector(selectHaveHistoricBalancesLoaded) const stateUninitialized = useAppSelector(selectIsStateUninitialized) - - const { data: alphPriceHistory } = useGetHistoricalPriceQuery({ currency, days: 365 }) + const alphPriceHistory = useAppSelector(selectAlphPriceHistory) const theme = useTheme() @@ -90,11 +86,11 @@ const HistoricWorthChart = memo(function HistoricWorthChart({ const computeChartDataPoints = (): DataPoint[] => { const addressesLatestAmount: LatestAmountPerAddress = {} - const dataPoints = alphPriceHistory.map(({ date, price }) => { + const dataPoints = alphPriceHistory.map(({ date, value }) => { let totalAmountPerDate = BigInt(0) - addresses.forEach(({ hash, balanceHistory }) => { - const amountOnDate = balanceHistory.entities[date]?.balance + addresses.forEach(({ hash, alphBalanceHistory }) => { + const amountOnDate = alphBalanceHistory.entities[date]?.balance if (amountOnDate !== undefined) { const amount = BigInt(amountOnDate) @@ -107,7 +103,7 @@ const HistoricWorthChart = memo(function HistoricWorthChart({ return { date, - worth: price * parseFloat(toHumanReadableAmount(totalAmountPerDate)) + worth: value * parseFloat(toHumanReadableAmount(totalAmountPerDate)) } }) diff --git a/apps/desktop-wallet/src/modals/AddressSelectModal.tsx b/apps/desktop-wallet/src/modals/AddressSelectModal.tsx index 82a265736..b8f406f0c 100644 --- a/apps/desktop-wallet/src/modals/AddressSelectModal.tsx +++ b/apps/desktop-wallet/src/modals/AddressSelectModal.tsx @@ -46,7 +46,7 @@ const AddressSelectModal = ({ hideAddressesWithoutAssets }: AddressSelectModalProps) => { const { t } = useTranslation() - const assetsInfo = useAppSelector((state) => state.assetsInfo.entities) + const fungibleTokens = useAppSelector((state) => state.fungibleTokens.entities) const addresses = hideAddressesWithoutAssets ? filterAddressesWithoutAssets(options) : options const [filteredAddresses, setFilteredAddresses] = useState(addresses) @@ -76,7 +76,7 @@ const AddressSelectModal = ({ } const handleSearch = (searchInput: string) => - setFilteredAddresses(filterAddresses(addresses, searchInput.toLowerCase(), assetsInfo)) + setFilteredAddresses(filterAddresses(addresses, searchInput.toLowerCase(), fungibleTokens)) return ( { const { t } = useTranslation() const userSpecifiedAlphAmount = assetAmounts.find((asset) => asset.id === ALPH.id)?.amount const { attoAlphAmount, tokens, extraAlphForDust } = getTransactionAssetAmounts(assetAmounts) - const assetsInfo = useAppSelector((s) => s.assetsInfo.entities) + const fungibleTokens = useAppSelector((s) => s.fungibleTokens.entities) const nfts = useAppSelector((s) => s.nfts.entities) const alphAsset = { id: ALPH.id, amount: attoAlphAmount } @@ -51,7 +51,7 @@ const CheckAmountsBox = ({ assetAmounts, className }: CheckAmountsBoxProps) => { return ( {assets.map((asset, index) => { - const assetInfo = assetsInfo[asset.id] + const fungibleToken = fungibleTokens[asset.id] const nftInfo = nfts[asset.id] return ( @@ -60,15 +60,15 @@ const CheckAmountsBox = ({ assetAmounts, className }: CheckAmountsBoxProps) => { {asset.id === ALPH.id && !!extraAlphForDust && ( diff --git a/apps/desktop-wallet/src/pages/UnlockedWallet/AddressesPage/AddressGridRow.tsx b/apps/desktop-wallet/src/pages/UnlockedWallet/AddressesPage/AddressGridRow.tsx index 9f8b7bdb8..1112ede61 100644 --- a/apps/desktop-wallet/src/pages/UnlockedWallet/AddressesPage/AddressGridRow.tsx +++ b/apps/desktop-wallet/src/pages/UnlockedWallet/AddressesPage/AddressGridRow.tsx @@ -36,8 +36,8 @@ import { selectAddressByHash, selectIsStateUninitialized } from '@/storage/addresses/addressesSelectors' -import { selectIsTokensMetadataUninitialized } from '@/storage/assets/assetsSelectors' -import { useGetPriceQuery } from '@/storage/assets/priceApiSlice' +import { selectDoVerifiedFungibleTokensNeedInitialization } from '@/storage/assets/assetsSelectors' +import { selectAlphPrice } from '@/storage/prices/pricesSelectors' import { currencies } from '@/utils/currencies' import { onEnterOrSpace } from '@/utils/misc' @@ -54,9 +54,10 @@ const AddressGridRow = ({ addressHash, className }: AddressGridRowProps) => { const selectAddressesTokens = useMemo(makeSelectAddressesTokens, []) const assets = useAppSelector((s) => selectAddressesTokens(s, addressHash)) const stateUninitialized = useAppSelector(selectIsStateUninitialized) - const isTokensMetadataUninitialized = useAppSelector(selectIsTokensMetadataUninitialized) + const verifiedFungibleTokensNeedInitialization = useAppSelector(selectDoVerifiedFungibleTokensNeedInitialization) const fiatCurrency = useAppSelector((s) => s.settings.fiatCurrency) - const { data: price, isLoading: isPriceLoading } = useGetPriceQuery(currencies[fiatCurrency].ticker) + const alphPrice = useAppSelector(selectAlphPrice) + const areTokenPricesInitialized = useAppSelector((s) => s.tokenPrices.status === 'initialized') const [isAddressDetailsModalOpen, setIsAddressDetailsModalOpen] = useState(false) @@ -66,7 +67,7 @@ const AddressGridRow = ({ addressHash, className }: AddressGridRowProps) => { if (!address) return null - const fiatBalance = calculateAmountWorth(BigInt(address.balance), price ?? 0) + const fiatBalance = calculateAmountWorth(BigInt(address.balance), alphPrice ?? 0) const hiddenAssetsSymbols = hiddenAssets.filter(({ symbol }) => !!symbol).map(({ symbol }) => symbol) const nbOfUnknownHiddenAssets = hiddenAssets.filter(({ symbol }) => !symbol).length @@ -107,7 +108,7 @@ const AddressGridRow = ({ addressHash, className }: AddressGridRowProps) => { - {isTokensMetadataUninitialized || stateUninitialized ? ( + {verifiedFungibleTokensNeedInitialization || stateUninitialized ? ( ) : ( @@ -124,7 +125,7 @@ const AddressGridRow = ({ addressHash, className }: AddressGridRowProps) => { {stateUninitialized ? : } - {stateUninitialized || isPriceLoading ? ( + {stateUninitialized || !areTokenPricesInitialized ? ( ) : ( diff --git a/apps/desktop-wallet/src/pages/UnlockedWallet/AddressesPage/AddressesTabContent.tsx b/apps/desktop-wallet/src/pages/UnlockedWallet/AddressesPage/AddressesTabContent.tsx index bd913138d..32d28c19a 100644 --- a/apps/desktop-wallet/src/pages/UnlockedWallet/AddressesPage/AddressesTabContent.tsx +++ b/apps/desktop-wallet/src/pages/UnlockedWallet/AddressesPage/AddressesTabContent.tsx @@ -40,7 +40,7 @@ interface AddressesTabContentProps { const AddressesTabContent = ({ tabsRowHeight }: AddressesTabContentProps) => { const addresses = useAppSelector(selectAllAddresses) - const assetsInfo = useAppSelector((state) => state.assetsInfo.entities) + const fungibleTokens = useAppSelector((state) => state.fungibleTokens.entities) const { t } = useTranslation() const [isGenerateNewAddressModalOpen, setIsGenerateNewAddressModalOpen] = useState(false) @@ -50,13 +50,13 @@ const AddressesTabContent = ({ tabsRowHeight }: AddressesTabContentProps) => { const [isAdvancedOperationsModalOpen, setIsAdvancedOperationsModalOpen] = useState(false) useEffect(() => { - const filteredByText = filterAddresses(addresses, searchInput.toLowerCase(), assetsInfo) + const filteredByText = filterAddresses(addresses, searchInput.toLowerCase(), fungibleTokens) const filteredByToggle = hideEmptyAddresses ? filteredByText.filter((address) => address.balance !== '0') : filteredByText setVisibleAddresses(filteredByToggle) - }, [addresses, assetsInfo, hideEmptyAddresses, searchInput]) + }, [addresses, fungibleTokens, hideEmptyAddresses, searchInput]) return ( { const addresses = useAppSelector(selectAllAddresses) const fiatCurrency = useAppSelector((s) => s.settings.fiatCurrency) - const { data: price } = useGetPriceQuery(currencies[fiatCurrency].ticker) + const alphPrice = useAppSelector(selectAlphPrice) const stateUninitialized = useAppSelector(selectIsStateUninitialized) const [selectedAddress, setSelectedAddress] = useState
() @@ -98,7 +98,7 @@ const AddressesList = ({ className, isExpanded, onExpand, onAddressClick }: Addr ) : ( = ({ className, addres const selectAddressesHaveHistoricBalances = useMemo(makeSelectAddressesHaveHistoricBalances, []) const hasHistoricBalances = useAppSelector((s) => selectAddressesHaveHistoricBalances(s, addressHashes)) const fiatCurrency = useAppSelector((s) => s.settings.fiatCurrency) - const { data: price, isLoading: isPriceLoading } = useGetPriceQuery(currencies[fiatCurrency].ticker, { - pollingInterval: 60000 - }) + const alphPrice = useAppSelector(selectAlphPrice) + const arePricesInitialized = useAppSelector((s) => s.tokenPrices.status === 'initialized') + const haveHistoricBalancesLoaded = useAppSelector(selectHaveHistoricBalancesLoaded) const [hoveredDataPoint, setHoveredDataPoint] = useState() const [chartLength, setChartLength] = useState('1m') const [worthInBeginningOfChart, setWorthInBeginningOfChart] = useState() - const { date, worth } = hoveredDataPoint ?? { date: undefined, worth: undefined } + const { date: hoveredDataPointDate, worth: hoveredDataPointWorth } = hoveredDataPoint ?? { + date: undefined, + worth: undefined + } const singleAddress = !!addressHash const totalBalance = addresses.reduce((acc, address) => acc + BigInt(address.balance), BigInt(0)) const totalAvailableBalance = addresses.reduce((acc, address) => acc + getAvailableBalance(address), BigInt(0)) const totalLockedBalance = addresses.reduce((acc, address) => acc + BigInt(address.lockedBalance), BigInt(0)) - const totalAmountWorth = price !== undefined ? calculateAmountWorth(totalBalance, price) : undefined - const balanceInFiat = worth ?? totalAmountWorth + const totalAlphAmountWorth = alphPrice !== undefined ? calculateAmountWorth(totalBalance, alphPrice) : undefined + + const selectAddessesTokensWorth = useMemo(makeSelectAddressesTokensWorth, []) + const totalAmountWorth = useAppSelector((s) => selectAddessesTokensWorth(s, addressHashes)) + const balanceInFiat = hoveredDataPointWorth ?? totalAmountWorth const isOnline = network.status === 'online' - const isHoveringChart = !!worth + const isHoveringChart = !!hoveredDataPointWorth const showBalancesSkeletonLoader = !isBalancesInitialized || (!isBalancesInitialized && isLoadingBalances) return ( @@ -95,27 +103,34 @@ const AmountsOverviewPanel: FC = ({ className, addres - {date ? dayjs(date).format('DD/MM/YYYY') : t('Value today')} - {isPriceLoading || showBalancesSkeletonLoader ? ( + + {hoveredDataPointDate + ? dayjs(hoveredDataPointDate).format('DD/MM/YYYY') + ' (ALPH only)' + : t('Value today')} + + {!arePricesInitialized || showBalancesSkeletonLoader ? ( ) : ( )} - - - {isPriceLoading || - stateUninitialized || - (hasHistoricBalances && worthInBeginningOfChart === undefined) ? ( - - ) : hasHistoricBalances && worthInBeginningOfChart && totalAmountWorth !== undefined ? ( - - ) : null} - - + {hoveredDataPointWorth !== undefined && ( + + + {!arePricesInitialized || + stateUninitialized || + !haveHistoricBalancesLoaded || + (hasHistoricBalances && worthInBeginningOfChart === undefined) ? ( + + ) : hasHistoricBalances && worthInBeginningOfChart && hoveredDataPointWorth !== undefined ? ( + + ) : null} + + + )} {chartLengths.map((length) => - isPriceLoading || stateUninitialized ? ( + !arePricesInitialized || stateUninitialized || !haveHistoricBalancesLoaded ? ( = ({ className, addres diff --git a/apps/desktop-wallet/src/pages/UnlockedWallet/OverviewPage/AssetsList.tsx b/apps/desktop-wallet/src/pages/UnlockedWallet/OverviewPage/AssetsList.tsx index 6a00dcb75..5b2519862 100644 --- a/apps/desktop-wallet/src/pages/UnlockedWallet/OverviewPage/AssetsList.tsx +++ b/apps/desktop-wallet/src/pages/UnlockedWallet/OverviewPage/AssetsList.tsx @@ -16,7 +16,7 @@ You should have received a copy of the GNU Lesser General Public License along with the library. If not, see . */ -import { AddressHash, Asset } from '@alephium/shared' +import { AddressHash, Asset, calculateAmountWorth } from '@alephium/shared' import { motion } from 'framer-motion' import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -42,7 +42,9 @@ import { makeSelectAddressesNFTs, selectIsStateUninitialized } from '@/storage/addresses/addressesSelectors' +import { selectPriceById } from '@/storage/prices/pricesSelectors' import { deviceBreakPoints } from '@/style/globalStyles' +import { currencies } from '@/utils/currencies' interface AssetsListProps { className?: string @@ -122,7 +124,9 @@ const TokensList = ({ className, addressHashes, isExpanded, onExpand }: AssetsLi const selectAddressesKnownFungibleTokens = useMemo(makeSelectAddressesKnownFungibleTokens, []) const knownFungibleTokens = useAppSelector((s) => selectAddressesKnownFungibleTokens(s, addressHashes)) const stateUninitialized = useAppSelector(selectIsStateUninitialized) - const isLoadingTokensMetadata = useAppSelector((s) => s.assetsInfo.loading) + const isLoadingFungibleTokens = useAppSelector( + (s) => s.fungibleTokens.loadingUnverified || s.fungibleTokens.loadingVerified + ) return ( <> @@ -130,7 +134,7 @@ const TokensList = ({ className, addressHashes, isExpanded, onExpand }: AssetsLi {knownFungibleTokens.map((asset) => ( ))} - {(isLoadingTokensMetadata || stateUninitialized) && ( + {(isLoadingFungibleTokens || stateUninitialized) && ( @@ -168,6 +172,8 @@ const TokenListRow = ({ asset, isExpanded }: TokenListRowProps) => { const { t } = useTranslation() const theme = useTheme() const stateUninitialized = useAppSelector(selectIsStateUninitialized) + const fiatCurrency = useAppSelector((s) => s.settings.fiatCurrency) + const assetPrice = useAppSelector((s) => selectPriceById(s, asset.symbol || '')) return ( @@ -210,6 +216,15 @@ const TokenListRow = ({ asset, isExpanded }: TokenListRowProps) => { )} {!asset.symbol && {t('Raw amount')}} + {assetPrice && assetPrice.price !== null && ( + + + + )} )} @@ -223,12 +238,12 @@ const NFTsList = ({ className, addressHashes, isExpanded, onExpand }: AssetsList const selectAddressesNFTs = useMemo(makeSelectAddressesNFTs, []) const nfts = useAppSelector((s) => selectAddressesNFTs(s, addressHashes)) const stateUninitialized = useAppSelector(selectIsStateUninitialized) - const isLoadingTokensMetadata = useAppSelector((s) => s.assetsInfo.loading) + const isLoadingNFTs = useAppSelector((s) => s.nfts.loading) return ( <> - {isLoadingTokensMetadata || stateUninitialized ? ( + {isLoadingNFTs || stateUninitialized ? ( @@ -291,6 +306,11 @@ const AmountSubtitle = styled.div` font-size: 10px; ` +const Price = styled.div` + font-size: 11px; + color: ${({ theme }) => theme.font.secondary}; +` + const NameColumn = styled(Column)` margin-right: 50px; ` diff --git a/apps/desktop-wallet/src/pages/UnlockedWallet/OverviewPage/GreetingMessages.tsx b/apps/desktop-wallet/src/pages/UnlockedWallet/OverviewPage/GreetingMessages.tsx index 22fe6a607..72f69c9ed 100644 --- a/apps/desktop-wallet/src/pages/UnlockedWallet/OverviewPage/GreetingMessages.tsx +++ b/apps/desktop-wallet/src/pages/UnlockedWallet/OverviewPage/GreetingMessages.tsx @@ -25,7 +25,7 @@ import styled from 'styled-components' import { fadeInOut } from '@/animations' import { useAppSelector } from '@/hooks/redux' import TimeOfDayMessage from '@/pages/UnlockedWallet/OverviewPage/TimeOfDayMessage' -import { useGetPriceQuery } from '@/storage/assets/priceApiSlice' +import { selectAlphPrice } from '@/storage/prices/pricesSelectors' import { currencies } from '@/utils/currencies' interface GreetingMessagesProps { @@ -37,20 +37,19 @@ const swapDelayInSeconds = 8 const GreetingMessages = ({ className }: GreetingMessagesProps) => { const { t } = useTranslation() const activeWallet = useAppSelector((s) => s.activeWallet) + const alphPrice = useAppSelector(selectAlphPrice) + const tokenPricesStatus = useAppSelector((s) => s.tokenPrices.status) const fiatCurrency = useAppSelector((s) => s.settings.fiatCurrency) - const { data: price, isLoading: isPriceLoading } = useGetPriceQuery(currencies[fiatCurrency].ticker, { - pollingInterval: 60000 - }) const [currentComponentIndex, setCurrentComponentIndex] = useState(0) const [lastClickTime, setLastChangeTime] = useState(Date.now()) const priceComponent = ( - {price + {alphPrice !== undefined ? '📈 ' + - t('ALPH price: {{ price }}', { price: formatFiatAmountForDisplay(price) }) + + t('ALPH price: {{ price }}', { price: formatFiatAmountForDisplay(alphPrice) }) + currencies[fiatCurrency].symbol : '💜'} @@ -67,13 +66,13 @@ const GreetingMessages = ({ className }: GreetingMessagesProps) => { const showNextMessage = useCallback(() => { setCurrentComponentIndex((prevIndex) => { - if (prevIndex === 0 && (isPriceLoading || price == null)) { + if (prevIndex === 0 && (tokenPricesStatus === 'uninitialized' || alphPrice === undefined)) { return prevIndex } return (prevIndex + 1) % componentList.length }) setLastChangeTime(Date.now()) - }, [componentList.length, isPriceLoading, price]) + }, [componentList.length, tokenPricesStatus, alphPrice]) const handleClick = useCallback(() => { showNextMessage() diff --git a/apps/desktop-wallet/src/pages/UnlockedWallet/TransfersPage/FiltersPanel.tsx b/apps/desktop-wallet/src/pages/UnlockedWallet/TransfersPage/FiltersPanel.tsx index 27acc5003..00e6bab32 100644 --- a/apps/desktop-wallet/src/pages/UnlockedWallet/TransfersPage/FiltersPanel.tsx +++ b/apps/desktop-wallet/src/pages/UnlockedWallet/TransfersPage/FiltersPanel.tsx @@ -28,7 +28,7 @@ import SelectOptionAsset from '@/components/Inputs/SelectOptionAsset' import { useAppSelector } from '@/hooks/redux' import { UnlockedWalletPanel } from '@/pages/UnlockedWallet/UnlockedWalletLayout' import { makeSelectAddressesTokens, selectAllAddresses } from '@/storage/addresses/addressesSelectors' -import { selectIsTokensMetadataUninitialized } from '@/storage/assets/assetsSelectors' +import { selectDoVerifiedFungibleTokensNeedInitialization } from '@/storage/assets/assetsSelectors' import { appHeaderHeightPx } from '@/style/globalStyles' import { Address } from '@/types/addresses' import { directionOptions } from '@/utils/transactions' @@ -57,7 +57,7 @@ const FiltersPanel = ({ const selectAddressesTokens = useMemo(makeSelectAddressesTokens, []) const assets = useAppSelector(selectAddressesTokens) - const isTokensMetadataUninitialized = useAppSelector(selectIsTokensMetadataUninitialized) + const verifiedFungibleTokensNeedInitialization = useAppSelector(selectDoVerifiedFungibleTokensNeedInitialization) const stateUninitialized = useAppSelector((s) => s.addresses.status === 'uninitialized') // TODO: Use selector from next PR const renderAddressesSelectedValue = () => @@ -92,10 +92,10 @@ const FiltersPanel = ({ } useEffect(() => { - if (!isTokensMetadataUninitialized && !stateUninitialized && !selectedAssets) { + if (!verifiedFungibleTokensNeedInitialization && !stateUninitialized && !selectedAssets) { setSelectedAssets(assets) } - }, [assets, isTokensMetadataUninitialized, selectedAssets, setSelectedAssets, stateUninitialized]) + }, [verifiedFungibleTokensNeedInitialization, assets, selectedAssets, setSelectedAssets, stateUninitialized]) return ( diff --git a/apps/desktop-wallet/src/pages/UnlockedWallet/TransfersPage/index.tsx b/apps/desktop-wallet/src/pages/UnlockedWallet/TransfersPage/index.tsx index 126b8abe1..36b729111 100644 --- a/apps/desktop-wallet/src/pages/UnlockedWallet/TransfersPage/index.tsx +++ b/apps/desktop-wallet/src/pages/UnlockedWallet/TransfersPage/index.tsx @@ -62,7 +62,7 @@ const TransfersPage = ({ className }: TransfersPageProps) => { const closeInfoMessage = () => dispatch(transfersPageInfoMessageClosed()) useEffect(() => { - scrollDirection?.on('change', setDirection) + scrollDirection?.onChange(setDirection) return () => scrollDirection?.destroy() }, [scrollDirection]) diff --git a/apps/desktop-wallet/src/storage/addresses/addressesActions.ts b/apps/desktop-wallet/src/storage/addresses/addressesActions.ts index 6fc8ddb76..4cf90de0a 100644 --- a/apps/desktop-wallet/src/storage/addresses/addressesActions.ts +++ b/apps/desktop-wallet/src/storage/addresses/addressesActions.ts @@ -25,7 +25,7 @@ import { posthog } from 'posthog-js' import { fetchAddressesBalances, - fetchAddressesTokens, + fetchAddressesTokensBalances, fetchAddressesTransactions, fetchAddressesTransactionsNextPage, fetchAddressTransactionsNextPage @@ -77,7 +77,7 @@ export const syncAddressesData = createAsyncThunk< try { await dispatch(syncAddressesBalances(addresses)) - await dispatch(syncAddressesTokens(addresses)) + await dispatch(syncAddressesTokensBalances(addresses)) return await dispatch(syncAddressesTransactions(addresses)).unwrap() } catch (e) { posthog.capture('Error', { message: 'Synching address data' }) @@ -98,9 +98,9 @@ export const syncAddressesTransactions = createAsyncThunk( async (addresses: AddressHash[]) => await fetchAddressesTransactions(addresses) ) -export const syncAddressesTokens = createAsyncThunk( - 'addresses/syncAddressesTokens', - async (addresses: AddressHash[]) => await fetchAddressesTokens(addresses) +export const syncAddressesTokensBalances = createAsyncThunk( + 'addresses/syncAddressesTokensBalances', + async (addresses: AddressHash[]) => await fetchAddressesTokensBalances(addresses) ) export const syncAddressTransactionsNextPage = createAsyncThunk( @@ -159,8 +159,8 @@ export const syncAllAddressesTransactionsNextPage = createAsyncThunk( } ) -export const syncAddressesHistoricBalances = createAsyncThunk( - 'addresses/syncAddressesHistoricBalances', +export const syncAddressesAlphHistoricBalances = createAsyncThunk( + 'addresses/syncAddressesAlphHistoricBalances', async ( payload: AddressHash[] | undefined, { getState } @@ -181,14 +181,16 @@ export const syncAddressesHistoricBalances = createAsyncThunk( for (const addressHash of addresses) { const balances = [] - const data = await client.explorer.addresses.getAddressesAddressAmountHistoryDeprecated( + + // TODO: Do not use getAddressesAddressAmountHistoryDeprecated when the new delta endpoints are released + const alphHistoryData = await client.explorer.addresses.getAddressesAddressAmountHistoryDeprecated( addressHash, { fromTs: oneYearAgo, toTs: thisMoment, 'interval-type': explorer.IntervalType.Daily }, { format: 'text' } ) try { - const { amountHistory } = JSON.parse(data) + const { amountHistory } = JSON.parse(alphHistoryData) for (const [timestamp, balance] of amountHistory) { balances.push({ diff --git a/apps/desktop-wallet/src/storage/addresses/addressesSelectors.ts b/apps/desktop-wallet/src/storage/addresses/addressesSelectors.ts index 777725dcf..82b0673ca 100644 --- a/apps/desktop-wallet/src/storage/addresses/addressesSelectors.ts +++ b/apps/desktop-wallet/src/storage/addresses/addressesSelectors.ts @@ -16,14 +16,22 @@ You should have received a copy of the GNU Lesser General Public License along with the library. If not, see . */ -import { AddressHash, Asset, NFT, TokenDisplayBalances } from '@alephium/shared' +import { + AddressFungibleToken, + AddressHash, + Asset, + calculateAmountWorth, + NFT, + TokenDisplayBalances +} from '@alephium/shared' import { ALPH } from '@alephium/token-list' import { AddressGroup } from '@alephium/walletconnect-provider' import { createSelector } from '@reduxjs/toolkit' import { sortBy } from 'lodash' import { addressesAdapter, contactsAdapter } from '@/storage/addresses/addressesAdapters' -import { selectAllAssetsInfo, selectAllNFTs, selectNFTIds } from '@/storage/assets/assetsSelectors' +import { selectAllFungibleTokens, selectAllNFTs, selectNFTIds } from '@/storage/assets/assetsSelectors' +import { selectAllPrices, selectAllPricesHistories } from '@/storage/prices/pricesSelectors' import { RootState } from '@/storage/store' import { Address } from '@/types/addresses' import { filterAddressesWithoutAssets } from '@/utils/addresses' @@ -72,22 +80,22 @@ export const makeSelectAddressesAlphAsset = () => export const makeSelectAddressesTokens = () => createSelector( - [selectAllAssetsInfo, selectAllNFTs, makeSelectAddressesAlphAsset(), makeSelectAddresses()], - (assetsInfo, nfts, alphAsset, addresses): Asset[] => { + [selectAllFungibleTokens, selectAllNFTs, makeSelectAddressesAlphAsset(), makeSelectAddresses()], + (fungibleTokens, nfts, alphAsset, addresses): Asset[] => { const tokens = getAddressesTokenBalances(addresses).reduce((acc, token) => { - const assetInfo = assetsInfo.find((t) => t.id === token.id) + const fungibleToken = fungibleTokens.find((t) => t.id === token.id) const nftInfo = nfts.find((nft) => nft.id === token.id) acc.push({ id: token.id, balance: BigInt(token.balance.toString()), lockedBalance: BigInt(token.lockedBalance.toString()), - name: assetInfo?.name ?? nftInfo?.name, - symbol: assetInfo?.symbol, - description: assetInfo?.description ?? nftInfo?.description, - logoURI: assetInfo?.logoURI ?? nftInfo?.image, - decimals: assetInfo?.decimals ?? 0, - verified: assetInfo?.verified + name: fungibleToken?.name ?? nftInfo?.name, + symbol: fungibleToken?.symbol, + description: fungibleToken?.description ?? nftInfo?.description, + logoURI: fungibleToken?.logoURI ?? nftInfo?.image, + decimals: fungibleToken?.decimals ?? 0, + verified: fungibleToken?.verified }) return acc @@ -101,14 +109,56 @@ export const makeSelectAddressesTokens = () => ) export const makeSelectAddressesKnownFungibleTokens = () => - createSelector([makeSelectAddressesTokens()], (tokens): Asset[] => tokens.filter((token) => !!token?.symbol)) + createSelector([makeSelectAddressesTokens()], (tokens): AddressFungibleToken[] => + tokens.filter((token): token is AddressFungibleToken => !!token.symbol) + ) + +export const makeSelectAddressesVerifiedFungibleTokens = () => + createSelector([makeSelectAddressesTokens()], (tokens): AddressFungibleToken[] => + tokens.filter((token): token is AddressFungibleToken => !!token.verified) + ) + +export const selectAllAddressVerifiedFungibleTokenSymbols = createSelector( + [makeSelectAddressesVerifiedFungibleTokens(), selectAllPricesHistories], + (verifiedFungibleTokens, histories) => + verifiedFungibleTokens + .map((token) => token.symbol) + .reduce( + (acc, tokenSymbol) => { + const tokenHistory = histories.find(({ symbol }) => symbol === tokenSymbol) + + if (!tokenHistory || tokenHistory.status === 'uninitialized') { + acc.uninitialized.push(tokenSymbol) + } else if (tokenHistory && tokenHistory.history.length > 0) { + acc.withPriceHistory.push(tokenSymbol) + } + + return acc + }, + { + uninitialized: [] as string[], + withPriceHistory: [] as string[] + } + ) +) + +export const makeSelectAddressesTokensWorth = () => + createSelector([makeSelectAddressesKnownFungibleTokens(), selectAllPrices], (verifiedFungibleTokens, tokenPrices) => + tokenPrices.reduce((totalWorth, { symbol, price }) => { + const verifiedFungibleToken = verifiedFungibleTokens.find((t) => t.symbol === symbol) + + return verifiedFungibleToken + ? totalWorth + calculateAmountWorth(verifiedFungibleToken.balance, price, verifiedFungibleToken.decimals) + : totalWorth + }, 0) + ) export const makeSelectAddressesUnknownTokens = () => createSelector( - [selectAllAssetsInfo, selectNFTIds, makeSelectAddresses()], - (assetsInfo, nftIds, addresses): Asset[] => { + [selectAllFungibleTokens, selectNFTIds, makeSelectAddresses()], + (fungibleTokens, nftIds, addresses): Asset[] => { const tokensWithoutMetadata = getAddressesTokenBalances(addresses).reduce((acc, token) => { - const hasTokenMetadata = !!assetsInfo.find((t) => t.id === token.id) + const hasTokenMetadata = !!fungibleTokens.find((t) => t.id === token.id) const hasNFTMetadata = nftIds.includes(token.id) if (!hasTokenMetadata && !hasNFTMetadata) { @@ -129,7 +179,7 @@ export const makeSelectAddressesUnknownTokens = () => export const makeSelectAddressesCheckedUnknownTokens = () => createSelector( - [makeSelectAddressesUnknownTokens(), (state: RootState) => state.assetsInfo.checkedUnknownTokenIds], + [makeSelectAddressesUnknownTokens(), (state: RootState) => state.fungibleTokens.checkedUnknownTokenIds], (tokensWithoutMetadata, checkedUnknownTokenIds) => tokensWithoutMetadata.filter((token) => checkedUnknownTokenIds.includes(token.id)) ) @@ -160,15 +210,15 @@ export const selectHaveAllPagesLoaded = createSelector( ) export const selectHaveHistoricBalancesLoaded = createSelector(selectAllAddresses, (addresses) => - addresses.every((address) => address.balanceHistoryInitialized) + addresses.every((address) => address.alphBalanceHistoryInitialized) ) export const makeSelectAddressesHaveHistoricBalances = () => createSelector( makeSelectAddresses(), (addresses) => - addresses.every((address) => address.balanceHistoryInitialized) && - addresses.some((address) => address.balanceHistory.ids.length > 0) + addresses.every((address) => address.alphBalanceHistoryInitialized) && + addresses.some((address) => address.alphBalanceHistory.ids.length > 0) ) export const selectAddressesWithSomeBalance = createSelector(selectAllAddresses, filterAddressesWithoutAssets) diff --git a/apps/desktop-wallet/src/storage/addresses/addressesSlice.ts b/apps/desktop-wallet/src/storage/addresses/addressesSlice.ts index 9c5b19b5c..7ecdc2c59 100644 --- a/apps/desktop-wallet/src/storage/addresses/addressesSlice.ts +++ b/apps/desktop-wallet/src/storage/addresses/addressesSlice.ts @@ -27,10 +27,10 @@ import { addressSettingsSaved, defaultAddressChanged, newAddressesSaved, + syncAddressesAlphHistoricBalances, syncAddressesBalances, syncAddressesData, - syncAddressesHistoricBalances, - syncAddressesTokens, + syncAddressesTokensBalances, syncAddressesTransactions, syncAddressTransactionsNextPage, syncAllAddressesTransactionsNextPage, @@ -55,7 +55,7 @@ import { getInitialAddressSettings } from '@/utils/addresses' const initialState: AddressesState = addressesAdapter.getInitialState({ loadingBalances: false, loadingTransactions: false, - loadingTokens: false, + loadingTokensBalances: false, syncingAddressData: false, isRestoringAddressesFromMetadata: false, status: 'uninitialized', @@ -72,7 +72,7 @@ const addressesSlice = createSlice({ state.syncingAddressData = true state.loadingBalances = true state.loadingTransactions = true - state.loadingTokens = true + state.loadingTokensBalances = true }) .addCase(transactionsLoadingStarted, (state) => { state.loadingTransactions = true @@ -122,9 +122,9 @@ const addressesSlice = createSlice({ state.syncingAddressData = false state.loadingBalances = false state.loadingTransactions = false - state.loadingTokens = false + state.loadingTokensBalances = false }) - .addCase(syncAddressesTokens.fulfilled, (state, action) => { + .addCase(syncAddressesTokensBalances.fulfilled, (state, action) => { const addressData = action.payload const updatedAddresses = addressData.map(({ hash, tokenBalances }) => ({ id: hash, @@ -135,7 +135,7 @@ const addressesSlice = createSlice({ addressesAdapter.updateMany(state, updatedAddresses) - state.loadingTokens = false + state.loadingTokensBalances = false }) .addCase(syncAddressesTransactions.fulfilled, (state, action) => { const addressData = action.payload @@ -181,7 +181,7 @@ const addressesSlice = createSlice({ state.syncingAddressData = false state.loadingBalances = false state.loadingTransactions = false - state.loadingTokens = false + state.loadingTokensBalances = false }) .addCase(syncAddressesBalances.rejected, (state) => { state.loadingBalances = false @@ -189,8 +189,8 @@ const addressesSlice = createSlice({ .addCase(syncAddressesTransactions.rejected, (state) => { state.loadingTransactions = false }) - .addCase(syncAddressesTokens.rejected, (state) => { - state.loadingTokens = false + .addCase(syncAddressesTokensBalances.rejected, (state) => { + state.loadingTokensBalances = false }) .addCase(syncAddressTransactionsNextPage.fulfilled, (state, action) => { const addressTransactionsData = action.payload @@ -238,13 +238,13 @@ const addressesSlice = createSlice({ .addCase(activeWalletDeleted, () => initialState) .addCase(networkPresetSwitched, clearAddressesNetworkData) .addCase(customNetworkSettingsSaved, clearAddressesNetworkData) - .addCase(syncAddressesHistoricBalances.fulfilled, (state, { payload: data }) => { + .addCase(syncAddressesAlphHistoricBalances.fulfilled, (state, { payload: data }) => { data.forEach(({ address, balances }) => { const addressState = state.entities[address] if (addressState) { - balanceHistoryAdapter.upsertMany(addressState.balanceHistory, balances) - addressState.balanceHistoryInitialized = true + balanceHistoryAdapter.upsertMany(addressState.alphBalanceHistory, balances) + addressState.alphBalanceHistoryInitialized = true } }) }) @@ -280,8 +280,8 @@ const getDefaultAddressState = (address: AddressBase): Address => ({ allTransactionPagesLoaded: false, tokens: [], lastUsed: 0, - balanceHistory: balanceHistoryAdapter.getInitialState(), - balanceHistoryInitialized: false + alphBalanceHistory: balanceHistoryAdapter.getInitialState(), + alphBalanceHistoryInitialized: false }) const updateOldDefaultAddress = (state: AddressesState) => { diff --git a/apps/desktop-wallet/src/storage/addresses/addressesStorageUtils.ts b/apps/desktop-wallet/src/storage/addresses/addressesStorageUtils.ts index 0ce2d8157..82999fb43 100644 --- a/apps/desktop-wallet/src/storage/addresses/addressesStorageUtils.ts +++ b/apps/desktop-wallet/src/storage/addresses/addressesStorageUtils.ts @@ -20,8 +20,8 @@ import { addressSettingsSaved, defaultAddressChanged, newAddressesSaved, - syncAddressesData, - syncAddressesHistoricBalances + syncAddressesAlphHistoricBalances, + syncAddressesData } from '@/storage/addresses/addressesActions' import AddressMetadataStorage from '@/storage/addresses/addressMetadataPersistentStorage' import { store } from '@/storage/store' @@ -43,7 +43,7 @@ export const saveNewAddresses = (addresses: AddressBase[]) => { store.dispatch(newAddressesSaved(addresses)) store.dispatch(syncAddressesData(addressHashes)) - store.dispatch(syncAddressesHistoricBalances(addressHashes)) + store.dispatch(syncAddressesAlphHistoricBalances(addressHashes)) } export const changeDefaultAddress = (address: Address) => { diff --git a/apps/desktop-wallet/src/storage/assets/assetsActions.ts b/apps/desktop-wallet/src/storage/assets/assetsActions.ts index 46cad230f..89ac1ae12 100644 --- a/apps/desktop-wallet/src/storage/assets/assetsActions.ts +++ b/apps/desktop-wallet/src/storage/assets/assetsActions.ts @@ -19,7 +19,7 @@ along with the library. If not, see . import { Asset, SyncUnknownTokensInfoResult, TOKENS_QUERY_LIMIT } from '@alephium/shared' import { TokenList } from '@alephium/token-list' import { explorer } from '@alephium/web3' -import { createAction, createAsyncThunk } from '@reduxjs/toolkit' +import { createAsyncThunk } from '@reduxjs/toolkit' import { chunk, groupBy } from 'lodash' import posthog from 'posthog-js' @@ -27,15 +27,11 @@ import client from '@/api/client' import { exponentialBackoffFetchRetry } from '@/api/fetchRetry' import { RootState } from '@/storage/store' -export const loadingStarted = createAction('assets/loadingStarted') - -export const syncNetworkTokensInfo = createAsyncThunk( - 'assets/syncNetworkTokensInfo', - async (_, { getState, dispatch }) => { +export const syncVerifiedFungibleTokens = createAsyncThunk( + 'assets/syncVerifiedFungibleTokens', + async (_, { getState }) => { const state = getState() as RootState - dispatch(loadingStarted()) - let metadata = undefined const network = state.network.settings.networkId === 0 @@ -62,14 +58,12 @@ export const syncNetworkTokensInfo = createAsyncThunk( export const syncUnknownTokensInfo = createAsyncThunk( 'assets/syncUnknownTokensInfo', - async (unknownTokenIds: Asset['id'][], { dispatch }): Promise => { + async (unknownTokenIds: Asset['id'][]): Promise => { const results = { tokens: [], nfts: [] } as SyncUnknownTokensInfoResult - dispatch(loadingStarted()) - const tokenTypes = await Promise.all( chunk(unknownTokenIds, TOKENS_QUERY_LIMIT).map((unknownTokenIdsChunk) => client.explorer.tokens.postTokens(unknownTokenIdsChunk) diff --git a/apps/desktop-wallet/src/storage/assets/assetsAdapter.ts b/apps/desktop-wallet/src/storage/assets/assetsAdapter.ts index 4117465fa..0cfd7fc81 100644 --- a/apps/desktop-wallet/src/storage/assets/assetsAdapter.ts +++ b/apps/desktop-wallet/src/storage/assets/assetsAdapter.ts @@ -16,10 +16,10 @@ You should have received a copy of the GNU Lesser General Public License along with the library. If not, see . */ -import { AssetInfo, NFT } from '@alephium/shared' +import { FungibleToken, NFT } from '@alephium/shared' import { createEntityAdapter } from '@reduxjs/toolkit' -export const assetsInfoAdapter = createEntityAdapter({ +export const fungibleTokensAdapter = createEntityAdapter({ sortComparer: (a, b) => a.name.localeCompare(b.name) }) diff --git a/apps/desktop-wallet/src/storage/assets/assetsSelectors.ts b/apps/desktop-wallet/src/storage/assets/assetsSelectors.ts index 609845637..702810111 100644 --- a/apps/desktop-wallet/src/storage/assets/assetsSelectors.ts +++ b/apps/desktop-wallet/src/storage/assets/assetsSelectors.ts @@ -18,15 +18,15 @@ along with the library. If not, see . import { createSelector } from '@reduxjs/toolkit' -import { assetsInfoAdapter, nftsAdapter } from '@/storage/assets/assetsAdapter' +import { fungibleTokensAdapter, nftsAdapter } from '@/storage/assets/assetsAdapter' import { networkPresets } from '@/storage/settings/settingsPersistentStorage' import { RootState } from '@/storage/store' -export const { selectAll: selectAllAssetsInfo, selectById: selectAssetInfoById } = - assetsInfoAdapter.getSelectors((state) => state.assetsInfo) +export const { selectAll: selectAllFungibleTokens, selectById: selectFungibleTokenById } = + fungibleTokensAdapter.getSelectors((state) => state.fungibleTokens) -export const selectIsTokensMetadataUninitialized = createSelector( - [(state: RootState) => state.assetsInfo.status, (state: RootState) => state.network.settings.networkId], +export const selectDoVerifiedFungibleTokensNeedInitialization = createSelector( + [(state: RootState) => state.fungibleTokens.status, (state: RootState) => state.network.settings.networkId], (status, networkId) => (networkId === networkPresets.mainnet.networkId || networkId === networkPresets.testnet.networkId) && status === 'uninitialized' diff --git a/apps/desktop-wallet/src/storage/assets/assetsInfoSlice.ts b/apps/desktop-wallet/src/storage/assets/fungibleTokensSlice.ts similarity index 65% rename from apps/desktop-wallet/src/storage/assets/assetsInfoSlice.ts rename to apps/desktop-wallet/src/storage/assets/fungibleTokensSlice.ts index a7f79c091..5a696b554 100644 --- a/apps/desktop-wallet/src/storage/assets/assetsInfoSlice.ts +++ b/apps/desktop-wallet/src/storage/assets/fungibleTokensSlice.ts @@ -16,23 +16,25 @@ You should have received a copy of the GNU Lesser General Public License along with the library. If not, see . */ -import { AssetInfo } from '@alephium/shared' +import { FungibleToken } from '@alephium/shared' import { ALPH } from '@alephium/token-list' import { createSlice, EntityState } from '@reduxjs/toolkit' -import { loadingStarted, syncNetworkTokensInfo, syncUnknownTokensInfo } from '@/storage/assets/assetsActions' -import { assetsInfoAdapter } from '@/storage/assets/assetsAdapter' +import { syncUnknownTokensInfo, syncVerifiedFungibleTokens } from '@/storage/assets/assetsActions' +import { fungibleTokensAdapter } from '@/storage/assets/assetsAdapter' import { customNetworkSettingsSaved, networkPresetSwitched } from '@/storage/settings/networkActions' -interface AssetsInfoState extends EntityState { - loading: boolean +interface FungibleTokensState extends EntityState { + loadingVerified: boolean + loadingUnverified: boolean status: 'initialized' | 'uninitialized' - checkedUnknownTokenIds: AssetInfo['id'][] + checkedUnknownTokenIds: FungibleToken['id'][] } -const initialState: AssetsInfoState = assetsInfoAdapter.addOne( - assetsInfoAdapter.getInitialState({ - loading: false, +const initialState: FungibleTokensState = fungibleTokensAdapter.addOne( + fungibleTokensAdapter.getInitialState({ + loadingVerified: false, + loadingUnverified: false, status: 'uninitialized', checkedUnknownTokenIds: [] }), @@ -42,20 +44,20 @@ const initialState: AssetsInfoState = assetsInfoAdapter.addOne( } ) -const assetsSlice = createSlice({ - name: 'assetsInfo', +const fungibleTokensSlice = createSlice({ + name: 'fungibleTokens', initialState, reducers: {}, extraReducers(builder) { builder - .addCase(loadingStarted, (state) => { - state.loading = true + .addCase(syncVerifiedFungibleTokens.pending, (state) => { + state.loadingVerified = true }) - .addCase(syncNetworkTokensInfo.fulfilled, (state, action) => { + .addCase(syncVerifiedFungibleTokens.fulfilled, (state, action) => { const metadata = action.payload if (metadata) { - assetsInfoAdapter.upsertMany( + fungibleTokensAdapter.upsertMany( state, metadata.tokens.map((tokenInfo) => ({ ...tokenInfo, @@ -63,9 +65,12 @@ const assetsSlice = createSlice({ })) ) state.status = 'initialized' - state.loading = false + state.loadingVerified = false } }) + .addCase(syncUnknownTokensInfo.pending, (state) => { + state.loadingUnverified = true + }) .addCase(syncUnknownTokensInfo.fulfilled, (state, action) => { const metadata = action.payload.tokens const initiallyUnknownTokenIds = action.meta.arg @@ -73,7 +78,7 @@ const assetsSlice = createSlice({ state.checkedUnknownTokenIds = [...initiallyUnknownTokenIds, ...state.checkedUnknownTokenIds] if (metadata) { - assetsInfoAdapter.upsertMany( + fungibleTokensAdapter.upsertMany( state, metadata.map((token) => ({ ...token, @@ -82,14 +87,14 @@ const assetsSlice = createSlice({ ) } - state.loading = false + state.loadingUnverified = false }) .addCase(networkPresetSwitched, resetState) .addCase(customNetworkSettingsSaved, resetState) } }) -export default assetsSlice +export default fungibleTokensSlice // Reducers helper functions diff --git a/apps/desktop-wallet/src/storage/assets/nftsSlice.tsx b/apps/desktop-wallet/src/storage/assets/nftsSlice.tsx index 08a6b384c..cc9f02a70 100644 --- a/apps/desktop-wallet/src/storage/assets/nftsSlice.tsx +++ b/apps/desktop-wallet/src/storage/assets/nftsSlice.tsx @@ -23,9 +23,13 @@ import { syncUnknownTokensInfo } from '@/storage/assets/assetsActions' import { nftsAdapter } from '@/storage/assets/assetsAdapter' import { customNetworkSettingsSaved, networkPresetSwitched } from '@/storage/settings/networkActions' -type NFTsState = EntityState +interface NFTsState extends EntityState { + loading: boolean +} -const initialState: NFTsState = nftsAdapter.getInitialState() +const initialState: NFTsState = nftsAdapter.getInitialState({ + loading: false +}) const nftsSlice = createSlice({ name: 'nfts', @@ -33,10 +37,17 @@ const nftsSlice = createSlice({ reducers: {}, extraReducers(builder) { builder + .addCase(syncUnknownTokensInfo.pending, (state) => { + state.loading = true + }) .addCase(syncUnknownTokensInfo.fulfilled, (state, action) => { const nfts = action.payload.nfts nftsAdapter.upsertMany(state, nfts) + state.loading = false + }) + .addCase(syncUnknownTokensInfo.rejected, (state) => { + state.loading = false }) .addCase(networkPresetSwitched, resetState) .addCase(customNetworkSettingsSaved, resetState) diff --git a/apps/desktop-wallet/src/storage/assets/priceApiSlice.ts b/apps/desktop-wallet/src/storage/assets/priceApiSlice.ts deleted file mode 100644 index 268d05230..000000000 --- a/apps/desktop-wallet/src/storage/assets/priceApiSlice.ts +++ /dev/null @@ -1,71 +0,0 @@ -/* -Copyright 2018 - 2024 The Alephium Authors -This file is part of the alephium project. - -The library is free software: you can redistribute it and/or modify -it under the terms of the GNU Lesser General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -The library is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Lesser General Public License for more details. - -You should have received a copy of the GNU Lesser General Public License -along with the library. If not, see . -*/ - -import { CHART_DATE_FORMAT } from '@alephium/shared' -import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react' -import dayjs from 'dayjs' - -import { Currency } from '@/types/settings' - -type HistoricalPriceQueryParams = { - currency: Currency - days: number -} - -interface HistoricalPriceResult { - date: string // CHART_DATE_FORMAT - price: number -} - -export const priceApi = createApi({ - reducerPath: 'priceApi', - baseQuery: fetchBaseQuery({ baseUrl: 'https://api.coingecko.com/api/v3/' }), - endpoints: (builder) => ({ - getPrice: builder.query({ - query: (currency) => `/simple/price?ids=alephium&vs_currencies=${currency.toLowerCase()}`, - transformResponse: (response: { alephium: { [key: string]: string } }, meta, arg) => { - const currency = arg.toLowerCase() - const price = response.alephium[currency] - - return parseFloat(price) - } - }), - getHistoricalPrice: builder.query({ - query: ({ currency, days }) => `/coins/alephium/market_chart?vs_currency=${currency.toLowerCase()}&days=${days}`, - transformResponse: (response: { prices: number[][] }) => { - const { prices } = response - const today = dayjs().format(CHART_DATE_FORMAT) - - return prices.reduce((acc, [date, price]) => { - const itemDate = dayjs(date).format(CHART_DATE_FORMAT) - const isDuplicatedItem = !!acc.find(({ date }) => dayjs(date).format(CHART_DATE_FORMAT) === itemDate) - - if (!isDuplicatedItem && itemDate !== today) - acc.push({ - date: itemDate, - price - }) - - return acc - }, [] as HistoricalPriceResult[]) - } - }) - }) -}) - -export const { useGetPriceQuery, useGetHistoricalPriceQuery } = priceApi diff --git a/apps/desktop-wallet/src/storage/prices/pricesActions.ts b/apps/desktop-wallet/src/storage/prices/pricesActions.ts new file mode 100644 index 000000000..595ea9ce4 --- /dev/null +++ b/apps/desktop-wallet/src/storage/prices/pricesActions.ts @@ -0,0 +1,102 @@ +/* +Copyright 2018 - 2024 The Alephium Authors +This file is part of the alephium project. + +The library is free software: you can redistribute it and/or modify +it under the terms of the GNU Lesser General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +The library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public License +along with the library. If not, see . +*/ + +import { CHART_DATE_FORMAT, TOKENS_QUERY_LIMIT } from '@alephium/shared' +import { createAsyncThunk } from '@reduxjs/toolkit' +import dayjs from 'dayjs' +import { chunk } from 'lodash' + +import client from '@/api/client' +import { TokenHistoricalPrice, TokenPriceEntity, TokenPriceHistoryEntity } from '@/types/price' +import { isFulfilled } from '@/utils/misc' + +export const syncTokenCurrentPrices = createAsyncThunk( + 'assets/syncTokenCurrentPrices', + async ({ verifiedFungibleTokenSymbols, currency }: { verifiedFungibleTokenSymbols: string[]; currency: string }) => { + let tokenPrices = [] as TokenPriceEntity[] + + const promiseResults = await Promise.allSettled( + chunk(verifiedFungibleTokenSymbols, TOKENS_QUERY_LIMIT).map((verifiedFungibleTokenSymbolsChunk) => + client.explorer.market + .postMarketPrices( + { + currency: currency.toLowerCase() + }, + verifiedFungibleTokenSymbolsChunk + ) + .then((prices) => + prices.map((price, index) => ({ + symbol: verifiedFungibleTokenSymbolsChunk[index], + price + })) + ) + ) + ) + + tokenPrices = promiseResults.filter(isFulfilled).flatMap((r) => r.value) + + return tokenPrices + } +) + +export const syncTokenPriceHistories = createAsyncThunk( + 'assets/syncTokenPriceHistories', + async ({ verifiedFungibleTokenSymbols, currency }: { verifiedFungibleTokenSymbols: string[]; currency: string }) => { + let tokenPriceHistories = [] as TokenPriceHistoryEntity[] + + const promiseResults = await Promise.allSettled( + verifiedFungibleTokenSymbols.map((symbol) => + client.explorer.market + .getMarketPricesSymbolCharts(symbol, { + currency: currency.toLowerCase() + }) + .then((rawHistory) => { + const today = dayjs().format(CHART_DATE_FORMAT) + let history = [] as TokenHistoricalPrice[] + + if (rawHistory.timestamps && rawHistory.prices) { + const pricesHistoryArray = rawHistory.prices + + history = rawHistory.timestamps.reduce((acc, v, index) => { + const itemDate = dayjs(v).format(CHART_DATE_FORMAT) + const isDuplicatedItem = !!acc.find(({ date }) => dayjs(date).format(CHART_DATE_FORMAT) === itemDate) + + if (!isDuplicatedItem && itemDate !== today) + acc.push({ + date: itemDate, + value: pricesHistoryArray[index] + }) + + return acc + }, [] as TokenHistoricalPrice[]) + } + + return { + symbol, + history, + status: 'initialized' + } as TokenPriceHistoryEntity + }) + ) + ) + + tokenPriceHistories = promiseResults.filter(isFulfilled).flatMap((r) => r.value) + + return tokenPriceHistories + } +) diff --git a/apps/desktop-wallet/src/storage/prices/pricesAdapter.ts b/apps/desktop-wallet/src/storage/prices/pricesAdapter.ts new file mode 100644 index 000000000..ff432d294 --- /dev/null +++ b/apps/desktop-wallet/src/storage/prices/pricesAdapter.ts @@ -0,0 +1,31 @@ +/* +Copyright 2018 - 2024 The Alephium Authors +This file is part of the alephium project. + +The library is free software: you can redistribute it and/or modify +it under the terms of the GNU Lesser General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +The library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public License +along with the library. If not, see . +*/ + +import { createEntityAdapter } from '@reduxjs/toolkit' + +import { TokenPriceEntity, TokenPriceHistoryEntity } from '@/types/price' + +export const tokenPricesAdapter = createEntityAdapter({ + selectId: (token) => token.symbol, + sortComparer: (a, b) => a.symbol.localeCompare(b.symbol) +}) + +export const tokenPricesHistoryAdapter = createEntityAdapter({ + selectId: (token) => token.symbol, + sortComparer: (a, b) => a.symbol.localeCompare(b.symbol) +}) diff --git a/apps/desktop-wallet/src/storage/prices/pricesHistorySlice.ts b/apps/desktop-wallet/src/storage/prices/pricesHistorySlice.ts new file mode 100644 index 000000000..e6a3e5d4b --- /dev/null +++ b/apps/desktop-wallet/src/storage/prices/pricesHistorySlice.ts @@ -0,0 +1,71 @@ +/* +Copyright 2018 - 2024 The Alephium Authors +This file is part of the alephium project. + +The library is free software: you can redistribute it and/or modify +it under the terms of the GNU Lesser General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +The library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public License +along with the library. If not, see . +*/ + +import { createSlice, EntityState } from '@reduxjs/toolkit' + +import { syncTokenPriceHistories } from '@/storage/prices/pricesActions' +import { tokenPricesHistoryAdapter } from '@/storage/prices/pricesAdapter' +import { fiatCurrencyChanged } from '@/storage/settings/settingsActions' +import { TokenPriceHistoryEntity } from '@/types/price' + +interface PricesHistoryState extends EntityState { + loading: boolean +} + +const initialState: PricesHistoryState = tokenPricesHistoryAdapter.getInitialState({ + loading: false +}) + +const pricesHistorySlice = createSlice({ + name: 'tokenPricesHistory', + initialState, + reducers: {}, + extraReducers(builder) { + builder + .addCase(syncTokenPriceHistories.pending, (state) => { + state.loading = true + }) + .addCase(syncTokenPriceHistories.fulfilled, (state, action) => { + const tokenPriceHistories = action.payload + const verifiedFungibleTokenSymbols = action.meta.arg.verifiedFungibleTokenSymbols + + if (tokenPriceHistories) { + tokenPricesHistoryAdapter.upsertMany(state, tokenPriceHistories) + } + + tokenPricesHistoryAdapter.upsertMany( + state, + verifiedFungibleTokenSymbols.map((symbol) => ({ + symbol, + history: tokenPriceHistories.find((history) => history.symbol === symbol)?.history ?? [], + status: 'initialized' + })) + ) + + state.loading = false + }) + .addCase(syncTokenPriceHistories.rejected, (state) => { + state.loading = false + }) + .addCase(fiatCurrencyChanged, (state) => { + tokenPricesHistoryAdapter.removeAll(state) + }) + } +}) + +export default pricesHistorySlice diff --git a/apps/desktop-wallet/src/storage/prices/pricesSelectors.ts b/apps/desktop-wallet/src/storage/prices/pricesSelectors.ts new file mode 100644 index 000000000..05a1d7957 --- /dev/null +++ b/apps/desktop-wallet/src/storage/prices/pricesSelectors.ts @@ -0,0 +1,40 @@ +/* +Copyright 2018 - 2024 The Alephium Authors +This file is part of the alephium project. + +The library is free software: you can redistribute it and/or modify +it under the terms of the GNU Lesser General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +The library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public License +along with the library. If not, see . +*/ + +import { ALPH } from '@alephium/token-list' +import { createSelector } from '@reduxjs/toolkit' + +import { tokenPricesAdapter, tokenPricesHistoryAdapter } from '@/storage/prices/pricesAdapter' +import { RootState } from '@/storage/store' + +export const { selectAll: selectAllPrices, selectById: selectPriceById } = tokenPricesAdapter.getSelectors( + (state) => state.tokenPrices +) + +export const selectAlphPrice = createSelector( + (state: RootState) => state, + (state) => selectPriceById(state, ALPH.symbol)?.price +) + +export const { selectAll: selectAllPricesHistories, selectById: selectPriceHistoryById } = + tokenPricesHistoryAdapter.getSelectors((state) => state.tokenPricesHistory) + +export const selectAlphPriceHistory = createSelector( + (state: RootState) => state, + (state) => selectPriceHistoryById(state, ALPH.symbol)?.history +) diff --git a/apps/desktop-wallet/src/storage/prices/pricesSlice.ts b/apps/desktop-wallet/src/storage/prices/pricesSlice.ts new file mode 100644 index 000000000..b49e53ae8 --- /dev/null +++ b/apps/desktop-wallet/src/storage/prices/pricesSlice.ts @@ -0,0 +1,60 @@ +/* +Copyright 2018 - 2024 The Alephium Authors +This file is part of the alephium project. + +The library is free software: you can redistribute it and/or modify +it under the terms of the GNU Lesser General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +The library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public License +along with the library. If not, see . +*/ + +import { createSlice, EntityState } from '@reduxjs/toolkit' + +import { syncTokenCurrentPrices } from '@/storage/prices/pricesActions' +import { tokenPricesAdapter } from '@/storage/prices/pricesAdapter' +import { TokenPriceEntity } from '@/types/price' + +interface PricesState extends EntityState { + loading: boolean + status: 'uninitialized' | 'initialized' +} + +const initialState: PricesState = tokenPricesAdapter.getInitialState({ + loading: false, + status: 'uninitialized' +}) + +const pricesSlice = createSlice({ + name: 'tokenPrices', + initialState, + reducers: {}, + extraReducers(builder) { + builder + .addCase(syncTokenCurrentPrices.pending, (state) => { + state.loading = true + }) + .addCase(syncTokenCurrentPrices.fulfilled, (state, action) => { + const prices = action.payload + + if (prices) { + tokenPricesAdapter.upsertMany(state, prices) + } + + state.loading = false + state.status = 'initialized' + }) + .addCase(syncTokenCurrentPrices.rejected, (state) => { + state.loading = false + }) + } +}) + +export default pricesSlice diff --git a/apps/desktop-wallet/src/storage/store.ts b/apps/desktop-wallet/src/storage/store.ts index 8e91e6473..79575417d 100644 --- a/apps/desktop-wallet/src/storage/store.ts +++ b/apps/desktop-wallet/src/storage/store.ts @@ -21,11 +21,12 @@ import { setupListeners } from '@reduxjs/toolkit/dist/query' import addressesSlice from '@/storage/addresses/addressesSlice' import contactsSlice from '@/storage/addresses/contactsSlice' -import assetsInfoSlice from '@/storage/assets/assetsInfoSlice' +import fungibleTokensSlice from '@/storage/assets/fungibleTokensSlice' import nftsSlice from '@/storage/assets/nftsSlice' -import { priceApi } from '@/storage/assets/priceApiSlice' import globalSlice from '@/storage/global/globalSlice' import snackbarSlice from '@/storage/global/snackbarSlice' +import pricesHistorySlice from '@/storage/prices/pricesHistorySlice' +import pricesSlice from '@/storage/prices/pricesSlice' import networkSlice, { networkListenerMiddleware } from '@/storage/settings/networkSlice' import settingsSlice, { settingsListenerMiddleware } from '@/storage/settings/settingsSlice' import confirmedTransactionsSlice from '@/storage/transactions/confirmedTransactionsSlice' @@ -44,14 +45,14 @@ export const store = configureStore({ [addressesSlice.name]: addressesSlice.reducer, [confirmedTransactionsSlice.name]: confirmedTransactionsSlice.reducer, [pendingTransactionsSlice.name]: pendingTransactionsSlice.reducer, - [assetsInfoSlice.name]: assetsInfoSlice.reducer, + [fungibleTokensSlice.name]: fungibleTokensSlice.reducer, [snackbarSlice.name]: snackbarSlice.reducer, - [priceApi.reducerPath]: priceApi.reducer, + [pricesSlice.name]: pricesSlice.reducer, + [pricesHistorySlice.name]: pricesHistorySlice.reducer, [nftsSlice.name]: nftsSlice.reducer }, middleware: (getDefaultMiddleware) => getDefaultMiddleware() - .concat(priceApi.middleware) .concat(settingsListenerMiddleware.middleware) .concat(networkListenerMiddleware.middleware) .concat(pendingTransactionsListenerMiddleware.middleware) diff --git a/apps/desktop-wallet/src/types/addresses.ts b/apps/desktop-wallet/src/types/addresses.ts index 609ad95a2..bc514b6d2 100644 --- a/apps/desktop-wallet/src/types/addresses.ts +++ b/apps/desktop-wallet/src/types/addresses.ts @@ -59,8 +59,8 @@ export type Address = AddressBase & allTransactionPagesLoaded: boolean tokens: AddressTokenBalance[] lastUsed: TimeInMs - balanceHistory: EntityState - balanceHistoryInitialized: boolean + alphBalanceHistory: EntityState + alphBalanceHistoryInitialized: boolean } export type LoadingEnabled = boolean | undefined @@ -70,7 +70,7 @@ export type AddressDataSyncResult = AddressBalancesSyncResult & AddressTokensSyn export interface AddressesState extends EntityState
{ loadingBalances: boolean loadingTransactions: boolean - loadingTokens: boolean + loadingTokensBalances: boolean syncingAddressData: boolean isRestoringAddressesFromMetadata: boolean status: 'uninitialized' | 'initialized' diff --git a/apps/desktop-wallet/src/types/price.ts b/apps/desktop-wallet/src/types/price.ts new file mode 100644 index 000000000..b836c1c5a --- /dev/null +++ b/apps/desktop-wallet/src/types/price.ts @@ -0,0 +1,33 @@ +/* +Copyright 2018 - 2024 The Alephium Authors +This file is part of the alephium project. + +The library is free software: you can redistribute it and/or modify +it under the terms of the GNU Lesser General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +The library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public License +along with the library. If not, see . +*/ + +export interface TokenPriceEntity { + symbol: string + price: number +} + +export interface TokenHistoricalPrice { + date: string + value: number +} + +export interface TokenPriceHistoryEntity { + symbol: string + history: TokenHistoricalPrice[] + status: 'initialized' | 'uninitialized' +} diff --git a/apps/desktop-wallet/src/utils/addresses.ts b/apps/desktop-wallet/src/utils/addresses.ts index 608168829..6f41ca2ff 100644 --- a/apps/desktop-wallet/src/utils/addresses.ts +++ b/apps/desktop-wallet/src/utils/addresses.ts @@ -16,7 +16,7 @@ You should have received a copy of the GNU Lesser General Public License along with the library. If not, see . */ -import { AssetAmount, AssetInfo } from '@alephium/shared' +import { AssetAmount, FungibleToken } from '@alephium/shared' import { ALPH } from '@alephium/token-list' import { explorer } from '@alephium/web3' import { Dictionary } from '@reduxjs/toolkit' @@ -58,14 +58,14 @@ export const getInitialAddressSettings = (): AddressSettings => ({ color: getRandomLabelColor() }) -export const filterAddresses = (addresses: Address[], text: string, assetsInfo: Dictionary) => +export const filterAddresses = (addresses: Address[], text: string, fungibleTokens: Dictionary) => text.length < 2 ? addresses : addresses.filter((address) => { const addressTokenIds = address.tokens.filter((token) => token.balance !== '0').map((token) => token.tokenId) const addressTokenNames = addressTokenIds .map((tokenId) => { - const tokenInfo = assetsInfo[tokenId] + const tokenInfo = fungibleTokens[tokenId] return tokenInfo ? `${tokenInfo.name} ${tokenInfo.symbol}` : undefined }) .filter((searchableText) => searchableText !== undefined) diff --git a/apps/desktop-wallet/src/utils/misc.ts b/apps/desktop-wallet/src/utils/misc.ts index 9e4c7d857..55f62195d 100644 --- a/apps/desktop-wallet/src/utils/misc.ts +++ b/apps/desktop-wallet/src/utils/misc.ts @@ -87,3 +87,5 @@ export function removeItemFromArray(array: T[], index: number) { } export const cleanUrl = (url: string) => url.replace('https://', '') + +export const isFulfilled = (p: PromiseSettledResult): p is PromiseFulfilledResult => p.status === 'fulfilled' diff --git a/apps/desktop-wallet/src/utils/transactions.ts b/apps/desktop-wallet/src/utils/transactions.ts index 91d008512..6c06eb974 100644 --- a/apps/desktop-wallet/src/utils/transactions.ts +++ b/apps/desktop-wallet/src/utils/transactions.ts @@ -133,7 +133,7 @@ export const directionOptions: { export const getTransactionInfo = (tx: AddressTransaction, showInternalInflows?: boolean): TransactionInfo => { const state = store.getState() - const assetsInfo = state.assetsInfo.entities + const fungibleTokens = state.fungibleTokens.entities const addresses = Object.values(state.addresses.entities) as Address[] let amount: bigint | undefined = BigInt(0) @@ -186,7 +186,7 @@ export const getTransactionInfo = (tx: AddressTransaction, showInternalInflows?: lockTime = lockTime.toISOString() === new Date(0).toISOString() ? undefined : lockTime } - const tokenAssets = [...tokens.map((token) => ({ ...token, ...assetsInfo[token.id] }))] + const tokenAssets = [...tokens.map((token) => ({ ...token, ...fungibleTokens[token.id] }))] const assets = amount !== undefined ? [{ ...ALPH, amount }, ...tokenAssets] : tokenAssets return { diff --git a/apps/mobile-wallet/src/components/AssetAmountWithLogo.tsx b/apps/mobile-wallet/src/components/AssetAmountWithLogo.tsx index d91d7abb6..c2fef362f 100644 --- a/apps/mobile-wallet/src/components/AssetAmountWithLogo.tsx +++ b/apps/mobile-wallet/src/components/AssetAmountWithLogo.tsx @@ -23,7 +23,7 @@ import Amount, { AmountProps } from '~/components/Amount' import AssetLogo from '~/components/AssetLogo' import { NFTThumbnail } from '~/components/NFTsGrid' import { useAppSelector } from '~/hooks/redux' -import { selectAssetInfoById, selectNFTById } from '~/store/assets/assetsSelectors' +import { selectFungibleTokenById, selectNFTById } from '~/store/assets/assetsSelectors' interface AssetAmountWithLogoProps extends Pick { assetId: Asset['id'] @@ -38,7 +38,7 @@ const AssetAmountWithLogo = ({ useTinyAmountShorthand, fullPrecision }: AssetAmountWithLogoProps) => { - const asset = useAppSelector((s) => selectAssetInfoById(s, assetId)) + const asset = useAppSelector((s) => selectFungibleTokenById(s, assetId)) const nft = useAppSelector((s) => selectNFTById(s, assetId)) return asset ? ( diff --git a/apps/mobile-wallet/src/components/AssetLogo.tsx b/apps/mobile-wallet/src/components/AssetLogo.tsx index 8d7632a94..293aaa6ae 100644 --- a/apps/mobile-wallet/src/components/AssetLogo.tsx +++ b/apps/mobile-wallet/src/components/AssetLogo.tsx @@ -26,7 +26,7 @@ import styled, { css, useTheme } from 'styled-components/native' import AppText from '~/components/AppText' import { useAppSelector } from '~/hooks/redux' import AlephiumLogo from '~/images/logos/AlephiumLogo' -import { selectAssetInfoById, selectNFTById } from '~/store/assets/assetsSelectors' +import { selectFungibleTokenById, selectNFTById } from '~/store/assets/assetsSelectors' import { BORDER_RADIUS_SMALL } from '~/style/globalStyle' interface AssetLogoProps { @@ -37,7 +37,7 @@ interface AssetLogoProps { const AssetLogo = ({ assetId, size, style }: AssetLogoProps) => { const theme = useTheme() - const token = useAppSelector((state) => selectAssetInfoById(state, assetId)) + const token = useAppSelector((state) => selectFungibleTokenById(state, assetId)) const nft = useAppSelector((s) => selectNFTById(s, assetId)) const isNft = !!nft diff --git a/apps/mobile-wallet/src/store/appSlice.ts b/apps/mobile-wallet/src/store/appSlice.ts index b9c1c4bc9..1bee0dcd3 100644 --- a/apps/mobile-wallet/src/store/appSlice.ts +++ b/apps/mobile-wallet/src/store/appSlice.ts @@ -16,7 +16,7 @@ You should have received a copy of the GNU Lesser General Public License along with the library. If not, see . */ -import { AssetInfo } from '@alephium/shared' +import { FungibleToken } from '@alephium/shared' import { NavigationState } from '@react-navigation/routers' import { createAction, createSlice, PayloadAction } from '@reduxjs/toolkit' @@ -28,7 +28,7 @@ const sliceName = 'app' export interface AppMetadataState { lastNavigationState?: NavigationState isCameraOpen: boolean - checkedUnknownTokenIds: AssetInfo['id'][] + checkedUnknownTokenIds: FungibleToken['id'][] } const initialState: AppMetadataState = { diff --git a/apps/mobile-wallet/src/store/assets/assetsAdapter.ts b/apps/mobile-wallet/src/store/assets/assetsAdapter.ts index 691da2498..509d2d938 100644 --- a/apps/mobile-wallet/src/store/assets/assetsAdapter.ts +++ b/apps/mobile-wallet/src/store/assets/assetsAdapter.ts @@ -18,10 +18,10 @@ along with the library. If not, see . // TODO: Same as in desktop wallet -import { AssetInfo, NFT } from '@alephium/shared' +import { FungibleToken, NFT } from '@alephium/shared' import { createEntityAdapter } from '@reduxjs/toolkit' -export const fungibleTokensAdapter = createEntityAdapter({ +export const fungibleTokensAdapter = createEntityAdapter({ sortComparer: (a, b) => a.name.localeCompare(b.name) }) diff --git a/apps/mobile-wallet/src/store/assets/assetsSelectors.ts b/apps/mobile-wallet/src/store/assets/assetsSelectors.ts index d8fb36510..21cbe09ed 100644 --- a/apps/mobile-wallet/src/store/assets/assetsSelectors.ts +++ b/apps/mobile-wallet/src/store/assets/assetsSelectors.ts @@ -24,7 +24,7 @@ import { networkPresetSettings } from '~/persistent-storage/settings' import { fungibleTokensAdapter, nftsAdapter } from '~/store/assets/assetsAdapter' import { RootState } from '~/store/store' -export const { selectAll: selectAllFungibleTokens, selectById: selectAssetInfoById } = +export const { selectAll: selectAllFungibleTokens, selectById: selectFungibleTokenById } = fungibleTokensAdapter.getSelectors((state) => state.fungibleTokens) export const selectIsFungibleTokensMetadataUninitialized = createSelector( diff --git a/apps/mobile-wallet/src/store/assets/fungibleTokensSlice.ts b/apps/mobile-wallet/src/store/assets/fungibleTokensSlice.ts index 89bde64ab..14f3183ec 100644 --- a/apps/mobile-wallet/src/store/assets/fungibleTokensSlice.ts +++ b/apps/mobile-wallet/src/store/assets/fungibleTokensSlice.ts @@ -18,7 +18,7 @@ along with the library. If not, see . // TODO: Same as in desktop wallet -import { AssetInfo } from '@alephium/shared' +import { FungibleToken } from '@alephium/shared' import { ALPH } from '@alephium/token-list' import { createSlice, EntityState } from '@reduxjs/toolkit' @@ -26,7 +26,7 @@ import { loadingStarted, syncNetworkFungibleTokensInfo, syncUnknownTokensInfo } import { fungibleTokensAdapter } from '~/store/assets/assetsAdapter' import { customNetworkSettingsSaved, networkPresetSwitched } from '~/store/networkSlice' -interface FungibleTokensState extends EntityState { +interface FungibleTokensState extends EntityState { loading: boolean status: 'initialized' | 'uninitialized' } diff --git a/packages/shared/lib/transactions.ts b/packages/shared/lib/transactions.ts index 918ac91bd..13c685805 100644 --- a/packages/shared/lib/transactions.ts +++ b/packages/shared/lib/transactions.ts @@ -31,19 +31,18 @@ export type TokenDisplayBalances = Omit & { - verified?: boolean -} +export type FungibleToken = TokenInfo & { verified?: boolean } -export type Asset = TokenDisplayBalances & Optional +export type Asset = TokenDisplayBalances & Optional -export type VerifiedAsset = Required +export type AddressFungibleToken = FungibleToken & TokenDisplayBalances -export type UnverifiedAsset = Optional +export type VerifiedAddressFungibleToken = AddressFungibleToken & { verified: true } export type AssetAmount = { id: Asset['id']; amount?: bigint } -export type TransactionInfoAsset = Omit & Required +export type TransactionInfoAsset = Optional, 'decimals'> & + Required export type TransactionInfo = { assets: TransactionInfoAsset[]