From 741f99ebb6de23ff4f5c2c201920393082d3f906 Mon Sep 17 00:00:00 2001 From: Josh Date: Thu, 20 May 2021 14:00:48 -0700 Subject: [PATCH] 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 --- .../src/components/account/RewardsCard.tsx | 118 +++++++++++ explorer/src/pages/AccountDetailsPage.tsx | 17 +- explorer/src/providers/accounts/index.tsx | 5 +- explorer/src/providers/accounts/rewards.tsx | 186 ++++++++++++++++++ 4 files changed, 324 insertions(+), 2 deletions(-) create mode 100644 explorer/src/components/account/RewardsCard.tsx create mode 100644 explorer/src/providers/accounts/rewards.tsx 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 ( + <> +
+
+
+
+

Rewards

+
+
+
+ +
+ + + + + + + + + + {rewardsList} +
EpochEffective SlotReward AmountPost Balance
+
+ +
+ {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] + ); +}