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
This commit is contained in:
5
explorer/package-lock.json
generated
5
explorer/package-lock.json
generated
@ -5424,6 +5424,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz",
|
||||||
"integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c="
|
"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": {
|
"collect-v8-coverage": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.1.tgz",
|
||||||
|
@ -30,6 +30,7 @@
|
|||||||
"chai": "^4.3.4",
|
"chai": "^4.3.4",
|
||||||
"chart.js": "^2.9.4",
|
"chart.js": "^2.9.4",
|
||||||
"classnames": "2.2.6",
|
"classnames": "2.2.6",
|
||||||
|
"coingecko-api": "^1.0.10",
|
||||||
"cross-fetch": "^3.1.1",
|
"cross-fetch": "^3.1.1",
|
||||||
"humanize-duration-ts": "^2.1.1",
|
"humanize-duration-ts": "^2.1.1",
|
||||||
"node-sass": "^4.14.1",
|
"node-sass": "^4.14.1",
|
||||||
|
@ -7,16 +7,35 @@ import {
|
|||||||
usePerformanceInfo,
|
usePerformanceInfo,
|
||||||
useStatsProvider,
|
useStatsProvider,
|
||||||
} from "providers/stats/solanaClusterStats";
|
} from "providers/stats/solanaClusterStats";
|
||||||
import { slotsToHumanString } from "utils";
|
import { lamportsToSol, slotsToHumanString } from "utils";
|
||||||
import { useCluster } from "providers/cluster";
|
import { ClusterStatus, useCluster } from "providers/cluster";
|
||||||
import { TpsCard } from "components/TpsCard";
|
import { TpsCard } from "components/TpsCard";
|
||||||
import { displayTimestampUtc } from "utils/date";
|
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() {
|
export function ClusterStatsPage() {
|
||||||
return (
|
return (
|
||||||
<div className="container mt-4">
|
<div className="container mt-4">
|
||||||
|
<StakingComponent />
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="card-header">
|
<div className="card-header">
|
||||||
<div className="row align-items-center">
|
<div className="row align-items-center">
|
||||||
@ -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 <LoadingCard />;
|
||||||
|
} else if (typeof supply === "string") {
|
||||||
|
return <ErrorCard text={supply} retry={fetchData} />;
|
||||||
|
} else if (stakeInfo.status === FetchStatus.FetchFailed) {
|
||||||
|
return (
|
||||||
|
<ErrorCard text={"Failed to fetch active stake"} retry={fetchData} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="card staking-card">
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="d-flex">
|
||||||
|
<div className="p-2 flex-fill">
|
||||||
|
<h4>Circulating Supply</h4>
|
||||||
|
<h1>
|
||||||
|
<em>{displayLamports(supply.circulating)}</em> /{" "}
|
||||||
|
<small>{displayLamports(supply.total)}</small>
|
||||||
|
</h1>
|
||||||
|
<h5>
|
||||||
|
<em>{circulatingPercentage}%</em> is circulating
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div className="p-2 flex-fill">
|
||||||
|
<h4>Active Stake</h4>
|
||||||
|
<h1>
|
||||||
|
<em>{displayLamports(stakeHistory.effective)}</em> /{" "}
|
||||||
|
<small>{displayLamports(supply.total)}</small>
|
||||||
|
</h1>
|
||||||
|
{delinquentStakePercentage && (
|
||||||
|
<h5>
|
||||||
|
Delinquent stake: <em>{delinquentStakePercentage}%</em>
|
||||||
|
</h5>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{solanaInfo && (
|
||||||
|
<div className="p-2 flex-fill">
|
||||||
|
<h4>Price</h4>
|
||||||
|
<h1>
|
||||||
|
<em>${solanaInfo.price.toFixed(2)}</em>{" "}
|
||||||
|
{solanaInfo.price_change_percentage_24h > 0 && (
|
||||||
|
<small>
|
||||||
|
↑ {solanaInfo.price_change_percentage_24h.toFixed(2)}%
|
||||||
|
</small>
|
||||||
|
)}
|
||||||
|
{solanaInfo.price_change_percentage_24h < 0 && (
|
||||||
|
<small>
|
||||||
|
↓ {solanaInfo.price_change_percentage_24h.toFixed(2)}%
|
||||||
|
</small>
|
||||||
|
)}
|
||||||
|
{solanaInfo.price_change_percentage_24h === 0 && (
|
||||||
|
<small>0%</small>
|
||||||
|
)}
|
||||||
|
</h1>
|
||||||
|
<h5>
|
||||||
|
24h Vol: <em>${abbreviatedNumber(solanaInfo.volume_24)}</em>{" "}
|
||||||
|
MCap: <em>${abbreviatedNumber(solanaInfo.market_cap)}</em>
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{coinInfo.status === CoingeckoStatus.FetchFailed && (
|
||||||
|
<div className="p-2 flex-fill">
|
||||||
|
<h4>Price</h4>
|
||||||
|
<h1>
|
||||||
|
<em>$--.--</em>
|
||||||
|
</h1>
|
||||||
|
<h5>Error fetching the latest price information</h5>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
function StatsCardBody() {
|
||||||
const dashboardInfo = useDashboardInfo();
|
const dashboardInfo = useDashboardInfo();
|
||||||
const performanceInfo = usePerformanceInfo();
|
const performanceInfo = usePerformanceInfo();
|
||||||
@ -158,3 +328,71 @@ export function StatsNotReady({ error }: { error: boolean }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<CoinGeckoResult>();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
32
explorer/src/providers/accounts/vote-accounts.tsx
Normal file
32
explorer/src/providers/accounts/vote-accounts.tsx
Normal file
@ -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<VoteAccountStatus | undefined>
|
||||||
|
>
|
||||||
|
) {
|
||||||
|
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<VoteAccountStatus>();
|
||||||
|
const { cluster, url } = useCluster();
|
||||||
|
|
||||||
|
return {
|
||||||
|
fetchVoteAccounts: () => fetchVoteAccounts(cluster, url, setVoteAccounts),
|
||||||
|
voteAccounts,
|
||||||
|
};
|
||||||
|
}
|
@ -353,3 +353,19 @@ pre.data-wrap, pre.json-wrap {
|
|||||||
pre.json-wrap {
|
pre.json-wrap {
|
||||||
max-width: 36rem;
|
max-width: 36rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.staking-card {
|
||||||
|
h1 {
|
||||||
|
margin-bottom: .75rem;
|
||||||
|
small {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h5 {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
em {
|
||||||
|
font-style: normal;
|
||||||
|
color: $primary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Reference in New Issue
Block a user