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:
Josh 2021-03-25 09:59:50 -07:00 committed by GitHub
parent 66c42f62d8
commit 2aea35281e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 295 additions and 3 deletions

View File

@ -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",

View File

@ -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",

View File

@ -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>
&uarr; {solanaInfo.price_change_percentage_24h.toFixed(2)}%
</small>
)}
{solanaInfo.price_change_percentage_24h < 0 && (
<small>
&darr; {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;
}

View 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,
};
}

View File

@ -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
}
}