diff --git a/explorer/src/components/account/RewardsCard.tsx b/explorer/src/components/account/RewardsCard.tsx
new file mode 100644
index 0000000000..39d2cb3eb5
--- /dev/null
+++ b/explorer/src/components/account/RewardsCard.tsx
@@ -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 ;
+ }
+
+ return ;
+ }
+
+ const rewardsList = rewards.data.rewards.map((reward) => {
+ if (!reward) {
+ return null;
+ }
+
+ return (
+
+ {reward.epoch} |
+
+
+ |
+ {lamportsToSolString(reward.amount)} |
+ {lamportsToSolString(reward.postBalance)} |
+
+ );
+ });
+
+ const { foundOldest } = rewards.data;
+ const fetching = rewards.status === FetchStatus.Fetching;
+
+ return (
+ <>
+
+
+
+
+
+
+
+ Epoch |
+ Effective Slot |
+ Reward Amount |
+ Post Balance |
+
+
+ {rewardsList}
+
+
+
+
+ {foundOldest ? (
+
+ Fetched full reward history
+
+ ) : (
+
+ )}
+
+
+ >
+ );
+}
diff --git a/explorer/src/pages/AccountDetailsPage.tsx b/explorer/src/pages/AccountDetailsPage.tsx
index 30e0fc4510..629f9e9928 100644
--- a/explorer/src/pages/AccountDetailsPage.tsx
+++ b/explorer/src/pages/AccountDetailsPage.tsx
@@ -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" && }
{tab === "instructions" && }
{tab === "largest" && }
+ {tab === "rewards" && }
{tab === "vote-history" && data?.program === "vote" && (
)}
diff --git a/explorer/src/providers/accounts/index.tsx b/explorer/src/providers/accounts/index.tsx
index 8b14e470ff..ebd3829aae 100644
--- a/explorer/src/providers/accounts/index.tsx
+++ b/explorer/src/providers/accounts/index.tsx
@@ -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) {
- {children}
+
+ {children}
+
diff --git a/explorer/src/providers/accounts/rewards.tsx b/explorer/src/providers/accounts/rewards.tsx
new file mode 100644
index 0000000000..4b3517ad05
--- /dev/null
+++ b/explorer/src/providers/accounts/rewards.tsx
@@ -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;
+type Dispatch = Cache.Dispatch;
+
+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(undefined);
+export const DispatchContext = React.createContext(
+ 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 (
+
+
+ {children}
+
+
+ );
+}
+
+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 | 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]
+ );
+}