Skip to content

Commit

Permalink
Explorer: introduce circulating supply, active stake, and price on cl…
Browse files Browse the repository at this point in the history
…uster 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
  • Loading branch information
oJshua authored Mar 25, 2021
1 parent 66c42f6 commit 2aea352
Show file tree
Hide file tree
Showing 5 changed files with 295 additions and 3 deletions.
5 changes: 5 additions & 0 deletions explorer/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions explorer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
244 changes: 241 additions & 3 deletions explorer/src/pages/ClusterStatsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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">
Expand All @@ -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();
Expand Down Expand Up @@ -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;
}
32 changes: 32 additions & 0 deletions explorer/src/providers/accounts/vote-accounts.tsx
Original file line number Diff line number Diff line change
@@ -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,
};
}
16 changes: 16 additions & 0 deletions explorer/src/scss/_solana.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

0 comments on commit 2aea352

Please sign in to comment.