Explorer: introduce rewards tab for stake/vote accounts (#16851)

* feat: introduce staking rewards tab

* feat: take into consideration stake activation

* fix: report fetch errors

* fix: find rewards all the way to epoch 0

* fix: find rewards all the way to epoch 0

* fix: autocomplete error

* fix: load one page at a time
This commit is contained in:
Josh 2021-05-20 14:00:48 -07:00 committed by GitHub
parent 0486df02ba
commit 741f99ebb6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 324 additions and 2 deletions

View File

@ -0,0 +1,118 @@
import React from "react";
import { PublicKey } from "@solana/web3.js";
import { useFetchRewards, useRewards } from "providers/accounts/rewards";
import { LoadingCard } from "components/common/LoadingCard";
import { FetchStatus } from "providers/cache";
import { ErrorCard } from "components/common/ErrorCard";
import { Slot } from "components/common/Slot";
import { lamportsToSolString } from "utils";
import { useAccountInfo } from "providers/accounts";
import BN from "bn.js";
const MAX_EPOCH = new BN(2).pow(new BN(64)).sub(new BN(1));
export function RewardsCard({ pubkey }: { pubkey: PublicKey }) {
const address = React.useMemo(() => pubkey.toBase58(), [pubkey]);
const info = useAccountInfo(address);
const account = info?.data;
const data = account?.details?.data?.parsed.info;
const highestEpoch = React.useMemo(() => {
if (data.stake && !data.stake.delegation.deactivationEpoch.eq(MAX_EPOCH)) {
return data.stake.delegation.deactivationEpoch.toNumber();
}
}, [data]);
const rewards = useRewards(address);
const fetchRewards = useFetchRewards();
const loadMore = () => fetchRewards(pubkey, highestEpoch);
React.useEffect(() => {
if (!rewards) {
fetchRewards(pubkey, highestEpoch);
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
if (!rewards) {
return null;
}
if (rewards?.data === undefined) {
if (rewards.status === FetchStatus.Fetching) {
return <LoadingCard message="Loading rewards" />;
}
return <ErrorCard retry={loadMore} text="Failed to fetch rewards" />;
}
const rewardsList = rewards.data.rewards.map((reward) => {
if (!reward) {
return null;
}
return (
<tr key={reward.epoch}>
<td>{reward.epoch}</td>
<td>
<Slot slot={reward.effectiveSlot} link />
</td>
<td>{lamportsToSolString(reward.amount)}</td>
<td>{lamportsToSolString(reward.postBalance)}</td>
</tr>
);
});
const { foundOldest } = rewards.data;
const fetching = rewards.status === FetchStatus.Fetching;
return (
<>
<div className="card">
<div className="card-header">
<div className="row align-items-center">
<div className="col">
<h3 className="card-header-title">Rewards</h3>
</div>
</div>
</div>
<div className="table-responsive mb-0">
<table className="table table-sm table-nowrap card-table">
<thead>
<tr>
<th className="w-1 text-muted">Epoch</th>
<th className="text-muted">Effective Slot</th>
<th className="text-muted">Reward Amount</th>
<th className="text-muted">Post Balance</th>
</tr>
</thead>
<tbody className="list">{rewardsList}</tbody>
</table>
</div>
<div className="card-footer">
{foundOldest ? (
<div className="text-muted text-center">
Fetched full reward history
</div>
) : (
<button
className="btn btn-primary w-100"
onClick={() => loadMore()}
disabled={fetching}
>
{fetching ? (
<>
<span className="spinner-grow spinner-grow-sm mr-2"></span>
Loading
</>
) : (
"Load More"
)}
</button>
)}
</div>
</div>
</>
);
}

View File

@ -33,6 +33,7 @@ import { Identicon } from "components/common/Identicon";
import { TransactionHistoryCard } from "components/account/history/TransactionHistoryCard";
import { TokenTransfersCard } from "components/account/history/TokenTransfersCard";
import { TokenInstructionsCard } from "components/account/history/TokenInstructionsCard";
import { RewardsCard } from "components/account/RewardsCard";
const IDENTICON_WIDTH = 64;
@ -54,12 +55,24 @@ const TABS_LOOKUP: { [id: string]: Tab[] } = {
path: "/largest",
},
],
stake: [
{
slug: "rewards",
title: "Rewards",
path: "/rewards",
},
],
vote: [
{
slug: "vote-history",
title: "Vote History",
path: "/vote-history",
},
{
slug: "rewards",
title: "Rewards",
path: "/rewards",
},
],
"sysvar:recentBlockhashes": [
{
@ -280,7 +293,8 @@ export type MoreTabs =
| "stake-history"
| "blockhashes"
| "transfers"
| "instructions";
| "instructions"
| "rewards";
function MoreSection({
account,
@ -325,6 +339,7 @@ function MoreSection({
{tab === "transfers" && <TokenTransfersCard pubkey={pubkey} />}
{tab === "instructions" && <TokenInstructionsCard pubkey={pubkey} />}
{tab === "largest" && <TokenLargestAccountsCard pubkey={pubkey} />}
{tab === "rewards" && <RewardsCard pubkey={pubkey} />}
{tab === "vote-history" && data?.program === "vote" && (
<VotesCard voteAccount={data.parsed} />
)}

View File

@ -24,6 +24,7 @@ import {
ProgramDataAccountInfo,
UpgradeableLoaderAccount,
} from "validators/accounts/upgradeable-program";
import { RewardsProvider } from "./rewards";
export { useAccountHistory } from "./history";
export type StakeProgramData = {
@ -106,7 +107,9 @@ export function AccountsProvider({ children }: AccountsProviderProps) {
<DispatchContext.Provider value={dispatch}>
<TokensProvider>
<HistoryProvider>
<FlaggedAccountsProvider>{children}</FlaggedAccountsProvider>
<RewardsProvider>
<FlaggedAccountsProvider>{children}</FlaggedAccountsProvider>
</RewardsProvider>
</HistoryProvider>
</TokensProvider>
</DispatchContext.Provider>

View File

@ -0,0 +1,186 @@
import React from "react";
import { Cluster, useCluster } from "providers/cluster";
import * as Cache from "providers/cache";
import { Connection, InflationReward, PublicKey } from "@solana/web3.js";
import { ActionType } from "providers/block";
import { FetchStatus } from "providers/cache";
import { reportError } from "utils/sentry";
const PAGE_SIZE = 15;
export type Rewards = {
highestFetchedEpoch?: number;
lowestFetchedEpoch?: number;
rewards: (InflationReward | null)[];
foundOldest?: boolean;
};
export type RewardsUpdate = {
rewards: (InflationReward | null)[];
foundOldest?: boolean;
};
type State = Cache.State<Rewards>;
type Dispatch = Cache.Dispatch<RewardsUpdate>;
function reconcile(
rewards: Rewards | undefined,
update: RewardsUpdate | undefined
): Rewards | undefined {
if (update === undefined) {
return rewards;
}
const combined = (rewards?.rewards || [])
.concat(update.rewards)
.filter((value) => value !== null);
const foundOldest = update.foundOldest;
return {
rewards: combined,
highestFetchedEpoch: combined[0]?.epoch,
lowestFetchedEpoch: combined[combined.length - 1]?.epoch,
foundOldest,
};
}
export const StateContext = React.createContext<State | undefined>(undefined);
export const DispatchContext = React.createContext<Dispatch | undefined>(
undefined
);
type RewardsProviderProps = { children: React.ReactNode };
export function RewardsProvider({ children }: RewardsProviderProps) {
const { url } = useCluster();
const [state, dispatch] = Cache.useCustomReducer(url, reconcile);
React.useEffect(() => {
dispatch({ type: ActionType.Clear, url });
}, [dispatch, url]);
return (
<StateContext.Provider value={state}>
<DispatchContext.Provider value={dispatch}>
{children}
</DispatchContext.Provider>
</StateContext.Provider>
);
}
async function fetchRewards(
dispatch: Dispatch,
pubkey: PublicKey,
cluster: Cluster,
url: string,
fromEpoch?: number,
highestEpoch?: number
) {
dispatch({
type: ActionType.Update,
status: FetchStatus.Fetching,
key: pubkey.toBase58(),
url,
});
const connection = new Connection(url);
if (!fromEpoch && highestEpoch) {
fromEpoch = highestEpoch;
}
if (!fromEpoch) {
try {
const epochInfo = await connection.getEpochInfo();
fromEpoch = epochInfo.epoch - 1;
} catch (error) {
if (cluster !== Cluster.Custom) {
reportError(error, { url });
}
return dispatch({
type: ActionType.Update,
status: FetchStatus.FetchFailed,
key: pubkey.toBase58(),
url,
});
}
}
const getInflationReward = async (epoch: number) => {
try {
const result = await connection.getInflationReward([pubkey], epoch);
return result[0];
} catch (error) {
if (cluster !== Cluster.Custom) {
reportError(error, { url });
}
}
return null;
};
const requests = [];
for (let i: number = fromEpoch; i > fromEpoch - PAGE_SIZE; i--) {
if (i >= 0) {
requests.push(getInflationReward(i));
}
}
const results = await Promise.all(requests);
fromEpoch = fromEpoch - requests.length;
dispatch({
type: ActionType.Update,
url,
key: pubkey.toBase58(),
status: FetchStatus.Fetched,
data: {
rewards: results || [],
foundOldest: fromEpoch <= 0,
},
});
}
export function useRewards(
address: string
): Cache.CacheEntry<Rewards> | undefined {
const context = React.useContext(StateContext);
if (!context) {
throw new Error(`useRewards must be used within a AccountsProvider`);
}
return context.entries[address];
}
export function useFetchRewards() {
const { cluster, url } = useCluster();
const state = React.useContext(StateContext);
const dispatch = React.useContext(DispatchContext);
if (!state || !dispatch) {
throw new Error(`useFetchRewards must be used within a AccountsProvider`);
}
return React.useCallback(
(pubkey: PublicKey, highestEpoch?: number) => {
const before = state.entries[pubkey.toBase58()];
if (before?.data) {
fetchRewards(
dispatch,
pubkey,
cluster,
url,
before.data.lowestFetchedEpoch
? before.data.lowestFetchedEpoch - 1
: undefined,
highestEpoch
);
} else {
fetchRewards(dispatch, pubkey, cluster, url, undefined, highestEpoch);
}
},
[state, dispatch, cluster, url]
);
}