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:
parent
66c42f62d8
commit
2aea35281e
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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 (
|
||||
<div className="container mt-4">
|
||||
<StakingComponent />
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<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() {
|
||||
const dashboardInfo = useDashboardInfo();
|
||||
const performanceInfo = usePerformanceInfo();
|
||||
|
@ -158,3 +328,71 @@ export function StatsNotReady({ error }: { error: boolean }) {
|
|||
</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;
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
max-width: 36rem;
|
||||
}
|
||||
|
||||
.staking-card {
|
||||
h1 {
|
||||
margin-bottom: .75rem;
|
||||
small {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
h5 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
em {
|
||||
font-style: normal;
|
||||
color: $primary
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue