From 2aea35281e16f9886dcca7a4568f95e23e482c31 Mon Sep 17 00:00:00 2001 From: Josh Date: Thu, 25 Mar 2021 09:59:50 -0700 Subject: [PATCH] Explorer: introduce circulating supply, active stake, and price on cluster stats page (#16095) * feat: add styles form staking component * feat: introduce circulating supply, active stake, and price on cluster stats page * feat: add an error state for coingecko --- explorer/package-lock.json | 5 + explorer/package.json | 1 + explorer/src/pages/ClusterStatsPage.tsx | 244 +++++++++++++++++- .../src/providers/accounts/vote-accounts.tsx | 32 +++ explorer/src/scss/_solana.scss | 16 ++ 5 files changed, 295 insertions(+), 3 deletions(-) create mode 100644 explorer/src/providers/accounts/vote-accounts.tsx diff --git a/explorer/package-lock.json b/explorer/package-lock.json index 40480377d18843..a24b6b6d2e2f1e 100644 --- a/explorer/package-lock.json +++ b/explorer/package-lock.json @@ -5424,6 +5424,11 @@ "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=" }, + "coingecko-api": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/coingecko-api/-/coingecko-api-1.0.10.tgz", + "integrity": "sha512-7YLLC85+daxAw5QlBWoHVBVpJRwoPr4HtwanCr8V/WRjoyHTa1Lb9DQAvv4MDJZHiz4no6HGnDQnddtjV35oRA==" + }, "collect-v8-coverage": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.1.tgz", diff --git a/explorer/package.json b/explorer/package.json index 0a92f9513c405f..219fd3dfb1846e 100644 --- a/explorer/package.json +++ b/explorer/package.json @@ -30,6 +30,7 @@ "chai": "^4.3.4", "chart.js": "^2.9.4", "classnames": "2.2.6", + "coingecko-api": "^1.0.10", "cross-fetch": "^3.1.1", "humanize-duration-ts": "^2.1.1", "node-sass": "^4.14.1", diff --git a/explorer/src/pages/ClusterStatsPage.tsx b/explorer/src/pages/ClusterStatsPage.tsx index 670d150403e893..5b0b8b8f19e982 100644 --- a/explorer/src/pages/ClusterStatsPage.tsx +++ b/explorer/src/pages/ClusterStatsPage.tsx @@ -7,16 +7,35 @@ import { usePerformanceInfo, useStatsProvider, } from "providers/stats/solanaClusterStats"; -import { slotsToHumanString } from "utils"; -import { useCluster } from "providers/cluster"; +import { lamportsToSol, slotsToHumanString } from "utils"; +import { ClusterStatus, useCluster } from "providers/cluster"; import { TpsCard } from "components/TpsCard"; import { displayTimestampUtc } from "utils/date"; +import { Status, useFetchSupply, useSupply } from "providers/supply"; +import { PublicKey } from "@solana/web3.js"; +import { ErrorCard } from "components/common/ErrorCard"; +import { LoadingCard } from "components/common/LoadingCard"; +import { useAccountInfo, useFetchAccountInfo } from "providers/accounts"; +import { FetchStatus } from "providers/cache"; +import { useVoteAccounts } from "providers/accounts/vote-accounts"; +// @ts-ignore +import * as CoinGecko from "coingecko-api"; -const CLUSTER_STATS_TIMEOUT = 10000; +enum CoingeckoStatus { + Success, + FetchFailed, +} + +const CoinGeckoClient = new CoinGecko(); + +const CLUSTER_STATS_TIMEOUT = 5000; +const STAKE_HISTORY_ACCOUNT = "SysvarStakeHistory1111111111111111111111111"; +const PRICE_REFRESH = 10000; export function ClusterStatsPage() { return (
+
@@ -32,6 +51,157 @@ export function ClusterStatsPage() { ); } +function StakingComponent() { + const { status } = useCluster(); + const supply = useSupply(); + const fetchSupply = useFetchSupply(); + const fetchAccount = useFetchAccountInfo(); + const stakeInfo = useAccountInfo(STAKE_HISTORY_ACCOUNT); + const coinInfo = useCoinGecko("solana"); + const { fetchVoteAccounts, voteAccounts } = useVoteAccounts(); + + function fetchData() { + fetchSupply(); + fetchAccount(new PublicKey(STAKE_HISTORY_ACCOUNT)); + fetchVoteAccounts(); + } + + React.useEffect(() => { + if (status === ClusterStatus.Connected) { + fetchData(); + } + }, [status]); // eslint-disable-line react-hooks/exhaustive-deps + + const deliquentStake = React.useMemo(() => { + if (voteAccounts) { + return voteAccounts.delinquent.reduce( + (prev, current) => prev + current.activatedStake, + 0 + ); + } + }, [voteAccounts]); + + let stakeHistory = stakeInfo?.data?.details?.data?.parsed.info; + + if (supply === Status.Disconnected) { + // we'll return here to prevent flicker + return null; + } + + if ( + supply === Status.Idle || + supply === Status.Connecting || + !stakeInfo || + !stakeHistory || + !coinInfo + ) { + return ; + } else if (typeof supply === "string") { + return ; + } else if (stakeInfo.status === FetchStatus.FetchFailed) { + return ( + + ); + } + + stakeHistory = stakeHistory[0].stakeHistory; + + const circulatingPercentage = ( + (supply.circulating / supply.total) * + 100 + ).toFixed(1); + + let delinquentStakePercentage; + if (deliquentStake) { + delinquentStakePercentage = ( + (deliquentStake / stakeHistory.effective) * + 100 + ).toFixed(1); + } + + let solanaInfo; + if (coinInfo.status === CoingeckoStatus.Success) { + solanaInfo = coinInfo.coinInfo; + } + + return ( +
+
+
+
+

Circulating Supply

+

+ {displayLamports(supply.circulating)} /{" "} + {displayLamports(supply.total)} +

+
+ {circulatingPercentage}% is circulating +
+
+
+

Active Stake

+

+ {displayLamports(stakeHistory.effective)} /{" "} + {displayLamports(supply.total)} +

+ {delinquentStakePercentage && ( +
+ Delinquent stake: {delinquentStakePercentage}% +
+ )} +
+ {solanaInfo && ( +
+

Price

+

+ ${solanaInfo.price.toFixed(2)}{" "} + {solanaInfo.price_change_percentage_24h > 0 && ( + + ↑ {solanaInfo.price_change_percentage_24h.toFixed(2)}% + + )} + {solanaInfo.price_change_percentage_24h < 0 && ( + + ↓ {solanaInfo.price_change_percentage_24h.toFixed(2)}% + + )} + {solanaInfo.price_change_percentage_24h === 0 && ( + 0% + )} +

+
+ 24h Vol: ${abbreviatedNumber(solanaInfo.volume_24)}{" "} + MCap: ${abbreviatedNumber(solanaInfo.market_cap)} +
+
+ )} + {coinInfo.status === CoingeckoStatus.FetchFailed && ( +
+

Price

+

+ $--.-- +

+
Error fetching the latest price information
+
+ )} +
+
+
+ ); +} + +const abbreviatedNumber = (value: number, fixed = 1) => { + if (value < 1e3) return value; + if (value >= 1e3 && value < 1e6) return +(value / 1e3).toFixed(fixed) + "K"; + if (value >= 1e6 && value < 1e9) return +(value / 1e6).toFixed(fixed) + "M"; + if (value >= 1e9 && value < 1e12) return +(value / 1e9).toFixed(fixed) + "B"; + if (value >= 1e12) return +(value / 1e12).toFixed(fixed) + "T"; +}; + +function displayLamports(value: number) { + return abbreviatedNumber(lamportsToSol(value)); +} + function StatsCardBody() { const dashboardInfo = useDashboardInfo(); const performanceInfo = usePerformanceInfo(); @@ -158,3 +328,71 @@ export function StatsNotReady({ error }: { error: boolean }) {
); } + +interface CoinInfo { + price: number; + volume_24: number; + market_cap: number; + price_change_percentage_24h: number; +} + +interface CoinInfoResult { + data: { + market_data: { + current_price: { + usd: number; + }; + total_volume: { + usd: number; + }; + market_cap: { + usd: number; + }; + price_change_percentage_24h: number; + }; + }; +} + +type CoinGeckoResult = { + coinInfo?: CoinInfo; + status: CoingeckoStatus; +}; + +function useCoinGecko(coinId: string): CoinGeckoResult | undefined { + const [coinInfo, setCoinInfo] = React.useState(); + + React.useEffect(() => { + const getCoinInfo = () => { + CoinGeckoClient.coins + .fetch("solana") + .then((info: CoinInfoResult) => { + setCoinInfo({ + coinInfo: { + price: info.data.market_data.current_price.usd, + volume_24: info.data.market_data.total_volume.usd, + market_cap: info.data.market_data.market_cap.usd, + price_change_percentage_24h: + info.data.market_data.price_change_percentage_24h, + }, + status: CoingeckoStatus.Success, + }); + }) + .catch((error: any) => { + setCoinInfo({ + status: CoingeckoStatus.FetchFailed, + }); + }); + }; + + getCoinInfo(); + const interval = setInterval(() => { + getCoinInfo(); + }, PRICE_REFRESH); + + return () => { + clearInterval(interval); + }; + }, [setCoinInfo]); + + return coinInfo; +} diff --git a/explorer/src/providers/accounts/vote-accounts.tsx b/explorer/src/providers/accounts/vote-accounts.tsx new file mode 100644 index 00000000000000..5c938338e5e242 --- /dev/null +++ b/explorer/src/providers/accounts/vote-accounts.tsx @@ -0,0 +1,32 @@ +import { Connection, VoteAccountStatus } from "@solana/web3.js"; +import { Cluster, useCluster } from "providers/cluster"; +import React from "react"; +import { reportError } from "utils/sentry"; + +async function fetchVoteAccounts( + cluster: Cluster, + url: string, + setVoteAccounts: React.Dispatch< + React.SetStateAction + > +) { + try { + const connection = new Connection(url); + const result = await connection.getVoteAccounts(); + setVoteAccounts(result); + } catch (error) { + if (cluster !== Cluster.Custom) { + reportError(error, { url }); + } + } +} + +export function useVoteAccounts() { + const [voteAccounts, setVoteAccounts] = React.useState(); + const { cluster, url } = useCluster(); + + return { + fetchVoteAccounts: () => fetchVoteAccounts(cluster, url, setVoteAccounts), + voteAccounts, + }; +} diff --git a/explorer/src/scss/_solana.scss b/explorer/src/scss/_solana.scss index 7d6e06a31cd97f..d590da8bd3f575 100644 --- a/explorer/src/scss/_solana.scss +++ b/explorer/src/scss/_solana.scss @@ -353,3 +353,19 @@ pre.data-wrap, pre.json-wrap { pre.json-wrap { max-width: 36rem; } + +.staking-card { + h1 { + margin-bottom: .75rem; + small { + font-size: 1rem; + } + } + h5 { + margin-bottom: 0; + } + em { + font-style: normal; + color: $primary + } +}