diff --git a/explorer/package-lock.json b/explorer/package-lock.json
index 40480377d1..a24b6b6d2e 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 0a92f9513c..219fd3dfb1 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 670d150403..5b0b8b8f19 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 0000000000..5c938338e5
--- /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 7d6e06a31c..d590da8bd3 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
+ }
+}