diff --git a/package.json b/package.json index 422e1da3a0..cc2352151d 100644 --- a/package.json +++ b/package.json @@ -26,9 +26,9 @@ "@fortawesome/react-fontawesome": "^0.2.0", "@ledgerhq/hw-transport-webhid": "^6.27.19", "@polkadot-cloud/assets": "^0.1.24", - "@polkadot-cloud/core": "^1.0.28", + "@polkadot-cloud/core": "^1.0.29", "@polkadot-cloud/react": "^0.1.97", - "@polkadot-cloud/utils": "^0.0.22", + "@polkadot-cloud/utils": "^0.0.23", "@polkadot/api": "^10.10.1", "@polkadot/keyring": "^12.1.1", "@polkadot/rpc-provider": "^10.9.1", diff --git a/src/Providers.tsx b/src/Providers.tsx index 1272a51858..e29983d323 100644 --- a/src/Providers.tsx +++ b/src/Providers.tsx @@ -48,6 +48,7 @@ import { OtherAccountsProvider } from 'contexts/Connect/OtherAccounts'; import { useActiveAccounts } from 'contexts/ActiveAccounts'; import { DappName } from 'consts'; import { ImportedAccountsProvider } from 'contexts/Connect/ImportedAccounts'; +import { PoolPerformanceProvider } from 'contexts/Pools/PoolPerformance'; // Embed providers from hook. export const Providers = () => { @@ -91,6 +92,7 @@ export const Providers = () => { FavoriteValidatorsProvider, FastUnstakeProvider, PayoutsProvider, + PoolPerformanceProvider, UIProvider, SetupProvider, MenuProvider, diff --git a/src/consts.ts b/src/consts.ts index 2712aee007..ff7511dfd8 100644 --- a/src/consts.ts +++ b/src/consts.ts @@ -54,8 +54,8 @@ export const FallbackEpochDuration = new BigNumber(2400); /* * Misc values */ -export const ListItemsPerPage = 30; -export const ListItemsPerBatch = 30; +export const ListItemsPerPage = 25; +export const ListItemsPerBatch = 25; export const MinBondPrecision = 3; export const MaxPayoutDays = 60; export const MaxEraRewardPointsEras = 14; diff --git a/src/contexts/Pools/BondedPools/index.tsx b/src/contexts/Pools/BondedPools/index.tsx index 338bf441dc..df6330040e 100644 --- a/src/contexts/Pools/BondedPools/index.tsx +++ b/src/contexts/Pools/BondedPools/index.tsx @@ -25,44 +25,20 @@ export const BondedPoolsProvider = ({ }) => { const { network } = useNetwork(); const { api, isReady } = useApi(); - const { getNominationsStatusFromTargets } = useStaking(); const { createAccounts, stats } = usePoolsConfig(); + const { getNominationsStatusFromTargets } = useStaking(); const { lastPoolId } = stats; - // stores the meta data batches for pool lists + // Stores the meta data batches for pool lists. const [poolMetaBatches, setPoolMetaBatch]: AnyMetaBatch = useState({}); const poolMetaBatchesRef = useRef(poolMetaBatches); - // stores the meta batch subscriptions for pool lists + // Stores the meta batch subscriptions for pool lists. const poolSubs = useRef>({}); - // store bonded pools + // Store bonded pools. const [bondedPools, setBondedPools] = useState([]); - // clear existing state for network refresh - useEffectIgnoreInitial(() => { - setBondedPools([]); - setStateWithRef({}, setPoolMetaBatch, poolMetaBatchesRef); - }, [network]); - - // initial setup for fetching bonded pools - useEffectIgnoreInitial(() => { - if (isReady) { - // fetch bonded pools - fetchBondedPools(); - } - return () => { - unsubscribe(); - }; - }, [network, isReady, lastPoolId]); - - // after bonded pools have synced, fetch metabatch - useEffectIgnoreInitial(() => { - if (bondedPools.length) { - fetchPoolsMetaBatch('bonded_pools', bondedPools, true); - } - }, [bondedPools]); - const unsubscribe = () => { Object.values(poolSubs.current).map((batch: Fn[]) => Object.entries(batch).map(([, v]) => v()) @@ -122,12 +98,8 @@ export const BondedPoolsProvider = ({ p: AnyMetaBatch, refetch = false ) => { - if (!isReady || !api) { - return; - } - if (!p.length) { - return; - } + if (!isReady || !api || !p.length) return; + if (!refetch) { // if already exists, do not re-fetch if (poolMetaBatchesRef.current[key] !== undefined) { @@ -344,9 +316,8 @@ export const BondedPoolsProvider = ({ }; const updateBondedPools = (updatedPools: BondedPool[]) => { - if (!updatedPools) { - return; - } + if (!updatedPools) return; + setBondedPools( bondedPools.map( (original) => @@ -365,9 +336,7 @@ export const BondedPoolsProvider = ({ if (!pool) return; const exists = bondedPools.find((b) => b.id === pool.id); - if (!exists) { - setBondedPools(bondedPools.concat(pool)); - } + if (!exists) setBondedPools(bondedPools.concat(pool)); }; // get all the roles belonging to one pool account @@ -461,6 +430,26 @@ export const BondedPoolsProvider = ({ setBondedPools(newBondedPools); }; + // Clear existing state for network refresh. + useEffectIgnoreInitial(() => { + setBondedPools([]); + setStateWithRef({}, setPoolMetaBatch, poolMetaBatchesRef); + }, [network]); + + // Initial setup for fetching bonded pools. + useEffectIgnoreInitial(() => { + if (isReady) fetchBondedPools(); + return () => { + unsubscribe(); + }; + }, [network, isReady, lastPoolId]); + + // After bonded pools have synced, fetch metabatch. + useEffectIgnoreInitial(() => { + if (bondedPools.length) + fetchPoolsMetaBatch('bonded_pools', bondedPools, true); + }, [bondedPools]); + return ( { + const { api } = useApi(); + const { network } = useNetwork(); + const { bondedPools } = useBondedPools(); + const { activeEra } = useNetworkMetrics(); + const { erasRewardPointsFetched, erasRewardPoints } = useValidators(); + + // Store whether pool performance data is being fetched. + const [poolRewardPointsFetched, setPoolRewardPointsFetched] = + useState('unsynced'); + + // Store pool performance data. + const [poolRewardPoints, setPoolRewardPoints] = useState< + Record> + >({}); + + // Store the currently active era being processed for pool performance. + const [currentEra, setCurrentEra] = useState(new BigNumber(0)); + + // Store the earliest era that should be processed. + const [finishEra, setFinishEra] = useState(new BigNumber(0)); + + // Handle worker message on completed exposure check. + worker.onmessage = (message: MessageEvent) => { + if (message) { + const { data } = message; + const { task } = data; + if (task !== 'processNominationPoolsRewardData') return; + + // Update state with new data. + const { poolRewardData } = data; + setPoolRewardPoints(mergeDeep(poolRewardPoints, poolRewardData)); + + if (currentEra.isEqualTo(finishEra)) { + setPoolRewardPointsFetched('synced'); + } else { + const nextEra = BigNumber.max(currentEra.minus(1), 1); + processEra(nextEra); + } + } + }; + + // Start fetching pool performance calls from the current era. + const startGetPoolPerformance = async () => { + setPoolRewardPointsFetched('syncing'); + setFinishEra( + BigNumber.max(activeEra.index.minus(MaxEraRewardPointsEras), 1) + ); + const startEra = BigNumber.max(activeEra.index.minus(1), 1); + processEra(startEra); + }; + + // Get era data and send to worker. + const processEra = async (era: BigNumber) => { + if (!api) return; + setCurrentEra(era); + const result = await api.query.staking.erasStakersClipped.entries( + era.toString() + ); + const exposures = formatRawExposures(result); + worker.postMessage({ + task: 'processNominationPoolsRewardData', + era: era.toString(), + exposures, + bondedPools: bondedPools.map((b) => b.addresses.stash), + erasRewardPoints, + }); + }; + + // Trigger worker to calculate pool reward data for garaphs once: + // + // - active era is synced. + // - era reward points are fetched. + // - bonded pools have been fetched. + // + // Re-calculates when any of the above change. + useEffectIgnoreInitial(() => { + if ( + api && + bondedPools.length && + activeEra.index.isGreaterThan(0) && + erasRewardPointsFetched === 'synced' && + poolRewardPointsFetched === 'unsynced' + ) { + startGetPoolPerformance(); + } + }, [ + bondedPools, + activeEra, + erasRewardPointsFetched, + poolRewardPointsFetched, + ]); + + // Reset state data on network change. + useEffectIgnoreInitial(() => { + setPoolRewardPoints({}); + setCurrentEra(new BigNumber(0)); + setFinishEra(new BigNumber(0)); + setPoolRewardPointsFetched('unsynced'); + }, [network]); + + return ( + + {children} + + ); +}; + +export const PoolPerformanceContext = + React.createContext( + defaultPoolPerformanceContext + ); + +export const usePoolPerformance = () => + React.useContext(PoolPerformanceContext); diff --git a/src/contexts/Pools/PoolPerformance/types.ts b/src/contexts/Pools/PoolPerformance/types.ts new file mode 100644 index 0000000000..acef8792bb --- /dev/null +++ b/src/contexts/Pools/PoolPerformance/types.ts @@ -0,0 +1,10 @@ +// Copyright 2023 @paritytech/polkadot-staking-dashboard authors & contributors +// SPDX-License-Identifier: GPL-3.0-only + +import type { AnyJson } from '@polkadot-cloud/react/types'; +import type { Sync } from 'types'; + +export interface PoolPerformanceContextInterface { + poolRewardPointsFetched: Sync; + poolRewardPoints: AnyJson; +} diff --git a/src/contexts/UI/index.tsx b/src/contexts/UI/index.tsx index a736c42066..6050f890cb 100644 --- a/src/contexts/UI/index.tsx +++ b/src/contexts/UI/index.tsx @@ -132,9 +132,9 @@ export const UIProvider = ({ children }: { children: React.ReactNode }) => { setSideMenuOpen(v); }; - const [containerRefs, _setContainerRefs] = useState({}); + const [containerRefs, setContainerRefsState] = useState({}); const setContainerRefs = (v: any) => { - _setContainerRefs(v); + setContainerRefsState(v); }; return ( diff --git a/src/library/ListItem/Labels/PoolBonded.tsx b/src/library/ListItem/Labels/PoolBonded.tsx index 8d9d2031f4..e2a12a473c 100644 --- a/src/library/ListItem/Labels/PoolBonded.tsx +++ b/src/library/ListItem/Labels/PoolBonded.tsx @@ -77,7 +77,7 @@ export const PoolBonded = ({ return ( <> - +
{nominationStatus === null || !eraStakers.stakers.length ? `${t('syncing')}...` diff --git a/src/library/ListItem/Wrappers.ts b/src/library/ListItem/Wrappers.ts index 58c7dd2b52..58035beb2f 100644 --- a/src/library/ListItem/Wrappers.ts +++ b/src/library/ListItem/Wrappers.ts @@ -9,10 +9,12 @@ export const Wrapper = styled.div` --height-top-row: 3.25rem; --height-bottom-row: 5rem; - &.pool, &.member { --height-bottom-row: 2.75rem; } + &.pool-join { + --height-bottom-row: 7.5rem; + } --height-total: calc(var(--height-top-row) + var(--height-bottom-row)); @@ -139,16 +141,20 @@ export const Labels = styled.div` margin-right: 0; button { - color: var(--accent-color-primary); - background: none; + color: var(--accent-color-secondary); + font-family: InterSemiBold, sans-serif; font-size: 0.95rem; display: flex; flex-flow: row wrap; align-items: center; width: auto; height: auto; - border-radius: none; + border-radius: 0.75rem; + padding: 0.25rem 0.75rem; + &:hover { + opacity: 1; + } > svg { margin-left: 0.3rem; } @@ -338,9 +344,9 @@ export const ValidatorPulseWrapper = styled.div` var(--shimmer-foreground) 100% ); background-repeat: no-repeat; - background-size: 600px 104px; + background-size: 500px 104px; animation-duration: 1.5s; - opacity: 0.15; + opacity: 0.2; animation-fill-mode: forwards; animation-iteration-count: infinite; animation-name: shimmer; diff --git a/src/library/Pool/Rewards.tsx b/src/library/Pool/Rewards.tsx new file mode 100644 index 0000000000..11ac4d90e6 --- /dev/null +++ b/src/library/Pool/Rewards.tsx @@ -0,0 +1,165 @@ +// Copyright 2023 @paritytech/polkadot-staking-dashboard authors & contributors +// SPDX-License-Identifier: GPL-3.0-only + +import BigNumber from 'bignumber.js'; +import { useValidators } from 'contexts/Validators/ValidatorEntries'; +import { + TooltipTrigger, + ValidatorPulseWrapper, +} from 'library/ListItem/Wrappers'; +import { useTooltip } from 'contexts/Tooltip'; +import { MaxEraRewardPointsEras } from 'consts'; +import { useApi } from 'contexts/Api'; +import { + normaliseEraPoints, + prefillEraPoints, +} from 'library/ValidatorList/ValidatorItem/Utils'; +import type { AnyJson } from '@polkadot-cloud/react/types'; +import { usePoolPerformance } from 'contexts/Pools/PoolPerformance'; +import { useTranslation } from 'react-i18next'; +import type { RewardProps, RewardsGraphProps } from './types'; + +export const Rewards = ({ address, displayFor = 'default' }: RewardProps) => { + const { t } = useTranslation('library'); + const { isReady } = useApi(); + const { setTooltipTextAndOpen } = useTooltip(); + const { eraPointsBoundaries } = useValidators(); + const { poolRewardPoints, poolRewardPointsFetched } = usePoolPerformance(); + + const eraRewardPoints = Object.fromEntries( + Object.entries(poolRewardPoints[address] || {}).map(([k, v]: AnyJson) => [ + k, + new BigNumber(v), + ]) + ); + + const high = eraPointsBoundaries?.high || new BigNumber(1); + const normalisedPoints = normaliseEraPoints(eraRewardPoints, high); + const prefilledPoints = prefillEraPoints(Object.values(normalisedPoints)); + + const empty = Object.values(poolRewardPoints).length === 0; + const syncing = !isReady || poolRewardPointsFetched !== 'synced'; + const tooltipText = `${MaxEraRewardPointsEras} ${t('dayPoolPerformance')}`; + + return ( + + {syncing &&
} + setTooltipTextAndOpen(tooltipText)} + /> + + + ); +}; + +export const RewardsGraph = ({ points = [], syncing }: RewardsGraphProps) => { + const totalSegments = points.length - 1; + const vbWidth = 512; + const vbHeight = 115; + const xPadding = 5; + const yPadding = 10; + const xArea = vbWidth - 2 * xPadding; + const yArea = vbHeight - 2 * yPadding; + const xSegment = xArea / totalSegments; + let xCursor = xPadding; + + const pointsCoords = points.map((point: number) => { + const coord = { + x: xCursor, + y: vbHeight - yPadding - yArea * point, + zero: point === 0, + }; + xCursor += xSegment; + return coord; + }); + + const lineCoords = []; + for (let i = 0; i <= pointsCoords.length - 1; i++) { + const startZero = pointsCoords[i].zero; + const endZero = pointsCoords[i + 1]?.zero; + + lineCoords.push({ + x1: pointsCoords[i].x, + y1: pointsCoords[i].y, + x2: pointsCoords[i + 1]?.x || pointsCoords[i].x, + y2: pointsCoords[i + 1]?.y || pointsCoords[i].y, + zero: startZero && endZero, + }); + } + + const barCoords = []; + for (let i = 0; i <= pointsCoords.length - 1; i++) { + barCoords.push({ + x1: pointsCoords[i].x, + y1: vbHeight - yPadding, + x2: pointsCoords[i].x, + y2: pointsCoords[i]?.y, + }); + } + + return ( + + {!syncing && + [{ y1: vbHeight * 0.5, y2: vbHeight * 0.5 }].map( + ({ y1, y2 }, index) => { + return ( + + ); + } + )} + + {!syncing && + barCoords.map(({ x1, y1, x2, y2 }, index) => { + return ( + + ); + })} + + {!syncing && + lineCoords.map(({ x1, y1, x2, y2, zero }, index) => { + return ( + + ); + })} + + ); +}; diff --git a/src/library/Pool/index.tsx b/src/library/Pool/index.tsx index b4780aac49..98ed0be7f7 100644 --- a/src/library/Pool/index.tsx +++ b/src/library/Pool/index.tsx @@ -32,6 +32,7 @@ import { JoinPool } from '../ListItem/Labels/JoinPool'; import { Members } from '../ListItem/Labels/Members'; import { PoolId } from '../ListItem/Labels/PoolId'; import type { PoolProps } from './types'; +import { Rewards } from './Rewards'; export const Pool = ({ pool, batchKey, batchIndex }: PoolProps) => { const { t } = useTranslation('library'); @@ -113,8 +114,15 @@ export const Pool = ({ pool, batchKey, batchIndex }: PoolProps) => { } }; + const displayJoin = + !isPoolSyncing && + state === 'Open' && + !membership && + !isReadOnlyAccount(activeAccount) && + activeAccount; + return ( - +
@@ -125,11 +133,6 @@ export const Pool = ({ pool, batchKey, batchIndex }: PoolProps) => { />
- {currentCommission > 0 && ( - - )} - -
-
- - {!isPoolSyncing && - state === 'Open' && - !membership && - !isReadOnlyAccount(activeAccount) && - activeAccount && ( - +
+
+ +
+
+ + {currentCommission > 0 && ( + + )} + + + + + {displayJoin && ( + )} +
diff --git a/src/library/Pool/types.ts b/src/library/Pool/types.ts index 6c463b447f..acaf7ae0b0 100644 --- a/src/library/Pool/types.ts +++ b/src/library/Pool/types.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: GPL-3.0-only import type { PoolAddresses, PoolRoles, PoolState } from 'contexts/Pools/types'; +import type { DisplayFor } from 'types'; export interface PoolProps { pool: Pool; @@ -17,3 +18,13 @@ export interface Pool { state: PoolState; roles: PoolRoles; } + +export interface RewardProps { + address: string; + displayFor?: DisplayFor; +} + +export interface RewardsGraphProps { + points: number[]; + syncing: boolean; +} diff --git a/src/locale/cn/library.json b/src/locale/cn/library.json index 025425559a..1da62b1e90 100644 --- a/src/locale/cn/library.json +++ b/src/locale/cn/library.json @@ -48,6 +48,7 @@ "dayAverage": "日平均值", "dayPerformance": "天内表现", "dayPerformanceStanding": "天内表现排名", + "dayPoolPerformance": "天内提名池表现", "destroying": "销毁中", "destroyingPools": "正在销毁提名池", "disclaimer": "免责声明", @@ -177,7 +178,7 @@ "validAddress": "有效地址", "validatingParachainBlocks": "验证平行链区块", "validatorCommission": "验证人佣金", - "validatorPerformance": "{{count}}天验证人性能", + "validatorPerformance": "{{count}}天内验证人表现", "valueTooSmall": "值太小", "viewDecentralization": "分布式指标", "viewMetrics": "验证人指标", diff --git a/src/locale/en/library.json b/src/locale/en/library.json index 5cd8212ba4..7e68eab8aa 100644 --- a/src/locale/en/library.json +++ b/src/locale/en/library.json @@ -48,6 +48,7 @@ "dayAverage": "Day Average", "dayPerformance": "Day Performance", "dayPerformanceStanding": "Day Performance Standing", + "dayPoolPerformance": "Day Pool Performance", "destroying": "Destroying", "destroyingPools": "Destroying Pools", "disclaimer": "Disclaimer", diff --git a/src/workers/poolPerformance.ts b/src/workers/poolPerformance.ts new file mode 100644 index 0000000000..ad4b46af4d --- /dev/null +++ b/src/workers/poolPerformance.ts @@ -0,0 +1,63 @@ +// Copyright 2023 @paritytech/polkadot-staking-dashboard authors & contributors +// SPDX-License-Identifier: GPL-3.0-only +/* eslint-disable no-await-in-loop */ + +import type { Exposure } from 'contexts/Staking/types'; +import type { ErasRewardPoints } from 'contexts/Validators/types'; +import type { AnyApi, AnyJson } from 'types'; + +// eslint-disable-next-line no-restricted-globals +export const ctx: Worker = self as any; + +// handle incoming message and route to correct handler. +ctx.addEventListener('message', async (event: AnyJson) => { + const { data } = event; + const { task } = data; + let message: AnyJson = {}; + switch (task) { + case 'processNominationPoolsRewardData': + message = await processErasStakersForNominationPoolRewards(data); + break; + default: + } + postMessage({ task, ...message }); +}); + +// Process `erasStakersClipped` and generate nomination pool reward data. +const processErasStakersForNominationPoolRewards = async ({ + bondedPools, + era, + erasRewardPoints, + exposures, +}: { + bondedPools: string[]; + era: string; + erasRewardPoints: ErasRewardPoints; + exposures: Exposure[]; +}) => { + const poolRewardData: Record> = {}; + + for (const address of bondedPools) { + let validator = null; + for (const exposure of exposures) { + const { others } = exposure.val; + const inOthers = others.find((o: AnyApi) => o.who === address); + + if (inOthers) { + validator = exposure.keys[1]; + break; + } + } + + if (validator) { + const rewardPoints: string = + erasRewardPoints[era]?.individual?.[validator || ''] ?? 0; + if (!poolRewardData[address]) poolRewardData[address] = {}; + poolRewardData[address][era] = rewardPoints; + } + } + + return { + poolRewardData, + }; +}; diff --git a/src/workers/stakers.ts b/src/workers/stakers.ts index 80beb59a5f..4353bae77b 100644 --- a/src/workers/stakers.ts +++ b/src/workers/stakers.ts @@ -202,5 +202,3 @@ const processExposures = (data: DataInitialiseExposures) => { who: activeAccount, }; }; - -export default null as any; diff --git a/yarn.lock b/yarn.lock index 66f1c6bb60..f83704f514 100644 --- a/yarn.lock +++ b/yarn.lock @@ -588,6 +588,11 @@ resolved "https://registry.yarnpkg.com/@polkadot-cloud/core/-/core-1.0.28.tgz#cb9351f27cb8c07b58433dcc9c00d4364a97028b" integrity sha512-5MyZc5vIQDoNzRWYbRZ9EO1Xqtzu3GXFZjCaprCMZ2lxY6VmseXarnOMV1K20lR3mGU4wBFv6bKEmm09eMrpTw== +"@polkadot-cloud/core@^1.0.29": + version "1.0.29" + resolved "https://registry.yarnpkg.com/@polkadot-cloud/core/-/core-1.0.29.tgz#c6b5a6c739872f5e814e47a193c9c5e829dedbcc" + integrity sha512-HhfowiWIcR1dLZu9KtMJMm6N1ySP2bkiq6aPaCepxn/gszn8tfpEZMw2X9EX++G2ZWM1zggQXs/05i6iPNaLeA== + "@polkadot-cloud/react@^0.1.97": version "0.1.97" resolved "https://registry.yarnpkg.com/@polkadot-cloud/react/-/react-0.1.97.tgz#40c9f077e2e79c6ecead503898598d17ced2cc76" @@ -616,6 +621,15 @@ "@polkadot/util" "^12.5.1" bignumber.js "^9.1.1" +"@polkadot-cloud/utils@^0.0.23": + version "0.0.23" + resolved "https://registry.yarnpkg.com/@polkadot-cloud/utils/-/utils-0.0.23.tgz#eb703f74c7f05f763f7624727ba83589131ae1c5" + integrity sha512-IWqtn/cBgxletCH+MqBwZhxPxEbFLhfCU6L4RmCSCoes0bC1xUHMtFevPiJuaEkXQTdFvOnosA8BbSI01/GUuA== + dependencies: + "@polkadot/keyring" "^12.5.1" + "@polkadot/util" "^12.5.1" + bignumber.js "^9.1.1" + "@polkadot/api-augment@10.10.1": version "10.10.1" resolved "https://registry.yarnpkg.com/@polkadot/api-augment/-/api-augment-10.10.1.tgz#d3d296c923b0ff915c8d4f163e9b3bad70b89b9b"