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:
118
explorer/src/components/account/RewardsCard.tsx
Normal file
118
explorer/src/components/account/RewardsCard.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -33,6 +33,7 @@ import { Identicon } from "components/common/Identicon";
|
|||||||
import { TransactionHistoryCard } from "components/account/history/TransactionHistoryCard";
|
import { TransactionHistoryCard } from "components/account/history/TransactionHistoryCard";
|
||||||
import { TokenTransfersCard } from "components/account/history/TokenTransfersCard";
|
import { TokenTransfersCard } from "components/account/history/TokenTransfersCard";
|
||||||
import { TokenInstructionsCard } from "components/account/history/TokenInstructionsCard";
|
import { TokenInstructionsCard } from "components/account/history/TokenInstructionsCard";
|
||||||
|
import { RewardsCard } from "components/account/RewardsCard";
|
||||||
|
|
||||||
const IDENTICON_WIDTH = 64;
|
const IDENTICON_WIDTH = 64;
|
||||||
|
|
||||||
@ -54,12 +55,24 @@ const TABS_LOOKUP: { [id: string]: Tab[] } = {
|
|||||||
path: "/largest",
|
path: "/largest",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
stake: [
|
||||||
|
{
|
||||||
|
slug: "rewards",
|
||||||
|
title: "Rewards",
|
||||||
|
path: "/rewards",
|
||||||
|
},
|
||||||
|
],
|
||||||
vote: [
|
vote: [
|
||||||
{
|
{
|
||||||
slug: "vote-history",
|
slug: "vote-history",
|
||||||
title: "Vote History",
|
title: "Vote History",
|
||||||
path: "/vote-history",
|
path: "/vote-history",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
slug: "rewards",
|
||||||
|
title: "Rewards",
|
||||||
|
path: "/rewards",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
"sysvar:recentBlockhashes": [
|
"sysvar:recentBlockhashes": [
|
||||||
{
|
{
|
||||||
@ -280,7 +293,8 @@ export type MoreTabs =
|
|||||||
| "stake-history"
|
| "stake-history"
|
||||||
| "blockhashes"
|
| "blockhashes"
|
||||||
| "transfers"
|
| "transfers"
|
||||||
| "instructions";
|
| "instructions"
|
||||||
|
| "rewards";
|
||||||
|
|
||||||
function MoreSection({
|
function MoreSection({
|
||||||
account,
|
account,
|
||||||
@ -325,6 +339,7 @@ function MoreSection({
|
|||||||
{tab === "transfers" && <TokenTransfersCard pubkey={pubkey} />}
|
{tab === "transfers" && <TokenTransfersCard pubkey={pubkey} />}
|
||||||
{tab === "instructions" && <TokenInstructionsCard pubkey={pubkey} />}
|
{tab === "instructions" && <TokenInstructionsCard pubkey={pubkey} />}
|
||||||
{tab === "largest" && <TokenLargestAccountsCard pubkey={pubkey} />}
|
{tab === "largest" && <TokenLargestAccountsCard pubkey={pubkey} />}
|
||||||
|
{tab === "rewards" && <RewardsCard pubkey={pubkey} />}
|
||||||
{tab === "vote-history" && data?.program === "vote" && (
|
{tab === "vote-history" && data?.program === "vote" && (
|
||||||
<VotesCard voteAccount={data.parsed} />
|
<VotesCard voteAccount={data.parsed} />
|
||||||
)}
|
)}
|
||||||
|
@ -24,6 +24,7 @@ import {
|
|||||||
ProgramDataAccountInfo,
|
ProgramDataAccountInfo,
|
||||||
UpgradeableLoaderAccount,
|
UpgradeableLoaderAccount,
|
||||||
} from "validators/accounts/upgradeable-program";
|
} from "validators/accounts/upgradeable-program";
|
||||||
|
import { RewardsProvider } from "./rewards";
|
||||||
export { useAccountHistory } from "./history";
|
export { useAccountHistory } from "./history";
|
||||||
|
|
||||||
export type StakeProgramData = {
|
export type StakeProgramData = {
|
||||||
@ -106,7 +107,9 @@ export function AccountsProvider({ children }: AccountsProviderProps) {
|
|||||||
<DispatchContext.Provider value={dispatch}>
|
<DispatchContext.Provider value={dispatch}>
|
||||||
<TokensProvider>
|
<TokensProvider>
|
||||||
<HistoryProvider>
|
<HistoryProvider>
|
||||||
<FlaggedAccountsProvider>{children}</FlaggedAccountsProvider>
|
<RewardsProvider>
|
||||||
|
<FlaggedAccountsProvider>{children}</FlaggedAccountsProvider>
|
||||||
|
</RewardsProvider>
|
||||||
</HistoryProvider>
|
</HistoryProvider>
|
||||||
</TokensProvider>
|
</TokensProvider>
|
||||||
</DispatchContext.Provider>
|
</DispatchContext.Provider>
|
||||||
|
186
explorer/src/providers/accounts/rewards.tsx
Normal file
186
explorer/src/providers/accounts/rewards.tsx
Normal 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]
|
||||||
|
);
|
||||||
|
}
|
Reference in New Issue
Block a user