diff --git a/public/euroe.png b/public/euroe.png new file mode 100644 index 000000000..b478a0d38 Binary files /dev/null and b/public/euroe.png differ diff --git a/src/app/components/Account/index.tsx b/src/app/components/Account/index.tsx index 581834c96..bb87e8eab 100644 --- a/src/app/components/Account/index.tsx +++ b/src/app/components/Account/index.tsx @@ -1,12 +1,9 @@ import { FC } from 'react' import { useTranslation } from 'react-i18next' import { Link as RouterLink } from 'react-router-dom' -import Box from '@mui/material/Box' import { useScreenSize } from '../../hooks/useScreensize' -import { styled } from '@mui/material/styles' import { StyledDescriptionList, StyledListTitleWithAvatar } from '../../components/StyledDescriptionList' import { CopyToClipboard } from '../../components/CopyToClipboard' -import { CoinGeckoReferral } from '../../components/CoinGeckoReferral' import { TextSkeleton } from '../../components/Skeleton' import { EvmToken, type RuntimeAccount } from '../../../oasis-nexus/api' import { TokenPills } from './TokenPills' @@ -15,34 +12,29 @@ import { RouteUtils } from '../../utils/route-utils' import { accountTransactionsContainerId } from '../../pages/AccountDetailsPage/AccountTransactionsCard' import Link from '@mui/material/Link' import { DashboardLink } from '../../pages/ParatimeDashboardPage/DashboardLink' -import { getNameForTicker, Ticker } from '../../../types/ticker' -import { TokenPriceInfo } from '../../../coin-gecko/api' +import { getNameForTicker } from '../../../types/ticker' +import { AllTokenPrices } from '../../../coin-gecko/api' import { ContractCreatorInfo } from './ContractCreatorInfo' import { ContractVerificationIcon } from '../ContractVerificationIcon' import { TokenLink } from '../Tokens/TokenLink' -import BigNumber from 'bignumber.js' import { getPreciseNumberFormat } from '../../../locales/getPreciseNumberFormat' import { AccountAvatar } from '../AccountAvatar' - -export const FiatMoneyAmountBox = styled(Box)(() => ({ - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', - flex: 1, -})) +import { RuntimeBalanceDisplay } from '../Balance/RuntimeBalanceDisplay' +import { calculateFiatValue } from '../Balance/hooks' +import { FiatMoneyAmount } from '../Balance/FiatMoneyAmount' +import { getTokensForScope } from '../../../config' type AccountProps = { account?: RuntimeAccount token?: EvmToken isLoading: boolean - tokenPriceInfo: TokenPriceInfo + tokenPrices: AllTokenPrices showLayer?: boolean } -export const Account: FC = ({ account, token, isLoading, tokenPriceInfo, showLayer }) => { +export const Account: FC = ({ account, token, isLoading, tokenPrices, showLayer }) => { const { t } = useTranslation() const { isMobile } = useScreenSize() - const balance = account?.balances[0]?.balance const address = account ? account.address_eth ?? account.address : undefined const transactionsLabel = account ? account.stats.num_txns.toLocaleString() : '' @@ -53,15 +45,10 @@ export const Account: FC = ({ account, token, isLoading, tokenPric )}#${accountTransactionsContainerId}` : undefined - const nativeToken = account?.ticker || Ticker.ROSE - const nativeTickerName = getNameForTicker(t, nativeToken) - const { - isLoading: isPriceLoading, - price: tokenFiatValue, - isFree: isTokenFree, - hasUsedCoinGecko, - } = tokenPriceInfo + const nativeTokens = getTokensForScope(account || { network: 'mainnet', layer: 'sapphire' }) + const nativeTickerNames = nativeTokens.map(token => getNameForTicker(t, token.ticker)) const contract = account?.evm_contract + const fiatValueInfo = calculateFiatValue(account?.balances, tokenPrices) return ( <> @@ -120,9 +107,7 @@ export const Account: FC = ({ account, token, isLoading, tokenPric
{t('common.balance')}
- {balance === undefined - ? t('common.missing') - : t('common.valueInToken', { ...getPreciseNumberFormat(balance), ticker: nativeTickerName })} +
{t('common.tokens')}
@@ -130,21 +115,11 @@ export const Account: FC = ({ account, token, isLoading, tokenPric - {!isPriceLoading && !isTokenFree && tokenFiatValue !== undefined && balance && ( + {!fiatValueInfo.loading && fiatValueInfo.hasValue && ( <>
{t('common.fiatValue')}
- - {t('common.fiatValueInUSD', { - value: new BigNumber(balance).multipliedBy(tokenFiatValue).toFixed(), - formatParams: { - value: { - currency: 'USD', - } satisfies Intl.NumberFormatOptions, - }, - })} - {hasUsedCoinGecko && } - +
)} @@ -160,21 +135,25 @@ export const Account: FC = ({ account, token, isLoading, tokenPric )} -
{t('account.totalReceived')}
-
- {t('common.valueInToken', { - ...getPreciseNumberFormat(account.stats.total_received), - ticker: nativeTickerName, - })} -
+ {nativeTokens.length === 1 && ( + <> +
{t('account.totalReceived')}
+
+ {t('common.valueInToken', { + ...getPreciseNumberFormat(account.stats.total_received), + ticker: nativeTickerNames[0], + })} +
-
{t('account.totalSent')}
-
- {t('common.valueInToken', { - ...getPreciseNumberFormat(account.stats.total_sent), - ticker: nativeTickerName, - })} -
+
{t('account.totalSent')}
+
+ {t('common.valueInToken', { + ...getPreciseNumberFormat(account.stats.total_sent), + ticker: nativeTickerNames[0], + })} +
+ + )} )} diff --git a/src/app/components/Balance/FiatMoneyAmount.tsx b/src/app/components/Balance/FiatMoneyAmount.tsx new file mode 100644 index 000000000..88a4cec51 --- /dev/null +++ b/src/app/components/Balance/FiatMoneyAmount.tsx @@ -0,0 +1,45 @@ +import { styled } from '@mui/material/styles' +import WarningIcon from '@mui/icons-material/WarningAmber' +import Box from '@mui/material/Box' +import { useTranslation } from 'react-i18next' +import { FC } from 'react' +import { CoinGeckoReferral } from '../CoinGeckoReferral' +import { FiatValueInfo } from './hooks' +import Tooltip from '@mui/material/Tooltip' +import Skeleton from '@mui/material/Skeleton' + +export const FiatMoneyAmountBox = styled(Box)(() => ({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: 4, + flex: 1, +})) + +export const FiatMoneyAmount: FC = ({ value, hasUsedCoinGecko, unknownTickers, loading }) => { + const { t } = useTranslation() + return ( + + + {t('common.fiatValueInUSD', { + value, + formatParams: { + value: { + currency: 'USD', // TODO: why are we fixated on USD? + } satisfies Intl.NumberFormatOptions, + }, + })} + {!!unknownTickers.length && ( + + + + )} + {loading && } + + {hasUsedCoinGecko && } + + ) +} diff --git a/src/app/components/Balance/RuntimeBalanceDisplay.tsx b/src/app/components/Balance/RuntimeBalanceDisplay.tsx new file mode 100644 index 000000000..0b3f70599 --- /dev/null +++ b/src/app/components/Balance/RuntimeBalanceDisplay.tsx @@ -0,0 +1,25 @@ +import { FC } from 'react' +import { RuntimeSdkBalance } from '../../../oasis-nexus/api' +import { useTranslation } from 'react-i18next' +import { getPreciseNumberFormat } from '../../../locales/getPreciseNumberFormat' + +export const RuntimeBalanceDisplay: FC<{ balances: RuntimeSdkBalance[] | undefined }> = ({ + balances = [], +}) => { + const { t } = useTranslation() + if (balances.length === 0 || balances[0].balance === undefined) { + return t('common.missing') + } + return ( + <> + {balances.map(balance => ( + + {t('common.valueInToken', { + ...getPreciseNumberFormat(balance.balance), + ticker: balance.token_symbol, + })} + + ))} + + ) +} diff --git a/src/app/components/Balance/hooks.ts b/src/app/components/Balance/hooks.ts new file mode 100644 index 000000000..1fa7ca2e8 --- /dev/null +++ b/src/app/components/Balance/hooks.ts @@ -0,0 +1,76 @@ +import { RuntimeSdkBalance } from '../../../oasis-nexus/api' +import { AllTokenPrices } from '../../../coin-gecko/api' +import BigNumber from 'bignumber.js' +import { Ticker } from '../../../types/ticker' + +export const hasRuntimeBalance = (balances: RuntimeSdkBalance[] = []) => + balances.some(balance => balance.token_decimals) + +export type FiatValueInfo = { + /** + * Do we have any known real value? + */ + hasValue: boolean + + /** + * The fiat value, to the best of our information + */ + value?: string + + /** + * Have we used CoinGecko for calculating this value? + */ + hasUsedCoinGecko: boolean + + /** + * Is the value of one of the tokens still being loaded? + */ + loading: boolean + + /** + * Any tokens for which we don't know the value? + */ + unknownTickers: Ticker[] +} + +export const calculateFiatValue = ( + balances: RuntimeSdkBalance[] = [], + tokenPrices: AllTokenPrices, +): FiatValueInfo => { + let hasValue = false + let value = new BigNumber(0) + let hasUsedCoinGecko = false + let loading = false + const unknown: Ticker[] = [] + + balances.forEach(balance => { + const priceInfo = tokenPrices[balance.token_symbol as Ticker] + if (priceInfo) { + if (priceInfo.isLoading) { + loading = true + } else { + hasUsedCoinGecko = hasUsedCoinGecko || priceInfo.hasUsedCoinGecko + if (!priceInfo.isFree) { + const tokenFiatValue = priceInfo.price + hasValue = true + if (tokenFiatValue === undefined) { + unknown.push(balance.token_symbol as Ticker) + } else { + value = value.plus(new BigNumber(balance.balance).multipliedBy(tokenFiatValue)) + } + } + } + } else { + unknown.push(balance.token_symbol as Ticker) + hasValue = true + } + }) + + return { + hasValue, + hasUsedCoinGecko, + loading, + unknownTickers: unknown, + value: value.toFixed(), + } +} diff --git a/src/app/components/logo/SmallLogo.tsx b/src/app/components/logo/SmallOasisLogo.tsx similarity index 64% rename from src/app/components/logo/SmallLogo.tsx rename to src/app/components/logo/SmallOasisLogo.tsx index 22fe2915d..38cfb7e94 100644 --- a/src/app/components/logo/SmallLogo.tsx +++ b/src/app/components/logo/SmallOasisLogo.tsx @@ -1,6 +1,6 @@ import { FC } from 'react' import logo from '../../../../public/logo512.png' -export const SmallLogo: FC<{ size?: number }> = ({ size = 25 }) => ( +export const SmallOasisLogo: FC<{ size?: number }> = ({ size = 25 }) => ( ) diff --git a/src/app/components/logo/SmallTokenLogo.tsx b/src/app/components/logo/SmallTokenLogo.tsx new file mode 100644 index 000000000..43a1add08 --- /dev/null +++ b/src/app/components/logo/SmallTokenLogo.tsx @@ -0,0 +1,15 @@ +import { FC } from 'react' +import { Ticker } from '../../../types/ticker' +import { SmallOasisLogo } from './SmallOasisLogo' +import euroELogo from '../../../../public/euroe.png' + +export const SmallTokenLogo: FC<{ ticker: Ticker }> = ({ ticker }) => { + switch (ticker) { + case 'ROSE': + return + case 'EUROe': + return {ticker} + default: + return null + } +} diff --git a/src/app/pages/AccountDetailsPage/AccountDetailsCard.tsx b/src/app/pages/AccountDetailsPage/AccountDetailsCard.tsx index a5611f4b4..23bfe2975 100644 --- a/src/app/pages/AccountDetailsPage/AccountDetailsCard.tsx +++ b/src/app/pages/AccountDetailsPage/AccountDetailsCard.tsx @@ -3,7 +3,7 @@ import { SubPageCard } from '../../components/SubPageCard' import { useTranslation } from 'react-i18next' import { EvmToken, RuntimeAccount } from '../../../oasis-nexus/api' import { AccountDetailsView } from './AccountDetailsView' -import { TokenPriceInfo } from '../../../coin-gecko/api' +import { AllTokenPrices } from '../../../coin-gecko/api' type AccountDetailsProps = { isLoading: boolean @@ -11,7 +11,7 @@ type AccountDetailsProps = { isContract: boolean account: RuntimeAccount | undefined token: EvmToken | undefined - tokenPriceInfo: TokenPriceInfo + tokenPrices: AllTokenPrices } export const AccountDetailsCard: FC = ({ @@ -20,7 +20,7 @@ export const AccountDetailsCard: FC = ({ isContract, account, token, - tokenPriceInfo, + tokenPrices, }) => { const { t } = useTranslation() return ( @@ -34,7 +34,7 @@ export const AccountDetailsCard: FC = ({ isError={isError} account={account} token={token} - tokenPriceInfo={tokenPriceInfo} + tokenPrices={tokenPrices} /> ) diff --git a/src/app/pages/AccountDetailsPage/AccountDetailsView.tsx b/src/app/pages/AccountDetailsPage/AccountDetailsView.tsx index 4cfabbc13..0c9f70225 100644 --- a/src/app/pages/AccountDetailsPage/AccountDetailsView.tsx +++ b/src/app/pages/AccountDetailsPage/AccountDetailsView.tsx @@ -1,7 +1,7 @@ import { FC } from 'react' import { EvmToken, RuntimeAccount } from '../../../oasis-nexus/api' import { useTranslation } from 'react-i18next' -import { TokenPriceInfo } from '../../../coin-gecko/api' +import { AllTokenPrices } from '../../../coin-gecko/api' import { CardEmptyState } from '../../components/CardEmptyState' import { Account } from '../../components/Account' @@ -10,9 +10,9 @@ export const AccountDetailsView: FC<{ isError: boolean account: RuntimeAccount | undefined token?: EvmToken - tokenPriceInfo: TokenPriceInfo + tokenPrices: AllTokenPrices showLayer?: boolean -}> = ({ isLoading, isError, account, token, tokenPriceInfo, showLayer }) => { +}> = ({ isLoading, isError, account, token, tokenPrices, showLayer }) => { const { t } = useTranslation() return isError ? ( @@ -21,7 +21,7 @@ export const AccountDetailsView: FC<{ account={account} token={token} isLoading={isLoading} - tokenPriceInfo={tokenPriceInfo} + tokenPrices={tokenPrices} showLayer={showLayer} /> ) diff --git a/src/app/pages/AccountDetailsPage/index.tsx b/src/app/pages/AccountDetailsPage/index.tsx index 098654a92..62b1ba7e7 100644 --- a/src/app/pages/AccountDetailsPage/index.tsx +++ b/src/app/pages/AccountDetailsPage/index.tsx @@ -3,8 +3,7 @@ import { useTranslation } from 'react-i18next' import { useHref, useLoaderData, useOutletContext } from 'react-router-dom' import { PageLayout } from '../../components/PageLayout' import { RouterTabs } from '../../components/RouterTabs' -import { useTokenPrice } from '../../../coin-gecko/api' -import { Ticker } from '../../../types/ticker' +import { useAllTokenPrices } from '../../../coin-gecko/api' import { EvmTokenType, RuntimeAccount } from '../../../oasis-nexus/api' import { accountTokenContainerId } from './AccountTokensCard' @@ -37,7 +36,7 @@ export const AccountDetailsPage: FC = () => { const isContract = !!account?.evm_contract const { token, isLoading: isTokenLoading } = useTokenInfo(scope, address, isContract) - const tokenPriceInfo = useTokenPrice(account?.ticker || Ticker.ROSE) + const tokenPrices = useAllTokenPrices() const { isLoading: areEventsLoading, isError: isEventsError, events } = useAccountEvents(scope, address) @@ -61,7 +60,7 @@ export const AccountDetailsPage: FC = () => { isContract={isContract} account={account} token={token} - tokenPriceInfo={tokenPriceInfo} + tokenPrices={tokenPrices} /> = ({ scope }) => { const defaultChartDurationValue = useConstant(() => ChartDuration.TODAY) const [chartDuration, setChartDuration] = useState(defaultChartDurationValue) const paratime = getLayerLabels(t)[scope.layer] - const ticker = getTickerForScope(scope) + const tokens = getTokensForScope(scope) + const mainToken = tokens[0] + const mainTicker = mainToken.ticker + const faucetLink = getFaucetLink(scope.network, scope.layer, mainTicker) const handleDurationSelectedChange = (duration: ChartDuration | null) => { if (!duration) { return @@ -29,8 +31,6 @@ export const ParaTimeSnapshot: FC<{ scope: SearchScope }> = ({ scope }) => { setChartDuration(duration) } - const faucetLink = getFaucetLink(scope.network, scope.layer, ticker) - return ( <> = ({ scope }) => { - {ticker === Ticker.ROSE && } - {faucetLink && } + {!mainToken.free && } + {faucetLink && } diff --git a/src/app/pages/ParatimeDashboardPage/TestnetFaucet.tsx b/src/app/pages/ParatimeDashboardPage/TestnetFaucet.tsx index c1f6341bc..b9f2a4832 100644 --- a/src/app/pages/ParatimeDashboardPage/TestnetFaucet.tsx +++ b/src/app/pages/ParatimeDashboardPage/TestnetFaucet.tsx @@ -3,13 +3,13 @@ import { useTranslation } from 'react-i18next' import { SnapshotCardExternalLink } from '../../components/Snapshots/SnapshotCardExternalLink' import { getFaucetLink } from '../../utils/faucet-links' import { Layer } from '../../../oasis-nexus/api' -import { NativeTicker } from '../../../types/ticker' +import { Ticker } from '../../../types/ticker' import { Network } from '../../../types/network' type TestnetFaucetProps = { network: Network layer: Layer - ticker: NativeTicker + ticker: Ticker } export const TestnetFaucet: FC = ({ network, layer, ticker }) => { diff --git a/src/app/pages/ParatimeDashboardPage/RosePriceCard.tsx b/src/app/pages/ParatimeDashboardPage/TokenPriceCard.tsx similarity index 68% rename from src/app/pages/ParatimeDashboardPage/RosePriceCard.tsx rename to src/app/pages/ParatimeDashboardPage/TokenPriceCard.tsx index e5a1ccc3a..4fba81f84 100644 --- a/src/app/pages/ParatimeDashboardPage/RosePriceCard.tsx +++ b/src/app/pages/ParatimeDashboardPage/TokenPriceCard.tsx @@ -4,10 +4,11 @@ import Box from '@mui/material/Box' import { styled } from '@mui/material/styles' import { CoinGeckoReferral } from '../../components/CoinGeckoReferral' import { SnapshotCard } from '../../components/Snapshots/SnapshotCard' -import { useGetRosePrice } from '../../../coin-gecko/api' +import { useTokenPrice } from '../../../coin-gecko/api' import { COLORS } from '../../../styles/theme/colors' import Typography from '@mui/material/Typography' -import { SmallLogo } from '../../components/logo/SmallLogo' +import { NativeTokenInfo } from '../../../types/ticker' +import { SmallTokenLogo } from '../../components/logo/SmallTokenLogo' const StyledBox = styled(Box)(({ theme }) => ({ position: 'absolute', @@ -15,20 +16,20 @@ const StyledBox = styled(Box)(({ theme }) => ({ left: theme.spacing(4), })) -const formatFiatRoseParams = { +const formatFiatParams = { value: { - currency: 'USD', + currency: 'USD', // TODO: why are we fixated on USD maximumFractionDigits: 5, } satisfies Intl.NumberFormatOptions, } -export const RosePriceCard: FC = () => { +export const TokenPriceCard: FC<{ token: NativeTokenInfo }> = ({ token }) => { const { t } = useTranslation() - const rosePriceQuery = useGetRosePrice() - const priceString = rosePriceQuery.data + const priceQuery = useTokenPrice(token.ticker) + const priceString = priceQuery.price ? t('common.fiatValueInUSD', { - value: rosePriceQuery.data, - formatParams: formatFiatRoseParams, + value: priceQuery.price, + formatParams: formatFiatParams, }) : '' @@ -36,8 +37,8 @@ export const RosePriceCard: FC = () => { - - {t('rosePrice.header')} + + {t('tokenPrice.header', { ticker: token.ticker })} } > diff --git a/src/app/pages/RuntimeTransactionDetailPage/CurrentFiatValue.tsx b/src/app/pages/RuntimeTransactionDetailPage/CurrentFiatValue.tsx index 15f4e8ecb..0ab93035c 100644 --- a/src/app/pages/RuntimeTransactionDetailPage/CurrentFiatValue.tsx +++ b/src/app/pages/RuntimeTransactionDetailPage/CurrentFiatValue.tsx @@ -1,6 +1,6 @@ import { FC } from 'react' import { useTranslation } from 'react-i18next' -import { FiatMoneyAmountBox } from '../../components/Account' +import { FiatMoneyAmountBox } from '../../components/Balance/FiatMoneyAmount' import Box from '@mui/material/Box' import Tooltip from '@mui/material/Tooltip' import { CoinGeckoReferral } from '../../components/CoinGeckoReferral' @@ -21,7 +21,7 @@ export const CurrentFiatValue: FC = ({ amount, price, has value: new BigNumber(amount).multipliedBy(price).toFixed(), formatParams: { value: { - currency: 'USD', + currency: 'USD', // TODO: why are we fixated on USD } satisfies Intl.NumberFormatOptions, }, })} diff --git a/src/app/pages/RuntimeTransactionDetailPage/index.tsx b/src/app/pages/RuntimeTransactionDetailPage/index.tsx index 5687cd23c..164ba0804 100644 --- a/src/app/pages/RuntimeTransactionDetailPage/index.tsx +++ b/src/app/pages/RuntimeTransactionDetailPage/index.tsx @@ -27,8 +27,7 @@ import { TransactionEvents } from '../../components/Transactions/TransactionEven import { useRequiredScopeParam } from '../../hooks/useScopeParam' import { DashboardLink } from '../ParatimeDashboardPage/DashboardLink' import { getNameForTicker, Ticker } from '../../../types/ticker' -import { getTickerForScope } from '../../../config' -import { TokenPriceInfo, useTokenPrice } from '../../../coin-gecko/api' +import { AllTokenPrices, useAllTokenPrices } from '../../../coin-gecko/api' import { CurrentFiatValue } from './CurrentFiatValue' import { AddressSwitch, AddressSwitchOption } from '../../components/AddressSwitch' import InfoIcon from '@mui/icons-material/Info' @@ -103,7 +102,7 @@ export const RuntimeTransactionDetailPage: FC = () => { data?.data, ) - const tokenPriceInfo = useTokenPrice(getTickerForScope(scope)) + const tokenPrices = useAllTokenPrices() if (!transaction && !isLoading) { throw AppErrors.NotFoundTxHash @@ -126,7 +125,7 @@ export const RuntimeTransactionDetailPage: FC = () => { @@ -167,14 +166,14 @@ export const RuntimeTransactionDetailView: FC<{ transaction: TransactionDetailRuntimeBlock | undefined showLayer?: boolean standalone?: boolean - tokenPriceInfo: TokenPriceInfo + tokenPrices: AllTokenPrices addressSwitchOption?: AddressSwitchOption }> = ({ isLoading, transaction, showLayer, standalone = false, - tokenPriceInfo, + tokenPrices, addressSwitchOption = AddressSwitchOption.ETH, }) => { const { t } = useTranslation() @@ -189,6 +188,7 @@ export const RuntimeTransactionDetailView: FC<{ const ticker = transaction?.ticker || Ticker.ROSE const tickerName = getNameForTicker(t, ticker) + const tokenPriceInfo = tokenPrices[ticker] return ( <> diff --git a/src/app/pages/RuntimeTransactionsPage/index.tsx b/src/app/pages/RuntimeTransactionsPage/index.tsx index 6b9ab1666..fa8104b5f 100644 --- a/src/app/pages/RuntimeTransactionsPage/index.tsx +++ b/src/app/pages/RuntimeTransactionsPage/index.tsx @@ -14,8 +14,7 @@ import { LoadMoreButton } from '../../components/LoadMoreButton' import { TableLayout, TableLayoutButton } from '../../components/TableLayoutButton' import { RuntimeTransactionDetailView } from '../RuntimeTransactionDetailPage' import { useRequiredScopeParam } from '../../hooks/useScopeParam' -import { useTokenPrice } from '../../../coin-gecko/api' -import { getTickerForScope } from '../../../config' +import { useAllTokenPrices } from '../../../coin-gecko/api' import { VerticalList } from '../../components/VerticalList' const limit = NUMBER_OF_ITEMS_ON_SEPARATE_PAGE @@ -35,7 +34,7 @@ export const RuntimeTransactionsPage: FC = () => { // we should call useGetConsensusTransactions() } - const tokenPriceInfo = useTokenPrice(getTickerForScope(scope)) + const tokenPrices = useAllTokenPrices() useEffect(() => { if (!isMobile) { @@ -117,7 +116,7 @@ export const RuntimeTransactionsPage: FC = () => { key={key} isLoading={true} transaction={undefined} - tokenPriceInfo={tokenPriceInfo} + tokenPrices={tokenPrices} standalone /> ))} @@ -127,7 +126,7 @@ export const RuntimeTransactionsPage: FC = () => { ))} diff --git a/src/app/pages/SearchResultsPage/SearchResultsList.tsx b/src/app/pages/SearchResultsPage/SearchResultsList.tsx index 2da0f092b..366259b74 100644 --- a/src/app/pages/SearchResultsPage/SearchResultsList.tsx +++ b/src/app/pages/SearchResultsPage/SearchResultsList.tsx @@ -21,7 +21,6 @@ import { AllTokenPrices } from '../../../coin-gecko/api' import { ResultListFrame } from './ResultListFrame' import { TokenDetails } from '../../components/Tokens/TokenDetails' import { ProposalDetailView } from '../ProposalDetailsPage' -import { getTickerForScope } from '../../../config' /** * Component for displaying a list of search results @@ -71,7 +70,7 @@ export const SearchResultsList: FC<{ )} @@ -87,7 +86,7 @@ export const SearchResultsList: FC<{ isLoading={false} isError={false} account={item} - tokenPriceInfo={tokenPrices[getTickerForScope(item)]} + tokenPrices={tokenPrices} showLayer={true} /> )} @@ -103,7 +102,7 @@ export const SearchResultsList: FC<{ isLoading={false} isError={false} account={item} - tokenPriceInfo={tokenPrices[getTickerForScope(item)]} + tokenPrices={tokenPrices} showLayer={true} /> )} diff --git a/src/app/pages/SearchResultsPage/__tests__/SearchResultsList.test.tsx b/src/app/pages/SearchResultsPage/__tests__/SearchResultsList.test.tsx index cf9ef0771..2805c549f 100644 --- a/src/app/pages/SearchResultsPage/__tests__/SearchResultsList.test.tsx +++ b/src/app/pages/SearchResultsPage/__tests__/SearchResultsList.test.tsx @@ -36,6 +36,12 @@ describe('SearchResultsView', () => { isFree: true, hasUsedCoinGecko: false, }, + [Ticker.EUROe]: { + isLoading: false, + isFree: false, + price: 1, + hasUsedCoinGecko: true, + }, }} title="test search" networkForTheme={Network.mainnet} @@ -74,6 +80,12 @@ describe('SearchResultsView', () => { isFree: true, hasUsedCoinGecko: false, }, + [Ticker.EUROe]: { + isLoading: false, + isFree: false, + price: 1, + hasUsedCoinGecko: true, + }, }} />, ) diff --git a/src/app/pages/TokenDashboardPage/TokenDetailsCard.tsx b/src/app/pages/TokenDashboardPage/TokenDetailsCard.tsx index 84262cbc3..add80eb64 100644 --- a/src/app/pages/TokenDashboardPage/TokenDetailsCard.tsx +++ b/src/app/pages/TokenDashboardPage/TokenDetailsCard.tsx @@ -11,17 +11,16 @@ import { useTranslation } from 'react-i18next' import { AccountLink } from '../../components/Account/AccountLink' import { CopyToClipboard } from '../../components/CopyToClipboard' import { VerificationIcon } from '../../components/ContractVerificationIcon' -import { getNameForTicker, Ticker } from '../../../types/ticker' import { DelayedContractCreatorInfo } from '../../components/Account/ContractCreatorInfo' import CardContent from '@mui/material/CardContent' import { TokenTypeTag } from '../../components/Tokens/TokenList' import { SearchScope } from '../../../types/searchScope' -import { getPreciseNumberFormat } from '../../../locales/getPreciseNumberFormat' import { RouteUtils } from '../../utils/route-utils' import { tokenTransfersContainerId } from '../../pages/TokenDashboardPage/TokenTransfersCard' import { tokenHoldersContainerId } from '../../pages/TokenDashboardPage/TokenHoldersCard' import { RoundedBalance } from 'app/components/RoundedBalance' import { HighlightedText } from '../../components/HighlightedText' +import { RuntimeBalanceDisplay } from '../../components/Balance/RuntimeBalanceDisplay' export const TokenDetailsCard: FC<{ scope: SearchScope; address: string; searchTerm: string }> = ({ scope, @@ -35,10 +34,6 @@ export const TokenDetailsCard: FC<{ scope: SearchScope; address: string; searchT const { account, isLoading: accountIsLoading } = useAccount(scope, address) const isLoading = tokenIsLoading || accountIsLoading - const balance = account?.balances[0]?.balance - const nativeToken = account?.ticker || Ticker.ROSE - const tickerName = getNameForTicker(t, nativeToken) - return ( @@ -79,9 +74,7 @@ export const TokenDetailsCard: FC<{ scope: SearchScope; address: string; searchT
{t('common.balance')}
- {balance === undefined - ? t('common.missing') - : t('common.valueInToken', { ...getPreciseNumberFormat(balance), ticker: tickerName })} +
{t('tokens.totalSupply')}
diff --git a/src/app/utils/faucet-links.ts b/src/app/utils/faucet-links.ts index a361286c9..1d473e68a 100644 --- a/src/app/utils/faucet-links.ts +++ b/src/app/utils/faucet-links.ts @@ -1,10 +1,10 @@ -import { NativeTicker, Ticker } from 'types/ticker' +import { Ticker } from 'types/ticker' import { Network } from '../../types/network' import { Layer } from '../../oasis-nexus/api' const testnetFaucetUrl = 'https://faucet.testnet.oasis.dev/' const faucetParaTimeBaseUrl = `${testnetFaucetUrl}?paratime=` -const faucetLinks: Partial>>>>> = { +const faucetLinks: Partial>>>>> = { [Network.testnet]: { [Layer.consensus]: { [Ticker.TEST]: testnetFaucetUrl }, [Layer.emerald]: { [Ticker.TEST]: `${faucetParaTimeBaseUrl}emerald` }, @@ -14,5 +14,5 @@ const faucetLinks: Partial +export const getFaucetLink = (network: Network, layer: Layer, ticker: Ticker) => faucetLinks[network]?.[layer]?.[ticker] diff --git a/src/app/utils/test-fixtures.ts b/src/app/utils/test-fixtures.ts index 93014f3af..1d7489cdb 100644 --- a/src/app/utils/test-fixtures.ts +++ b/src/app/utils/test-fixtures.ts @@ -1,5 +1,4 @@ import { EvmTokenType, groupAccountTokenBalances, Layer, RuntimeAccount } from '../../oasis-nexus/api' -import { Ticker } from '../../types/ticker' import { Network } from '../../types/network' import { AccountResult, BlockResult } from '../pages/SearchResultsPage/hooks' @@ -63,7 +62,6 @@ export const suggestedParsedAccount: RuntimeAccount = groupAccountTokenBalances( }, layer: Layer.emerald, network: Network.mainnet, - ticker: Ticker.ROSE, }) export const suggestedEmptyAccount: RuntimeAccount = groupAccountTokenBalances({ @@ -79,7 +77,6 @@ export const suggestedEmptyAccount: RuntimeAccount = groupAccountTokenBalances({ }, layer: Layer.emerald, network: Network.mainnet, - ticker: Ticker.ROSE, evm_contract: undefined, }) diff --git a/src/coin-gecko/api.ts b/src/coin-gecko/api.ts index 9e6afb431..87ec303eb 100644 --- a/src/coin-gecko/api.ts +++ b/src/coin-gecko/api.ts @@ -1,12 +1,13 @@ import axios from 'axios' import type { AxiosResponse, AxiosError } from 'axios' import { useQuery } from '@tanstack/react-query' -import { NativeTicker, Ticker } from '../types/ticker' -import { getTickerForScope } from '../config' +import { Ticker } from '../types/ticker' +import { getTokensForScope } from '../config' import { RouteUtils } from '../app/utils/route-utils' +import { uniq } from '../app/utils/helpers' import { exhaustedTypeWarning } from '../types/errors' -type GetRosePriceParams = { +type GetTokenPricesFromGeckoParams = { ids: string vs_currencies: string include_market_cap?: string @@ -16,13 +17,13 @@ type GetRosePriceParams = { precision?: string } -type GetRosePriceResponse = { - 'oasis-network': { - usd: number - } -} +type TokenPriceMap = Partial> + +type GetTokenPricesFromGeckoResponse = TokenPriceMap -export const getRosePrice = (params: GetRosePriceParams): Promise> => { +export const getTokenPricesFromGecko = ( + params: GetTokenPricesFromGeckoParams, +): Promise> => { return axios.get('https://api.coingecko.com/api/v3/simple/price', { params: { ...params }, }) @@ -30,16 +31,20 @@ export const getRosePrice = (params: GetRosePriceParams): Promise>, AxiosError, number>( - ['roseFiatPrice'], +export function useGetTokenPricesFromGecko(tokenIds: string[]) { + return useQuery, AxiosError>( + ['tokenFiatPrices'], () => - getRosePrice({ - ids: 'oasis-network', + getTokenPricesFromGecko({ + ids: tokenIds.join(','), vs_currencies: 'usd', }), { - select: ({ data }) => data['oasis-network'].usd, + select: ({ data }) => { + const result: TokenPriceMap = {} + Object.keys(data).forEach(key => (result[key] = (data as any)[key].usd)) // TODO why are we fixated on USD + return result as any + }, staleTime, }, ) @@ -52,41 +57,29 @@ export type TokenPriceInfo = { hasUsedCoinGecko: boolean } -export const useTokenPrice = (ticker: NativeTicker): TokenPriceInfo => { - const { isLoading: roseIsLoading, data: rosePrice } = useGetRosePrice() - switch (ticker) { - case Ticker.ROSE: - return { - price: rosePrice, - isLoading: roseIsLoading, - isFree: false, - hasUsedCoinGecko: true, - } - case Ticker.TEST: - return { - hasUsedCoinGecko: false, - isLoading: false, - isFree: true, - } - default: - exhaustedTypeWarning('Checking price of unknown token', ticker) - return { - isLoading: false, - hasUsedCoinGecko: false, - isFree: false, - } +export const useTokenPrice = (ticker: Ticker): TokenPriceInfo => { + const tokenPrices = useAllTokenPrices() + const price = tokenPrices[ticker] + if (!price) { + exhaustedTypeWarning('Checking price of unknown token ticker', ticker as any) } + return price || tokenPrices[Ticker.TEST]! } -export type AllTokenPrices = Record +export type AllTokenPrices = Partial> export const useAllTokenPrices = (): AllTokenPrices => { - const results = {} as any - RouteUtils.getEnabledScopes().forEach(scope => { - const ticker = getTickerForScope(scope) - // The list of networks will never change on the run, so we can do this - // eslint-disable-next-line react-hooks/rules-of-hooks - results[ticker] = useTokenPrice(ticker) + const tokens = uniq(RouteUtils.getEnabledScopes().map(getTokensForScope).flat()) + const geckoIds = tokens.map(token => token.geckoId).filter((id): id is string => !!id) + const { isLoading: geckoIsLoading, data: geckoPrices } = useGetTokenPricesFromGecko(geckoIds) + const results: AllTokenPrices = {} + tokens.forEach(token => { + results[token.ticker] = { + isLoading: geckoIsLoading, + isFree: !!token.free, + hasUsedCoinGecko: !!token.geckoId, + price: token.geckoId && geckoPrices ? (geckoPrices as any)[token.geckoId] : undefined, + } }) return results } diff --git a/src/config.ts b/src/config.ts index fca7cf58e..c4bce3fff 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,7 +1,7 @@ // We get this from the generated code to avoid circular imports // eslint-disable-next-line no-restricted-imports import { Layer } from './oasis-nexus/generated/api' -import { getTickerForNetwork, NativeTicker } from './types/ticker' +import { getTokenForNetwork, NativeToken, NativeTokenInfo } from './types/ticker' import { SearchScope } from './types/searchScope' export const consensusDecimals = 9 @@ -13,11 +13,11 @@ type LayerNetwork = { runtimeId: string | undefined /** - * What do we call the native ticker on this layer? + * What are the native tokens on this layer? * * (If not given, the network's default token will be used.) */ - ticker?: NativeTicker + tokens?: NativeTokenInfo[] } type LayerConfig = { @@ -131,6 +131,7 @@ const pontusxConfig: LayerConfig = { // See max_batch_gas https://github.com/oasisprotocol/sapphire-paratime/blob/main/runtime/src/lib.rs#L166 blockGasLimit: 15_000_000, runtimeId: '000000000000000000000000000000000000000000000000a6d1e3ebf60dff6c', + tokens: [NativeToken.EUROe, NativeToken.TEST], }, local: { activeNodes: undefined, @@ -175,11 +176,15 @@ export const isStableDeploy = stableDeploys.some(url => window.location.origin = export const getAppTitle = () => process.env.REACT_APP_META_TITLE -export const getTickerForScope = ({ network, layer }: SearchScope): NativeTicker => { - const networkDefault = getTickerForNetwork(network) +export const getTokensForScope = (scope: SearchScope | undefined): NativeTokenInfo[] => { + if (!scope) { + return [] + } + const { network, layer } = scope + const networkDefault = getTokenForNetwork(network) if (layer !== Layer.consensus) { - return paraTimesConfig[layer][network].ticker ?? networkDefault + return paraTimesConfig[layer][network].tokens ?? [networkDefault] } - return networkDefault + return [networkDefault] } diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index a16e267fb..578133e1f 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -7,6 +7,7 @@ "emptyTokenList": "This account holds no {{spec}} {{description}}.", "emptyTransactionList": "There are no transactions on record for this account.", "emptyTokenTransferList": "There are no token transfers on record for this account.", + "failedToLookUpTickers": "We don't have the price for: {{tickers}}", "ERC20": "ERC-20", "ERC721": "ERC-721", "lastNonce": "Last Nonce", @@ -290,9 +291,6 @@ "first": "First", "last": "Last" }, - "rosePrice": { - "header": "ROSE Price" - }, "social": { "description": "Be part of the community and stay in the loop on everything Oasis", "discord": "Discord", @@ -338,6 +336,9 @@ "tokenSnapshot": { "header": "Token Snapshot" }, + "tokenPrice": { + "header": "{{ticker}} Price" + }, "totalTransactions": { "header": "Total Transactions", "tooltip": "{{value, number}} total transactions" diff --git a/src/oasis-nexus/api.ts b/src/oasis-nexus/api.ts index 075f7a025..a8ff7f8b4 100644 --- a/src/oasis-nexus/api.ts +++ b/src/oasis-nexus/api.ts @@ -1,7 +1,7 @@ /** @file Wrappers around generated API */ import axios, { AxiosResponse } from 'axios' -import { consensusDecimals, getTickerForScope, paraTimesConfig } from '../config' +import { consensusDecimals, getTokensForScope, paraTimesConfig } from '../config' import * as generated from './generated/api' import { QueryKey, UseQueryOptions, UseQueryResult } from '@tanstack/react-query' import { @@ -17,7 +17,7 @@ import { import { fromBaseUnits, getEthAddressForAccount, getAccountSize } from '../app/utils/helpers' import { Network } from '../types/network' import { SearchScope } from '../types/searchScope' -import { getTickerForNetwork, NativeTicker } from '../types/ticker' +import { Ticker } from '../types/ticker' import { useTransformToOasisAddress } from '../app/hooks/useTransformToOasisAddress' import { useEffect, useState } from 'react' import { RpcUtils } from '../app/utils/rpc-utils' @@ -32,13 +32,13 @@ declare module './generated/api' { export interface Transaction { network: Network layer: Layer - ticker: NativeTicker + ticker: Ticker } export interface RuntimeTransaction { network: Network layer: Layer - ticker: NativeTicker + ticker: Ticker } export interface Block { @@ -54,7 +54,7 @@ declare module './generated/api' { export interface Account { network: Network layer: Layer - ticker: NativeTicker + ticker: Ticker size: string total: string } @@ -63,7 +63,6 @@ declare module './generated/api' { network: Network layer: Layer address_eth?: string - ticker: NativeTicker tokenBalances: Partial> } @@ -84,7 +83,7 @@ declare module './generated/api' { } export interface Validator { - ticker: NativeTicker + ticker: Ticker } export interface Proposal { @@ -129,7 +128,7 @@ export const useGetConsensusTransactions: typeof generated.useGetConsensusTransa params?, options?, ) => { - const ticker = getTickerForNetwork(network) + const ticker = getTokensForScope({ network, layer: Layer.consensus })[0].ticker return generated.useGetConsensusTransactions(network, params, { ...options, request: { @@ -167,7 +166,7 @@ export const useGetRuntimeTransactions: typeof generated.useGetRuntimeTransactio params?, options?, ) => { - const ticker = getTickerForScope({ network, layer: runtime }) + const ticker = getTokensForScope({ network, layer: runtime })[0].ticker // TODO: find this out from tx data return generated.useGetRuntimeTransactions(network, runtime, params, { ...options, request: { @@ -204,7 +203,7 @@ export const useGetConsensusTransactionsTxHash: typeof generated.useGetConsensus txHash, options?, ) => { - const ticker = getTickerForNetwork(network) + const ticker = getTokensForScope({ network, layer: Layer.consensus })[0].ticker return generated.useGetConsensusTransactionsTxHash(network, txHash, { ...options, request: { @@ -239,7 +238,7 @@ export const useGetRuntimeTransactionsTxHash: typeof generated.useGetRuntimeTran ) => { // Sometimes we will call this with an undefined txHash, so we must be careful here. const actualHash = txHash?.startsWith('0x') ? txHash.substring(2) : txHash - const ticker = getTickerForScope({ network, layer: runtime }) + const ticker = getTokensForScope({ network, layer: runtime })[0].ticker // TODO: find this out from tx data return generated.useGetRuntimeTransactionsTxHash(network, runtime, actualHash, { ...options, request: { @@ -276,7 +275,7 @@ export const useGetConsensusAccountsAddress: typeof generated.useGetConsensusAcc address, options?, ) => { - const ticker = getTickerForNetwork(network) + const ticker = getTokensForScope({ network, layer: Layer.consensus })[0].ticker return generated.useGetConsensusAccountsAddress(network, address, { ...options, request: { @@ -320,7 +319,6 @@ export const useGetRuntimeAccountsAddress: typeof generated.useGetRuntimeAccount const oasisAddress = useTransformToOasisAddress(address) - const ticker = getTickerForScope({ network, layer: runtime }) const query = generated.useGetRuntimeAccountsAddress(network, runtime, oasisAddress!, { ...options, query: { @@ -369,7 +367,6 @@ export const useGetRuntimeAccountsAddress: typeof generated.useGetRuntimeAccount ? fromBaseUnits(data.stats?.total_sent, paraTimesConfig[runtime].decimals) : '0', }, - ticker, }) }, ...arrayify(options?.request?.transformResponse), @@ -755,7 +752,7 @@ export const useGetRuntimeEvents: typeof generated.useGetRuntimeEvents = ( event.body.amount.Amount, paraTimesConfig[runtime].decimals, ), - Denomination: getTickerForScope({ network, layer: runtime }), + Denomination: getTokensForScope({ network, layer: runtime })[0].ticker, // TODO find this out from event data } : event.body.amount, }, @@ -891,7 +888,7 @@ export const useGetConsensusValidators: typeof generated.useGetConsensusValidato params?, options?, ) => { - const ticker = getTickerForNetwork(network) + const ticker = getTokensForScope({ network, layer: Layer.consensus })[0].ticker return generated.useGetConsensusValidators(network, params, { ...options, request: { @@ -922,7 +919,7 @@ export const useGetConsensusAccounts: typeof generated.useGetConsensusAccounts = params?, options?, ) => { - const ticker = getTickerForNetwork(network) + const ticker = getTokensForScope({ network, layer: Layer.consensus })[0].ticker return generated.useGetConsensusAccounts(network, params, { ...options, request: { diff --git a/src/types/ticker.ts b/src/types/ticker.ts index 5c7a19b73..047d5d888 100644 --- a/src/types/ticker.ts +++ b/src/types/ticker.ts @@ -1,21 +1,44 @@ import { Network } from './network' import { TFunction } from 'i18next' -export type NativeTicker = (typeof Ticker)[keyof typeof Ticker] +export type Ticker = (typeof Ticker)[keyof typeof Ticker] +// eslint-disable-next-line @typescript-eslint/no-redeclare export const Ticker = { ROSE: 'ROSE', TEST: 'TEST', + EUROe: 'EUROe', } as const -const networkTicker: Record = { - [Network.mainnet]: Ticker.ROSE, - [Network.testnet]: Ticker.TEST, +export type NativeTokenInfo = { + ticker: Ticker + free?: boolean + geckoId?: string } -export const getTickerForNetwork = (network: Network): NativeTicker => networkTicker[network] +export const NativeToken = { + ROSE: { + ticker: Ticker.ROSE, + geckoId: 'oasis-network', + }, + TEST: { + ticker: Ticker.TEST, + free: true, + }, + EUROe: { + ticker: Ticker.EUROe, + geckoId: 'euroe-stablecoin', + }, +} as const + +export const networkToken: Record = { + [Network.mainnet]: NativeToken.ROSE, + [Network.testnet]: NativeToken.TEST, +} + +export const getTokenForNetwork = (network: Network): NativeTokenInfo => networkToken[network] -export const getNameForTicker = (t: TFunction, ticker: string): string => { +export const getNameForTicker = (_t: TFunction, ticker: string): string => { // TODO: how do we translate ticker names? return ticker }