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:
parent
0486df02ba
commit
741f99ebb6
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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} />
|
||||
)}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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]
|
||||
);
|
||||
}
|
Loading…
Reference in New Issue