From 690ba51e7b8cfc7285fbdb90748362d85e848460 Mon Sep 17 00:00:00 2001 From: Ross Bulat Date: Wed, 18 Oct 2023 04:26:56 +0100 Subject: [PATCH] feat: store `eraRewardPoints` from recent eras (#1529) --- src/consts.ts | 1 + src/contexts/Validators/Utils.ts | 45 +++- .../Validators/ValidatorEntries/defaults.ts | 1 + .../Validators/ValidatorEntries/index.tsx | 227 ++++++++++++------ src/contexts/Validators/types.ts | 8 + 5 files changed, 213 insertions(+), 69 deletions(-) diff --git a/src/consts.ts b/src/consts.ts index 8266d01ede..d8632fc3f4 100644 --- a/src/consts.ts +++ b/src/consts.ts @@ -58,6 +58,7 @@ export const ListItemsPerPage = 50; export const ListItemsPerBatch = 30; export const MinBondPrecision = 3; export const MaxPayoutDays = 60; +export const MaxEraRewardPointsEras = 7; /* * Third party API keys and endpoints diff --git a/src/contexts/Validators/Utils.ts b/src/contexts/Validators/Utils.ts index e922fe113e..dd496f08e3 100644 --- a/src/contexts/Validators/Utils.ts +++ b/src/contexts/Validators/Utils.ts @@ -1,9 +1,10 @@ // Copyright 2023 @paritytech/polkadot-staking-dashboard authors & contributors // SPDX-License-Identifier: GPL-3.0-only -import type BigNumber from 'bignumber.js'; +import BigNumber from 'bignumber.js'; import type { LocalMeta } from 'contexts/FastUnstake/types'; import type { + EraRewardPoints, LocalValidatorEntriesData, Validator, } from 'contexts/Validators/types'; @@ -94,3 +95,45 @@ export const validateLocalExposure = ( checked: localChecked, }; }; + +// Check if era reward points entry exists for an era. +export const hasLocalEraRewardPoints = (network: NetworkName, era: string) => { + const current = JSON.parse( + localStorage.getItem(`${network}_era_reward_points`) || '{}' + ); + return !!current?.[era]; +}; + +// Get local era reward points entry for an era. +export const getLocalEraRewardPoints = (network: NetworkName, era: string) => { + const current = JSON.parse( + localStorage.getItem(`${network}_era_reward_points`) || '{}' + ); + return current?.[era] || {}; +}; + +// Set local era reward points entry for an era. +export const setLocalEraRewardPoints = ( + network: NetworkName, + era: string, + eraRewardPoints: EraRewardPoints | null, + endEra: string +) => { + const current = JSON.parse( + localStorage.getItem(`${network}_era_reward_points`) || '{}' + ); + + const removeStaleEras = Object.fromEntries( + Object.entries(current || {}).filter(([k]: [string, unknown]) => + new BigNumber(k).isGreaterThanOrEqualTo(endEra) + ) + ); + + localStorage.setItem( + `${network}_era_reward_points`, + JSON.stringify({ + ...removeStaleEras, + [era]: eraRewardPoints, + }) + ); +}; diff --git a/src/contexts/Validators/ValidatorEntries/defaults.ts b/src/contexts/Validators/ValidatorEntries/defaults.ts index d98d5cb6f3..a9a437a094 100644 --- a/src/contexts/Validators/ValidatorEntries/defaults.ts +++ b/src/contexts/Validators/ValidatorEntries/defaults.ts @@ -22,5 +22,6 @@ export const defaultValidatorsContext: ValidatorsContextInterface = { nominated: null, poolNominated: null, validatorCommunity: [], + erasRewardPoints: {}, validatorsFetched: 'unsynced', }; diff --git a/src/contexts/Validators/ValidatorEntries/index.tsx b/src/contexts/Validators/ValidatorEntries/index.tsx index 16512f278e..3e3733800c 100644 --- a/src/contexts/Validators/ValidatorEntries/index.tsx +++ b/src/contexts/Validators/ValidatorEntries/index.tsx @@ -1,11 +1,11 @@ // Copyright 2023 @paritytech/polkadot-staking-dashboard authors & contributors // SPDX-License-Identifier: GPL-3.0-only -import { greaterThanZero, shuffle } from '@polkadot-cloud/utils'; +import { greaterThanZero, rmCommas, shuffle } from '@polkadot-cloud/utils'; import BigNumber from 'bignumber.js'; import React, { useEffect, useRef, useState } from 'react'; import { ValidatorCommunity } from '@polkadot-cloud/assets/validators'; -import type { AnyApi, Fn, Sync } from 'types'; +import type { AnyApi, AnyJson, Fn, Sync } from 'types'; import { useEffectIgnoreInitial } from '@polkadot-cloud/react/hooks'; import { useBonded } from 'contexts/Bonded'; import { useNetworkMetrics } from 'contexts/NetworkMetrics'; @@ -13,7 +13,10 @@ import { useActivePools } from 'contexts/Pools/ActivePools'; import { useNetwork } from 'contexts/Network'; import { useApi } from 'contexts/Api'; import { useActiveAccounts } from 'contexts/ActiveAccounts'; +import { MaxEraRewardPointsEras } from 'consts'; import type { + EraRewardPoints, + ErasRewardPoints, Identity, Validator, ValidatorAddresses, @@ -21,7 +24,13 @@ import type { ValidatorsContextInterface, } from '../types'; import { defaultValidatorsData, defaultValidatorsContext } from './defaults'; -import { getLocalEraValidators, setLocalEraValidators } from '../Utils'; +import { + getLocalEraRewardPoints, + getLocalEraValidators, + hasLocalEraRewardPoints, + setLocalEraRewardPoints, + setLocalEraValidators, +} from '../Utils'; export const ValidatorsProvider = ({ children, @@ -54,7 +63,6 @@ export const ValidatorsProvider = ({ // Stores the currently active validator set. const [sessionValidators, setSessionValidators] = useState([]); - const sessionUnsub = useRef(); // Stores the currently active parachain validator set. const [sessionParaValidators, setSessionParaValidators] = useState( @@ -72,75 +80,114 @@ export const ValidatorsProvider = ({ const [poolNominated, setPoolNominated] = useState(null); // Stores a randomised validator community dataset. - const [validatorCommunity] = useState([...shuffle(ValidatorCommunity)]); + const [validatorCommunity] = useState([...shuffle(ValidatorCommunity)]); - // Reset validators list on network change. - useEffectIgnoreInitial(() => { - setValidatorsFetched('unsynced'); - setSessionValidators([]); - setSessionParaValidators([]); - setAvgCommission(0); - setValidators([]); - setValidatorIdentities({}); - setValidatorSupers({}); - }, [network]); + // Store era reward points, keyed by era. + const [erasRewardPoints, setErasRewardPoints] = useState( + {} + ); - // fetch validators and session validators when activeEra ready - useEffectIgnoreInitial(() => { - if (isReady && activeEra.index.isGreaterThan(0)) { - fetchValidators(); - subscribeSessionValidators(); - } - }, [isReady, activeEra]); + // Processes reward points for a given era. + const processEraRewardPoints = ( + result: AnyJson, + era: BigNumber, + endEra: BigNumber + ) => { + if (!api || erasRewardPoints[era.toString()]) return false; + + const formatted = { + total: rmCommas(result.total), + individual: Object.fromEntries( + Object.entries(result.individual).map(([key, value]) => [ + key, + rmCommas(value as string), + ]) + ), + }; - // fetch parachain session validators when `earliestStoredSession` ready - useEffectIgnoreInitial(() => { - if (isReady && greaterThanZero(earliestStoredSession)) { - subscribeParachainValidators(); - } - }, [isReady, earliestStoredSession]); + // Store era in local storage. + setLocalEraRewardPoints( + network, + era.toString(), + formatted, + endEra.toString() + ); - // fetch active account's nominations in validator list format - useEffectIgnoreInitial(() => { - if (isReady && activeAccount) { - fetchNominatedList(); + return formatted; + }; + + // Fetches era reward points for eligible eras. + const fetchErasRewardPoints = async () => { + if (activeEra.index.isZero() || !api) return; + + // start fetching from the current era. + let currentEra = activeEra.index; + const endEra = BigNumber.max( + currentEra.minus(MaxEraRewardPointsEras - 1), + 1 + ); + + // Introduce additional safeguard againt looping forever. + const totalEras = new BigNumber(MaxEraRewardPointsEras); + let erasProcessed = new BigNumber(0); + + // Iterate eras and process reward points. + const calls = []; + const eras = []; + const localEras: Record = {}; + do { + // If already in local storage, get data from there. Otherwise, add to calls. + if (hasLocalEraRewardPoints(network, currentEra.toString())) { + localEras[currentEra.toString()] = getLocalEraRewardPoints( + network, + currentEra.toString() + ); + } else { + calls.push(api.query.staking.erasRewardPoints(currentEra.toString())); + eras.push(currentEra); + } + + currentEra = currentEra.minus(1); + erasProcessed = erasProcessed.plus(1); + } while ( + currentEra.isGreaterThanOrEqualTo(endEra) && + erasProcessed.isLessThan(totalEras) + ); + + // Make calls and format reward point results. + const newErasRewardPoints: ErasRewardPoints = {}; + let i = 0; + for (const result of await Promise.all(calls)) { + const formatted = processEraRewardPoints( + result.toHuman(), + eras[i], + endEra + ); + if (formatted) newErasRewardPoints[eras[i].toString()] = formatted; + i++; } - }, [isReady, activeAccount, bondedAccounts]); + // Commit results to state. + setErasRewardPoints({ + ...localEras, + ...newErasRewardPoints, + }); + }; + + // Fetches the active account's nominees. const fetchNominatedList = async () => { if (!activeAccount) return; - // get raw targets list - const targets = getAccountNominations(activeAccount); - // format to list format - const targetsFormatted = targets.map((item: any) => ({ address: item })); + const targetsFormatted = getAccountNominations(activeAccount).map( + (item) => ({ address: item }) + ); // fetch preferences const nominationsWithPrefs = await fetchValidatorPrefs(targetsFormatted); setNominated(nominationsWithPrefs || []); }; - // fetch active account's pool nominations in validator list format - useEffectIgnoreInitial(() => { - if (isReady && poolNominations) { - fetchPoolNominatedList(); - } - }, [isReady, poolNominations]); - - // Unsubscribe on network change and component unmount. - useEffect(() => { - if (sessionValidators.length) { - sessionUnsub.current?.(); - } - if (sessionParaValidators.length) { - sessionParaUnsub.current?.(); - } - return () => { - sessionUnsub.current?.(); - sessionParaUnsub.current?.(); - }; - }, [network]); - + // Fetches the active pool's nominees. const fetchPoolNominatedList = async () => { // get raw nominations list let n = poolNominations.targets; @@ -165,11 +212,9 @@ export const ValidatorsProvider = ({ const prefs = p.toHuman(); const commission = new BigNumber(prefs.commission.replace(/%/g, '')); - if (!commission.isEqualTo(100)) { + if (!commission.isEqualTo(100)) totalNonAllCommission = totalNonAllCommission.plus(commission); - } else { - notFullCommissionCount++; - } + else notFullCommissionCount++; entries.push({ address, @@ -238,12 +283,10 @@ export const ValidatorsProvider = ({ }; // Subscribe to active session validators. - const subscribeSessionValidators = async () => { + const fetchSessionValidators = async () => { if (!api || !isReady) return; - const unsub: AnyApi = await api.query.session.validators((v: AnyApi) => { - setSessionValidators(v.toHuman()); - sessionUnsub.current = unsub; - }); + const sessionValidatorsRaw: AnyApi = await api.query.session.validators(); + setSessionValidators(sessionValidatorsRaw.toHuman()); }; // Subscribe to active parachain validators. @@ -331,10 +374,57 @@ export const ValidatorsProvider = ({ }, ]) ); - return supersWithIdentity; }; + // Reset validator state data on network change. + useEffectIgnoreInitial(() => { + setValidatorsFetched('unsynced'); + setSessionValidators([]); + setSessionParaValidators([]); + setAvgCommission(0); + setValidators([]); + setValidatorIdentities({}); + setValidatorSupers({}); + setErasRewardPoints({}); + }, [network]); + + // Fetch validators, session validators, and era reward points when `activeEra` ready. + useEffectIgnoreInitial(() => { + if (isReady && activeEra.index.isGreaterThan(0)) { + fetchValidators(); + fetchErasRewardPoints(); + fetchSessionValidators(); + } + }, [isReady, activeEra]); + + // Fetch parachain session validators when `earliestStoredSession` ready. + useEffectIgnoreInitial(() => { + if (isReady && greaterThanZero(earliestStoredSession)) + subscribeParachainValidators(); + }, [isReady, earliestStoredSession]); + + // Fetch active account's nominations in validator list format. + useEffectIgnoreInitial(() => { + if (isReady && activeAccount) { + fetchNominatedList(); + } + }, [isReady, activeAccount, bondedAccounts]); + + // Fetch active account's pool nominations in validator list format. + useEffectIgnoreInitial(() => { + if (isReady && poolNominations) fetchPoolNominatedList(); + }, [isReady, poolNominations]); + + // Unsubscribe on network change and component unmount. + useEffect(() => { + if (sessionParaValidators.length) sessionParaUnsub.current?.(); + + return () => { + sessionParaUnsub.current?.(); + }; + }, [network]); + return ( diff --git a/src/contexts/Validators/types.ts b/src/contexts/Validators/types.ts index f5a503f44d..6d2cfaeabd 100644 --- a/src/contexts/Validators/types.ts +++ b/src/contexts/Validators/types.ts @@ -14,6 +14,7 @@ export interface ValidatorsContextInterface { nominated: Validator[] | null; poolNominated: Validator[] | null; validatorCommunity: any[]; + erasRewardPoints: ErasRewardPoints; validatorsFetched: Sync; } @@ -54,3 +55,10 @@ export interface LocalValidatorEntriesData { era: string; entries: Validator[]; } + +export type ErasRewardPoints = Record; + +export interface EraRewardPoints { + total: string; + individual: Record; +}