diff --git a/src/components/HistoricWorthChart.tsx b/src/components/HistoricWorthChart.tsx index f46a7673b..ae5860d5e 100644 --- a/src/components/HistoricWorthChart.tsx +++ b/src/components/HistoricWorthChart.tsx @@ -26,10 +26,11 @@ import styled, { useTheme } from 'styled-components' import { useAppSelector } from '@/hooks/redux' import { makeSelectAddresses, + makeSelectAddressesKnownFungibleTokens, selectHaveHistoricBalancesLoaded, selectIsStateUninitialized } from '@/storage/addresses/addressesSelectors' -import { useGetHistoricalPriceQuery } from '@/storage/assets/priceApiSlice' +import { getTokensApiIds, useGetHistoricalPriceQuery } from '@/storage/assets/priceApiSlice' import { AddressHash } from '@/types/addresses' import { ChartLength, DataPoint, LatestAmountPerAddress } from '@/types/chart' import { Currency } from '@/types/settings' @@ -69,14 +70,26 @@ const HistoricWorthChart = memo(function HistoricWorthChart({ const haveHistoricBalancesLoaded = useAppSelector(selectHaveHistoricBalancesLoaded) const stateUninitialized = useAppSelector(selectIsStateUninitialized) - const { data: alphPriceHistory } = useGetHistoricalPriceQuery({ currency, days: 365 }) + const selectAddressesKnownFungibleTokens = useMemo(makeSelectAddressesKnownFungibleTokens, []) + const knownFungibleTokens = useAppSelector((s) => + selectAddressesKnownFungibleTokens( + s, + addresses.map((a) => a.hash) + ) + ) + + const { data: priceHistory } = useGetHistoricalPriceQuery({ + assetIds: ['alephium', ...getTokensApiIds(knownFungibleTokens)], + currency, + days: 365 + }) const theme = useTheme() const [chartData, setChartData] = useState([]) const startingDate = startingDates[length].format('YYYY-MM-DD') - const isDataAvailable = addresses.length !== 0 && haveHistoricBalancesLoaded && !!alphPriceHistory + const isDataAvailable = addresses.length !== 0 && haveHistoricBalancesLoaded && !!priceHistory const firstItem = chartData.at(0) useEffect(() => { @@ -92,7 +105,7 @@ const HistoricWorthChart = memo(function HistoricWorthChart({ const computeChartDataPoints = (): DataPoint[] => { const addressesLatestAmount: LatestAmountPerAddress = {} - const dataPoints = alphPriceHistory.map(({ date, price }) => { + const dataPoints = priceHistory.alephium.map(({ date, price }) => { let totalAmountPerDate = BigInt(0) addresses.forEach(({ hash, balanceHistory }) => { @@ -124,7 +137,7 @@ const HistoricWorthChart = memo(function HistoricWorthChart({ dataPoints = trimInitialZeroDataPoints(dataPoints) setChartData(getFilteredChartData(dataPoints, startingDate)) - }, [addresses, alphPriceHistory, isDataAvailable, latestWorth, startingDate]) + }, [addresses, priceHistory, isDataAvailable, latestWorth, startingDate]) if (!isDataAvailable || chartData.length < 2 || !firstItem || latestWorth === undefined) return null diff --git a/src/pages/UnlockedWallet/AddressesPage/AddressGridRow.tsx b/src/pages/UnlockedWallet/AddressesPage/AddressGridRow.tsx index 9c71141de..7a2c9aaf6 100644 --- a/src/pages/UnlockedWallet/AddressesPage/AddressGridRow.tsx +++ b/src/pages/UnlockedWallet/AddressesPage/AddressGridRow.tsx @@ -37,7 +37,7 @@ import { selectIsStateUninitialized } from '@/storage/addresses/addressesSelectors' import { selectIsTokensMetadataUninitialized } from '@/storage/assets/assetsSelectors' -import { useGetPriceQuery } from '@/storage/assets/priceApiSlice' +import { useGetPricesQuery } from '@/storage/assets/priceApiSlice' import { AddressHash } from '@/types/addresses' import { currencies } from '@/utils/currencies' import { onEnterOrSpace } from '@/utils/misc' @@ -57,7 +57,12 @@ const AddressGridRow = ({ addressHash, className }: AddressGridRowProps) => { const stateUninitialized = useAppSelector(selectIsStateUninitialized) const isTokensMetadataUninitialized = useAppSelector(selectIsTokensMetadataUninitialized) const fiatCurrency = useAppSelector((s) => s.settings.fiatCurrency) - const { data: price, isLoading: isPriceLoading } = useGetPriceQuery(currencies[fiatCurrency].ticker) + const { data: priceRes, isLoading: isPriceLoading } = useGetPricesQuery({ + assets: ['alephium'], + currency: currencies[fiatCurrency].ticker + }) + + const price = priceRes?.alephium const [isAddressDetailsModalOpen, setIsAddressDetailsModalOpen] = useState(false) diff --git a/src/pages/UnlockedWallet/OverviewPage/AddressesContactsList.tsx b/src/pages/UnlockedWallet/OverviewPage/AddressesContactsList.tsx index c230eff1f..ef21e5d85 100644 --- a/src/pages/UnlockedWallet/OverviewPage/AddressesContactsList.tsx +++ b/src/pages/UnlockedWallet/OverviewPage/AddressesContactsList.tsx @@ -36,7 +36,7 @@ import { useAppSelector } from '@/hooks/redux' import AddressDetailsModal from '@/modals/AddressDetailsModal' import ModalPortal from '@/modals/ModalPortal' import { selectAllAddresses, selectIsStateUninitialized } from '@/storage/addresses/addressesSelectors' -import { useGetPriceQuery } from '@/storage/assets/priceApiSlice' +import { useGetPricesQuery } from '@/storage/assets/priceApiSlice' import { Address } from '@/types/addresses' import { currencies } from '@/utils/currencies' @@ -78,11 +78,13 @@ const AddressesContactsList = ({ className, maxHeightInPx }: AddressesContactsLi const AddressesList = ({ className, isExpanded, onExpand, onAddressClick }: AddressListProps) => { const addresses = useAppSelector(selectAllAddresses) const fiatCurrency = useAppSelector((s) => s.settings.fiatCurrency) - const { data: price } = useGetPriceQuery(currencies[fiatCurrency].ticker) + const { data: priceRes } = useGetPricesQuery({ assets: ['alephium'], currency: currencies[fiatCurrency].ticker }) const stateUninitialized = useAppSelector(selectIsStateUninitialized) const [selectedAddress, setSelectedAddress] = useState
() + const price = priceRes?.alephium + const handleRowClick = (address: Address) => { onAddressClick() setSelectedAddress(address) diff --git a/src/pages/UnlockedWallet/OverviewPage/AmountsOverviewPanel.tsx b/src/pages/UnlockedWallet/OverviewPage/AmountsOverviewPanel.tsx index 0a50b0349..8512f1f43 100644 --- a/src/pages/UnlockedWallet/OverviewPage/AmountsOverviewPanel.tsx +++ b/src/pages/UnlockedWallet/OverviewPage/AmountsOverviewPanel.tsx @@ -33,10 +33,11 @@ import { UnlockedWalletPanel } from '@/pages/UnlockedWallet/UnlockedWalletLayout import { makeSelectAddresses, makeSelectAddressesHaveHistoricBalances, + makeSelectAddressesKnownFungibleTokens, selectAddressIds, selectIsStateUninitialized } from '@/storage/addresses/addressesSelectors' -import { useGetPriceQuery } from '@/storage/assets/priceApiSlice' +import { symbolCoinGeckoMapping, useGetPricesQuery } from '@/storage/assets/priceApiSlice' import { AddressHash } from '@/types/addresses' import { ChartLength, chartLengths, DataPoint } from '@/types/chart' import { getAvailableBalance } from '@/utils/addresses' @@ -67,12 +68,24 @@ const AmountsOverviewPanel: FC = ({ className, addres const isLoadingBalances = useAppSelector((s) => s.addresses.loadingBalances) const isBalancesInitialized = useAppSelector((s) => s.addresses.balancesStatus === 'initialized') + const selectAddressesKnownFungibleTokens = useMemo(makeSelectAddressesKnownFungibleTokens, []) + const knownFungibleTokens = useAppSelector((s) => selectAddressesKnownFungibleTokens(s, addressHashes)) + const knownFungibleTokenIds = knownFungibleTokens.flatMap((t) => (t.symbol ? symbolCoinGeckoMapping[t.symbol] : [])) + 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 { data: assetPrices, isLoading: arePricesLoading } = useGetPricesQuery( + { + assets: ['alephium', ...knownFungibleTokenIds], + currency: currencies[fiatCurrency].ticker + }, + { + pollingInterval: 60000 + } + ) + + const alphPrice = assetPrices?.alephium const [hoveredDataPoint, setHoveredDataPoint] = useState() const [chartLength, setChartLength] = useState('1m') @@ -83,7 +96,7 @@ const AmountsOverviewPanel: FC = ({ className, addres 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 totalAmountWorth = alphPrice !== undefined ? calculateAmountWorth(totalBalance, alphPrice) : undefined const balanceInFiat = worth ?? totalAmountWorth const isOnline = network.status === 'online' @@ -97,14 +110,14 @@ const AmountsOverviewPanel: FC = ({ className, addres {date ? dayjs(date).format('DD/MM/YYYY') : t('Value today')} - {isPriceLoading || showBalancesSkeletonLoader ? ( + {arePricesLoading || showBalancesSkeletonLoader ? ( ) : ( )} - {isPriceLoading || + {arePricesLoading || stateUninitialized || (hasHistoricBalances && worthInBeginningOfChart === undefined) ? ( @@ -116,7 +129,7 @@ const AmountsOverviewPanel: FC = ({ className, addres {chartLengths.map((length) => - isPriceLoading || stateUninitialized ? ( + arePricesLoading || stateUninitialized ? ( . */ -import { Asset } from '@alephium/sdk' +import { Asset, calculateAmountWorth } from '@alephium/sdk' import { motion } from 'framer-motion' import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -42,8 +42,10 @@ import { makeSelectAddressesNFTs, selectIsStateUninitialized } from '@/storage/addresses/addressesSelectors' +import { symbolCoinGeckoMapping, useGetPricesQuery } from '@/storage/assets/priceApiSlice' import { deviceBreakPoints } from '@/style/globalStyles' import { AddressHash } from '@/types/addresses' +import { currencies } from '@/utils/currencies' interface AssetsListProps { className?: string @@ -169,6 +171,19 @@ const TokenListRow = ({ asset, isExpanded }: TokenListRowProps) => { const { t } = useTranslation() const theme = useTheme() const stateUninitialized = useAppSelector(selectIsStateUninitialized) + const fiatCurrency = useAppSelector((s) => s.settings.fiatCurrency) + + const assetApiId = asset.symbol ? symbolCoinGeckoMapping[asset.symbol] : undefined + + const { data: priceRes } = useGetPricesQuery( + { assets: assetApiId ? [assetApiId] : [], currency: currencies[fiatCurrency].ticker }, + { + skip: !asset.symbol, + pollingInterval: 60000 + } + ) + + const price = priceRes && assetApiId ? priceRes[assetApiId] : NaN return ( @@ -211,6 +226,11 @@ const TokenListRow = ({ asset, isExpanded }: TokenListRowProps) => { )} {!asset.symbol && {t('Raw amount')}} + {price && !isNaN(price) ? ( + + + + ) : null} )} @@ -292,6 +312,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/src/pages/UnlockedWallet/OverviewPage/GreetingMessages.tsx b/src/pages/UnlockedWallet/OverviewPage/GreetingMessages.tsx index cbf5c7eb2..b88a039b4 100644 --- a/src/pages/UnlockedWallet/OverviewPage/GreetingMessages.tsx +++ b/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 { useGetPricesQuery } from '@/storage/assets/priceApiSlice' import { currencies } from '@/utils/currencies' interface GreetingMessagesProps { @@ -39,9 +39,17 @@ const GreetingMessages = ({ className }: GreetingMessagesProps) => { const activeWallet = useAppSelector((s) => s.activeWallet) const fiatCurrency = useAppSelector((s) => s.settings.fiatCurrency) - const { data: price, isLoading: isPriceLoading } = useGetPriceQuery(currencies[fiatCurrency].ticker, { - pollingInterval: 60000 - }) + const { data: priceRes, isLoading: isPriceLoading } = useGetPricesQuery( + { + assets: ['alephium'], + currency: currencies[fiatCurrency].ticker + }, + { + pollingInterval: 60000 + } + ) + + const price = priceRes?.alephium const [currentComponentIndex, setCurrentComponentIndex] = useState(0) const [lastClickTime, setLastChangeTime] = useState(Date.now()) diff --git a/src/storage/assets/priceApiSlice.ts b/src/storage/assets/priceApiSlice.ts index e4a50c9f2..a6e974b3e 100644 --- a/src/storage/assets/priceApiSlice.ts +++ b/src/storage/assets/priceApiSlice.ts @@ -16,56 +16,103 @@ You should have received a copy of the GNU Lesser General Public License along with the library. If not, see . */ -import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react' +import { Asset } from '@alephium/sdk' +import { createApi, fetchBaseQuery, FetchBaseQueryError } from '@reduxjs/toolkit/query/react' import dayjs from 'dayjs' +import { uniq } from 'lodash' import { Currency } from '@/types/settings' import { CHART_DATE_FORMAT } from '@/utils/constants' +export interface HistoricalPrice { + date: string // CHART_DATE_FORMAT + price: number +} + +interface MarketChartEnpointResult { + market_caps: [number, number][] // date, value + prices: [number, number][] + total_volumes: [number, number][] +} + +// TODO: EXPORT TO SHARED LIB +export type CoinGeckoID = 'alephium' | 'tether' | 'usdc' | 'dai' | 'ethereum' | 'wrapped-bitcoin' + +export const symbolCoinGeckoMapping: { [key: string]: CoinGeckoID } = { + ALPH: 'alephium', + USDT: 'tether', + USDC: 'usdc', + DAI: 'dai', + WETH: 'ethereum', + WBTC: 'wrapped-bitcoin' +} + +export const getTokensApiIds = (tokens: Asset[]) => + uniq(tokens.flatMap((t) => (t.symbol && symbolCoinGeckoMapping[t.symbol] ? symbolCoinGeckoMapping[t.symbol] : []))) + type HistoricalPriceQueryParams = { + assetIds: CoinGeckoID[] 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] + getPrices: builder.query< + { [id in CoinGeckoID]: number } | undefined, + { assets?: CoinGeckoID[]; currency: Currency } + >({ + query: ({ assets, currency }) => `/simple/price?ids=${assets?.join(',')}&vs_currencies=${currency.toLowerCase()}`, + transformResponse: (response: { [key in CoinGeckoID]: { [key: string]: string } }, meta, arg) => { + if (!arg.assets) return undefined - return parseFloat(price) + const currency = arg.currency.toLowerCase() + + return Object.entries(response).reduce( + (acc, [id, price]) => ({ ...acc, [id]: parseFloat(price[currency]) }), + {} as { [id in CoinGeckoID]: number } + ) } }), - getHistoricalPrice: builder.query({ - query: ({ currency, days }) => `/coins/alephium/market_chart?vs_currency=${currency.toLowerCase()}&days=${days}`, - transformResponse: (response: { prices: number[][] }) => { - const { prices } = response + getHistoricalPrice: builder.query<{ [id in CoinGeckoID]: HistoricalPrice[] }, HistoricalPriceQueryParams>({ + queryFn: async ({ assetIds, currency, days }, _queryApi, _extraOptions, fetchWithBQ) => { + const results = (await Promise.all( + assetIds.map((id) => + fetchWithBQ(`/coins/${id}/market_chart?vs_currency=${currency.toLowerCase()}&days=${days}`) + ) + )) as { data: MarketChartEnpointResult; error?: string }[] + 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) + const errors = results.filter((r) => !!r.error) + + if (errors.length > 0) return { error: { error: errors.join(', ') } as FetchBaseQueryError } + + return { + data: results.reduce( + (acc, { data: { prices } }, i) => ({ + ...acc, + [assetIds[i]]: 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 - }) + if (!isDuplicatedItem && itemDate !== today) + acc.push({ + date: itemDate, + price + }) - return acc - }, [] as HistoricalPriceResult[]) + return acc + }, [] as HistoricalPrice[]) + }), + {} as { [id in CoinGeckoID]: HistoricalPrice[] } + ) + } } }) }) }) -export const { useGetPriceQuery, useGetHistoricalPriceQuery } = priceApi +export const { useGetPricesQuery, useGetHistoricalPriceQuery } = priceApi