From 5e36d3ae97177b19fc4875a891958b70186b0781 Mon Sep 17 00:00:00 2001 From: Ross Bulat Date: Sun, 15 Dec 2024 22:59:00 +0700 Subject: [PATCH] feat: Nominator Rewards from Staking API, discontinue Subscan nominator rewards (#2365) --- packages/app/src/Router.tsx | 46 +- packages/app/src/StakingApi.tsx | 38 ++ packages/app/src/contexts/Payouts/Utils.ts | 99 ----- packages/app/src/contexts/Payouts/defaults.ts | 10 +- packages/app/src/contexts/Payouts/index.tsx | 401 +----------------- packages/app/src/contexts/Payouts/types.ts | 21 +- packages/app/src/contexts/Validators/types.ts | 8 + packages/app/src/controllers/Subscan/index.ts | 171 +------- packages/app/src/controllers/Subscan/types.ts | 47 +- .../app/src/hooks/useAccountFromUrl/index.tsx | 35 ++ .../app/src/hooks/useSubscanData/index.tsx | 75 ++-- packages/app/src/library/Graphs/PayoutBar.tsx | 68 ++- .../app/src/library/Graphs/PayoutLine.tsx | 42 +- packages/app/src/library/Graphs/Utils.ts | 296 +++++++------ packages/app/src/library/Graphs/types.ts | 20 +- packages/app/src/library/Headers/Sync.tsx | 18 +- .../app/src/library/StatusLabel/index.tsx | 1 - .../src/overlay/modals/ClaimPayouts/Forms.tsx | 81 ++-- .../src/overlay/modals/ClaimPayouts/Item.tsx | 12 +- .../overlay/modals/ClaimPayouts/Overview.tsx | 29 +- .../src/overlay/modals/ClaimPayouts/Utils.ts | 9 +- .../src/overlay/modals/ClaimPayouts/index.tsx | 4 +- .../src/overlay/modals/ClaimPayouts/types.ts | 4 +- .../Active/Status/UnclaimedPayoutsStatus.tsx | 29 +- .../pages/Nominate/Active/Status/index.tsx | 6 +- .../pages/Overview/Payouts/ActiveGraph.tsx | 75 ++++ .../pages/Overview/Payouts/InactiveGraph.tsx | 39 ++ .../{Payouts.tsx => Payouts/index.tsx} | 77 ++-- packages/app/src/pages/Overview/index.tsx | 2 - .../app/src/pages/Payouts/ActiveGraph.tsx | 81 ++++ .../app/src/pages/Payouts/InactiveGraph.tsx | 26 ++ .../src/pages/Payouts/PayoutList/index.tsx | 53 +-- packages/app/src/pages/Payouts/index.tsx | 87 ++-- packages/app/src/workers/stakers.ts | 2 +- packages/app/tests/graphs.test.ts | 58 ++- packages/locales/src/resources/cn/pages.json | 3 + packages/locales/src/resources/en/pages.json | 3 + packages/plugin-staking-api/package.json | 4 + packages/plugin-staking-api/src/index.tsx | 2 + .../src/queries/useRewards.tsx | 33 ++ .../src/queries/useTokenPrice.tsx | 4 +- .../src/queries/useUnclaimedRewards.tsx | 37 ++ packages/plugin-staking-api/src/types.ts | 44 +- 43 files changed, 958 insertions(+), 1242 deletions(-) create mode 100644 packages/app/src/StakingApi.tsx delete mode 100644 packages/app/src/contexts/Payouts/Utils.ts create mode 100644 packages/app/src/hooks/useAccountFromUrl/index.tsx create mode 100644 packages/app/src/pages/Overview/Payouts/ActiveGraph.tsx create mode 100644 packages/app/src/pages/Overview/Payouts/InactiveGraph.tsx rename packages/app/src/pages/Overview/{Payouts.tsx => Payouts/index.tsx} (64%) create mode 100644 packages/app/src/pages/Payouts/ActiveGraph.tsx create mode 100644 packages/app/src/pages/Payouts/InactiveGraph.tsx create mode 100644 packages/plugin-staking-api/src/queries/useRewards.tsx create mode 100644 packages/plugin-staking-api/src/queries/useUnclaimedRewards.tsx diff --git a/packages/app/src/Router.tsx b/packages/app/src/Router.tsx index cb139e817b..4ddb988a83 100644 --- a/packages/app/src/Router.tsx +++ b/packages/app/src/Router.tsx @@ -1,14 +1,13 @@ // Copyright 2024 @polkadot-cloud/polkadot-staking-dashboard authors & contributors // SPDX-License-Identifier: GPL-3.0-only -import { extractUrlValue } from '@w3ux/utils' import { PagesConfig } from 'config/pages' import { useActiveAccounts } from 'contexts/ActiveAccounts' -import { useImportedAccounts } from 'contexts/Connect/ImportedAccounts' -import { useOtherAccounts } from 'contexts/Connect/OtherAccounts' import { useNetwork } from 'contexts/Network' +import { usePlugins } from 'contexts/Plugins' +import { useStaking } from 'contexts/Staking' import { useUi } from 'contexts/UI' -import { Notifications } from 'controllers/Notifications' +import { useAccountFromUrl } from 'hooks/useAccountFromUrl' import { ErrorFallbackApp, ErrorFallbackRoutes } from 'library/ErrorBoundary' import { Headers } from 'library/Headers' import { Help } from 'library/Help' @@ -23,7 +22,6 @@ import { Tooltip } from 'library/Tooltip' import { Overlays } from 'overlay' import { useEffect, useRef } from 'react' import { ErrorBoundary } from 'react-error-boundary' -import { useTranslation } from 'react-i18next' import { HashRouter, Navigate, @@ -31,54 +29,40 @@ import { Routes, useLocation, } from 'react-router-dom' +import { StakingApi } from 'StakingApi' import { Body, Main } from 'ui-structure' const RouterInner = () => { - const { t } = useTranslation() const { network } = useNetwork() + const { inSetup } = useStaking() const { pathname } = useLocation() const { setContainerRefs } = useUi() - const { accounts } = useImportedAccounts() - const { accountsInitialised } = useOtherAccounts() - const { activeAccount, setActiveAccount } = useActiveAccounts() + const { pluginEnabled } = usePlugins() + const { activeAccount } = useActiveAccounts() - // References to outer container. + // References to outer container const mainInterfaceRef = useRef(null) - // Scroll to top of the window on every page change or network change. + // Scroll to top of the window on every page change or network change useEffect(() => { window.scrollTo(0, 0) }, [pathname, network]) - // Set container references to UI context and make available throughout app. + // Set container references to UI context and make available throughout app useEffect(() => { setContainerRefs({ mainInterface: mainInterfaceRef, }) }, []) - // Open default account modal if url var present and accounts initialised. - useEffect(() => { - if (accountsInitialised) { - const aUrl = extractUrlValue('a') - if (aUrl) { - const account = accounts.find((a) => a.address === aUrl) - if (account && aUrl !== activeAccount) { - setActiveAccount(account.address || null) - - Notifications.emit({ - title: t('accountConnected', { ns: 'library' }), - subtitle: `${t('connectedTo', { ns: 'library' })} ${ - account.name || aUrl - }.`, - }) - } - } - } - }, [accountsInitialised]) + // Support active account from url + useAccountFromUrl() return ( + {pluginEnabled('staking_api') && !inSetup() && activeAccount && ( + + )} diff --git a/packages/app/src/StakingApi.tsx b/packages/app/src/StakingApi.tsx new file mode 100644 index 0000000000..fde0b032ac --- /dev/null +++ b/packages/app/src/StakingApi.tsx @@ -0,0 +1,38 @@ +// Copyright 2024 @polkadot-cloud/polkadot-staking-dashboard authors & contributors +// SPDX-License-Identifier: GPL-3.0-only + +import { useApi } from 'contexts/Api' +import { useNetwork } from 'contexts/Network' +import { usePayouts } from 'contexts/Payouts' +import { ApolloProvider, client, useUnclaimedRewards } from 'plugin-staking-api' +import { useEffect } from 'react' + +interface Props { + activeAccount: string +} + +const Inner = ({ activeAccount }: Props) => { + const { activeEra } = useApi() + const { network } = useNetwork() + const { setUnclaimedRewards } = usePayouts() + + const { data, loading, error } = useUnclaimedRewards({ + chain: network, + who: activeAccount, + fromEra: Math.max(activeEra.index.minus(1).toNumber(), 0), + }) + + useEffect(() => { + if (!loading && !error && data?.unclaimedRewards) { + setUnclaimedRewards(data?.unclaimedRewards) + } + }, [data?.unclaimedRewards.total]) + + return null +} + +export const StakingApi = (props: Props) => ( + + + +) diff --git a/packages/app/src/contexts/Payouts/Utils.ts b/packages/app/src/contexts/Payouts/Utils.ts deleted file mode 100644 index 4decdb6fb5..0000000000 --- a/packages/app/src/contexts/Payouts/Utils.ts +++ /dev/null @@ -1,99 +0,0 @@ -// Copyright 2024 @polkadot-cloud/polkadot-staking-dashboard authors & contributors -// SPDX-License-Identifier: GPL-3.0-only - -import type { AnyJson } from '@w3ux/types' -import BigNumber from 'bignumber.js' -import type { NetworkId } from 'common-types' -import type { LocalValidatorExposure } from './types' - -// Check if local exposure entry exists for an era. -export const hasLocalEraExposure = ( - network: NetworkId, - era: string, - who: string -) => { - const current = JSON.parse( - localStorage.getItem(`${network}_era_exposures`) || '{}' - ) - return !!current?.[who]?.[era] -} - -// Get local exposure entry for an era. -export const getLocalEraExposure = ( - network: NetworkId, - era: string, - who: string -) => { - const current = JSON.parse( - localStorage.getItem(`${network}_era_exposures`) || '{}' - ) - return current?.[who]?.[era] || [] -} - -// Set local exposure entry for an era. -export const setLocalEraExposure = ( - network: NetworkId, - era: string, - who: string, - exposedValidators: Record | null, - endEra: string -) => { - const current = JSON.parse( - localStorage.getItem(`${network}_era_exposures`) || '{}' - ) - - const whoRemoveStaleEras = Object.fromEntries( - Object.entries(current[who] || {}).filter(([k]: AnyJson) => - new BigNumber(k).isGreaterThanOrEqualTo(endEra) - ) - ) - - localStorage.setItem( - `${network}_era_exposures`, - JSON.stringify({ - ...current, - [who]: { - ...whoRemoveStaleEras, - [era]: exposedValidators, - }, - }) - ) -} - -// Get unclaimed payouts for an account. -export const getLocalUnclaimedPayouts = (network: NetworkId, who: string) => { - const current = JSON.parse( - localStorage.getItem(`${network}_unclaimed_payouts`) || '{}' - ) - return current?.[who] || {} -} - -// Set local unclaimed payouts for an account. -export const setLocalUnclaimedPayouts = ( - network: NetworkId, - era: string, - who: string, - unclaimdPayouts: Record, - endEra: string -) => { - const current = JSON.parse( - localStorage.getItem(`${network}_unclaimed_payouts`) || '{}' - ) - - const whoRemoveStaleEras = Object.fromEntries( - Object.entries(current[who] || {}).filter(([k]: AnyJson) => - new BigNumber(k).isGreaterThanOrEqualTo(endEra) - ) - ) - - localStorage.setItem( - `${network}_unclaimed_payouts`, - JSON.stringify({ - ...current, - [who]: { - ...whoRemoveStaleEras, - [era]: unclaimdPayouts, - }, - }) - ) -} diff --git a/packages/app/src/contexts/Payouts/defaults.ts b/packages/app/src/contexts/Payouts/defaults.ts index 6b458f6fab..6d44d53df5 100644 --- a/packages/app/src/contexts/Payouts/defaults.ts +++ b/packages/app/src/contexts/Payouts/defaults.ts @@ -4,10 +4,12 @@ import type { PayoutsContextInterface } from './types' -export const MaxSupportedPayoutEras = 7 +export const defaultUnclaimedRewards = { + total: '0', + entries: [], +} export const defaultPayoutsContext: PayoutsContextInterface = { - payoutsSynced: 'unsynced', - unclaimedPayouts: null, - removeEraPayout: (era, validator) => {}, + unclaimedRewards: defaultUnclaimedRewards, + setUnclaimedRewards: (unclaimedRewards) => {}, } diff --git a/packages/app/src/contexts/Payouts/index.tsx b/packages/app/src/contexts/Payouts/index.tsx index f32cd98563..973bf416be 100644 --- a/packages/app/src/contexts/Payouts/index.tsx +++ b/packages/app/src/contexts/Payouts/index.tsx @@ -1,37 +1,11 @@ // Copyright 2024 @polkadot-cloud/polkadot-staking-dashboard authors & contributors // SPDX-License-Identifier: GPL-3.0-only -import type { AnyJson, Sync } from '@w3ux/types' -import { setStateWithRef } from '@w3ux/utils' -import { ClaimedRewards } from 'api/query/claimedRewards' -import { ErasRewardPoints } from 'api/query/erasRewardPoints' -import { ErasValidatorReward } from 'api/query/erasValidatorReward' -import { ValidatorPrefs } from 'api/query/validatorPrefs' -import { BondedMulti } from 'api/queryMulti/bondedMulti' -import BigNumber from 'bignumber.js' -import type { AnyApi } from 'common-types' -import { useActiveAccounts } from 'contexts/ActiveAccounts' -import { useApi } from 'contexts/Api' -import { useNetwork } from 'contexts/Network' -import { useStaking } from 'contexts/Staking' +import type { UnclaimedRewards } from 'plugin-staking-api/types' import type { ReactNode } from 'react' -import { createContext, useContext, useEffect, useRef, useState } from 'react' -import { perbillToPercent } from 'utils' -import Worker from 'workers/stakers?worker' -import { MaxSupportedPayoutEras, defaultPayoutsContext } from './defaults' -import type { - LocalValidatorExposure, - PayoutsContextInterface, - UnclaimedPayouts, -} from './types' -import { - getLocalEraExposure, - hasLocalEraExposure, - setLocalEraExposure, - setLocalUnclaimedPayouts, -} from './Utils' - -const worker = new Worker() +import { createContext, useContext, useState } from 'react' +import { defaultPayoutsContext } from './defaults' +import type { PayoutsContextInterface } from './types' export const PayoutsContext = createContext( defaultPayoutsContext @@ -40,370 +14,17 @@ export const PayoutsContext = createContext( export const usePayouts = () => useContext(PayoutsContext) export const PayoutsProvider = ({ children }: { children: ReactNode }) => { - const { network } = useNetwork() - const { consts, activeEra } = useApi() - const { activeAccount } = useActiveAccounts() - const { isNominating, fetchEraStakers } = useStaking() - const { maxExposurePageSize } = consts - - // Store active accont's payout state. - const [unclaimedPayouts, setUnclaimedPayouts] = - useState(null) - - // Track whether payouts have been fetched. - const [payoutsSynced, setPayoutsSynced] = useState('unsynced') - const payoutsSyncedRef = useRef(payoutsSynced) - - // Calculate eras to check for pending payouts. - const getErasInterval = () => { - const startEra = activeEra?.index.minus(1) || new BigNumber(1) - const endEra = BigNumber.max( - startEra.minus(MaxSupportedPayoutEras).plus(1), - 1 - ) - return { - startEra, - endEra, - } - } - - // Determine whether to keep processing a next era, or move onto checking for pending payouts. - const shouldContinueProcessing = async ( - era: BigNumber, - endEra: BigNumber - ) => { - // If there are more exposures to process, check next era. - if (new BigNumber(era).isGreaterThan(endEra)) { - checkEra(new BigNumber(era).minus(1)) - } - // If all exposures have been processed, check for pending payouts. - else if (new BigNumber(era).isEqualTo(endEra)) { - await getUnclaimedPayouts() - setStateWithRef('synced', setPayoutsSynced, payoutsSyncedRef) - } - } - - // Fetch exposure data for an era, and pass the data to the worker to determine the validator the - // active account was backing in that era. - const checkEra = async (era: BigNumber) => { - if (!activeAccount) { - return - } - - // Bypass worker if local exposure data is available. - if (hasLocalEraExposure(network, era.toString(), activeAccount)) { - // Continue processing eras, or move onto reward processing. - shouldContinueProcessing(era, getErasInterval().endEra) - } else { - const exposures = await fetchEraStakers(era.toString()) - worker.postMessage({ - task: 'processEraForExposure', - era: String(era), - who: activeAccount, - networkName: network, - maxExposurePageSize: maxExposurePageSize.toString(), - exitOnExposed: false, - exposures, - }) - } - } - - // Handle worker message on completed exposure check. - worker.onmessage = (message: MessageEvent) => { - if (message) { - // ensure correct task received. - const { data } = message - const { task } = data - if (task !== 'processEraForExposure') { - return - } - - // Exit early if network or account conditions have changed. - const { networkName, who } = data - if (networkName !== network || who !== activeAccount) { - return - } - const { era, exposedValidators } = data - const { endEra } = getErasInterval() - - // Store received era exposure data results in local storage. - setLocalEraExposure( - networkName, - era, - who, - exposedValidators, - endEra.toString() - ) - - // Continue processing eras, or move onto reward processing. - shouldContinueProcessing(era, endEra) - } - } - - // Start pending payout process once exposure data is fetched. - const getUnclaimedPayouts = async () => { - if (!activeAccount) { - return - } - - // Accumulate eras to check, and determine all validator ledgers to fetch from exposures. - const erasValidators = [] - const { startEra, endEra } = getErasInterval() - let erasToCheck: string[] = [] - let currentEra = startEra - while (currentEra.isGreaterThanOrEqualTo(endEra)) { - const validators = Object.keys( - getLocalEraExposure(network, currentEra.toString(), activeAccount) - ) - erasValidators.push(...validators) - erasToCheck.push(currentEra.toString()) - currentEra = currentEra.minus(1) - } - - // Ensure no validator duplicates. - const uniqueValidators = [...new Set(erasValidators)] - - // Ensure `erasToCheck` is in order, highest first. - erasToCheck = erasToCheck.sort((a: string, b: string) => - new BigNumber(b).minus(a).toNumber() - ) - - // Fetch controllers in order to query ledgers. - const uniqueValidatorsMulti: [string][] = uniqueValidators.map((v) => [v]) - const bondedResultsMulti = await new BondedMulti( - network, - uniqueValidatorsMulti - ).fetch() - - const validatorControllers: Record = {} - for (let i = 0; i < bondedResultsMulti.length; i++) { - const ctlr = bondedResultsMulti[i] || null - if (ctlr) { - validatorControllers[uniqueValidators[i]] = ctlr - } - } - - // Unclaimed rewards by validator. Record. - const unclaimedRewards: Record = {} - - // Refer to new `ClaimedRewards` storage item and calculate unclaimed rewards from that and - // `exposedPage` stored locally in exposure data. - - // Accumulate calls to fetch unclaimed rewards for each era for all validators. - const unclaimedRewardsEntries = erasToCheck - .map((era) => uniqueValidators.map((v) => [era, v])) - .flat() - - const results = await Promise.all( - unclaimedRewardsEntries.map(([era, v]) => - new ClaimedRewards(network, Number(era), v).fetch() - ) - ) - - for (let i = 0; i < results.length; i++) { - const pages = results[i] || [] - const era = unclaimedRewardsEntries[i][0] - const validator = unclaimedRewardsEntries[i][1] - const exposure = getLocalEraExposure(network, era, activeAccount) - const exposedPage = - exposure?.[validator]?.exposedPage !== undefined - ? Number(exposure[validator].exposedPage) - : undefined - - // Add to `unclaimedRewards` if payout page has not yet been claimed. - if (exposedPage) { - if (!pages.includes(exposedPage)) { - if (unclaimedRewards?.[validator]) { - unclaimedRewards[validator].push(era) - } else { - unclaimedRewards[validator] = [era] - } - } - } - } - - // Reformat unclaimed rewards to be { era: validators[] }. - const unclaimedByEra: Record = {} - erasToCheck.forEach((era) => { - const eraValidators: string[] = [] - Object.entries(unclaimedRewards).forEach(([validator, eras]) => { - if (eras.includes(era)) { - eraValidators.push(validator) - } - }) - if (eraValidators.length > 0) { - unclaimedByEra[era] = eraValidators - } - }) - - // Accumulate calls needed to fetch data to calculate rewards. - const calls: AnyApi[] = [] - Object.entries(unclaimedByEra).forEach(([era, validators]) => { - if (validators.length > 0) { - calls.push( - Promise.all([ - new ErasValidatorReward(network, Number(era)).fetch(), - new ErasRewardPoints(network, Number(era)).fetch(), - ...validators.map((validator: AnyJson) => - new ValidatorPrefs(network, Number(era), validator).fetch() - ), - ]) - ) - } - }) - - // Iterate calls and determine unclaimed payouts. - // `unclaimed`: Record>. - const unclaimed: UnclaimedPayouts = {} - let i = 0 - for (const [reward, eraRewardPoints, ...prefs] of await Promise.all( - calls - )) { - const era = Object.keys(unclaimedByEra)[i] - const eraTotalPayout = new BigNumber(reward.toString()) - const unclaimedValidators = unclaimedByEra[era] - - let j = 0 - for (const pref of prefs) { - const eraValidatorPrefs = { - commission: pref.commission, - blocked: pref.blocked, - } - const commission = new BigNumber( - perbillToPercent(eraValidatorPrefs.commission) - ) - - // Get validator from era exposure data. Falls back no null if it cannot be found. - const validator = unclaimedValidators?.[j] || '' - - const localExposed: LocalValidatorExposure | null = getLocalEraExposure( - network, - era, - activeAccount - )?.[validator] - - const staked = new BigNumber(localExposed?.staked || '0') - const total = new BigNumber(localExposed?.total || '0') - const isValidator = localExposed?.isValidator || false - const exposedPage = localExposed?.exposedPage || 0 - - // Calculate the validator's share of total era payout. - const totalRewardPoints = new BigNumber( - eraRewardPoints.total.toString() - ) - const validatorRewardPoints = new BigNumber( - eraRewardPoints.individual.find( - ([v]: [string]) => v === validator - )?.[1] || '0' - ) - - const avail = eraTotalPayout - .multipliedBy(validatorRewardPoints) - .dividedBy(totalRewardPoints) - - const valCut = commission.multipliedBy(0.01).multipliedBy(avail) - - const unclaimedPayout = total.isZero() - ? new BigNumber(0) - : avail - .minus(valCut) - .multipliedBy(staked) - .dividedBy(total) - .plus(isValidator ? valCut : 0) - .integerValue(BigNumber.ROUND_DOWN) - - if (!unclaimedPayout.isZero()) { - unclaimed[era] = { - ...unclaimed[era], - [validator]: [exposedPage, unclaimedPayout.toString()], - } - j++ - } - } - - // This is not currently useful for preventing re-syncing. Need to know the eras that have - // been claimed already and remove them from `erasToCheck`. - setLocalUnclaimedPayouts( - network, - era, - activeAccount, - unclaimed[era], - endEra.toString() - ) - i++ - } - - setUnclaimedPayouts({ - ...unclaimedPayouts, - ...unclaimed, - }) - } - - // Removes a payout from `unclaimedPayouts` based on an era and validator record. - const removeEraPayout = (era: string, validator: string) => { - if (!unclaimedPayouts) { - return - } - - // Delete the payout from local storage. - const localPayouts = localStorage.getItem(`${network}_unclaimed_payouts`) - if (localPayouts && activeAccount) { - const parsed = JSON.parse(localPayouts) - - if (parsed?.[activeAccount]?.[era]?.[validator]) { - delete parsed[activeAccount][era][validator] - - // Delete the era if it has no more payouts. - if (Object.keys(parsed[activeAccount][era]).length === 0) { - delete parsed[activeAccount][era] - } - - // Delete the active account if it has no more eras. - if (Object.keys(parsed[activeAccount]).length === 0) { - delete parsed[activeAccount] - } - } - localStorage.setItem( - `${network}_unclaimed_payouts`, - JSON.stringify(parsed) - ) - } - - // Remove the payout from state. - const newUnclaimedPayouts = { ...unclaimedPayouts } - delete newUnclaimedPayouts[era][validator] - - setUnclaimedPayouts(newUnclaimedPayouts) - } - - // Fetch payouts if active account is nominating. - useEffect(() => { - if (!activeEra.index.isZero()) { - if (!isNominating()) { - setStateWithRef('synced', setPayoutsSynced, payoutsSyncedRef) - } else if ( - unclaimedPayouts === null && - payoutsSyncedRef.current !== 'syncing' - ) { - setStateWithRef('syncing', setPayoutsSynced, payoutsSyncedRef) - // Start checking eras for exposures, starting with the previous one. - checkEra(activeEra.index.minus(1)) - } - } - }, [unclaimedPayouts, isNominating(), activeEra, payoutsSynced]) - - // Clear payout state on network / active account change. - useEffect(() => { - setUnclaimedPayouts(null) - setStateWithRef('unsynced', setPayoutsSynced, payoutsSyncedRef) - }, [network, activeAccount]) + // Store pending nominator reward total & individual entries. + const [unclaimedRewards, setUnclaimedRewards] = useState({ + total: '0', + entries: [], + }) return ( {children} diff --git a/packages/app/src/contexts/Payouts/types.ts b/packages/app/src/contexts/Payouts/types.ts index b10eeb8271..9543d84938 100644 --- a/packages/app/src/contexts/Payouts/types.ts +++ b/packages/app/src/contexts/Payouts/types.ts @@ -1,24 +1,9 @@ // Copyright 2024 @polkadot-cloud/polkadot-staking-dashboard authors & contributors // SPDX-License-Identifier: GPL-3.0-only -import type { Sync } from '@w3ux/types' +import type { UnclaimedRewards } from 'plugin-staking-api/types' export interface PayoutsContextInterface { - payoutsSynced: Sync - unclaimedPayouts: UnclaimedPayouts - removeEraPayout: (era: string, validator: string) => void -} - -// Record -export type UnclaimedPayouts = Record | null - -// Record -export type EraUnclaimedPayouts = Record - -export interface LocalValidatorExposure { - staked: string - total: string - share: string - isValidator: boolean - exposedPage: number + unclaimedRewards: UnclaimedRewards + setUnclaimedRewards: (unclaimedRewards: UnclaimedRewards) => void } diff --git a/packages/app/src/contexts/Validators/types.ts b/packages/app/src/contexts/Validators/types.ts index 1212a6bae8..77c1711869 100644 --- a/packages/app/src/contexts/Validators/types.ts +++ b/packages/app/src/contexts/Validators/types.ts @@ -84,3 +84,11 @@ export interface ValidatorEraPointHistory { rank?: number quartile?: number } + +export interface LocalValidatorExposure { + staked: string + total: string + share: string + isValidator: boolean + exposedPage: number +} diff --git a/packages/app/src/controllers/Subscan/index.ts b/packages/app/src/controllers/Subscan/index.ts index 9e0a3a9f65..5845dc0ea0 100644 --- a/packages/app/src/controllers/Subscan/index.ts +++ b/packages/app/src/controllers/Subscan/index.ts @@ -1,127 +1,60 @@ // Copyright 2024 @polkadot-cloud/polkadot-staking-dashboard authors & contributors // SPDX-License-Identifier: GPL-3.0-only -import type { Locale } from 'date-fns' -import { format, fromUnixTime, getUnixTime, subDays } from 'date-fns' import { poolMembersPerPage } from 'library/List/defaults' import type { PoolMember } from 'types' import type { SubscanData, SubscanEraPoints, - SubscanPayout, SubscanPoolClaim, + SubscanPoolClaimRaw, SubscanPoolMember, SubscanRequestBody, } from './types' export class Subscan { - // List of endpoints to be used for Subscan API calls. + // List of endpoints to be used for Subscan API calls static ENDPOINTS = { eraStat: '/api/scan/staking/era_stat', poolMembers: '/api/scan/nomination_pool/pool/members', poolRewards: '/api/scan/nomination_pool/rewards', - rewardSlash: '/api/v2/scan/account/reward_slash', } - // Total amount of requests that can be made in 1 second. - static TOTAL_REQUESTS_PER_SECOND = 5 - - // Maximum amount of payout days supported. - static MAX_PAYOUT_DAYS = 60 - - // The network to use for Subscan API calls. + // The network to use for Subscan API calls static network: string - // Subscan payout data, keyed by address. + // Subscan payout data, keyed by address static payoutData: Record = {} - // Subscan pool data, keyed by `---...`. + // Subscan pool data, keyed by `---...` static poolData: Record = {} - // Subscan era points data, keyed by `-
-`. + // Subscan era points data, keyed by `-
-` static eraPointsData: Record = {} - // Set the network to use for Subscan API calls. - // - // Effects the endpoint being used. Should be updated on network change in the UI. + // Set the network to use for Subscan API calls set network(network: string) { Subscan.network = network } - // Handle fetching the various types of payout and set state in one render. + // Handle fetching pool claims and set state in one render static handleFetchPayouts = async (address: string): Promise => { try { if (!this.payoutData[address]) { - const results = await Promise.all([ - this.fetchNominatorPayouts(address), - this.fetchPoolClaims(address), - ]) - const { payouts, unclaimedPayouts } = results[0] - const poolClaims = results[1] - - // Persist results to class. + const poolClaims = await this.fetchPoolClaims(address) this.payoutData[address] = { - payouts, - unclaimedPayouts, poolClaims, } - document.dispatchEvent( new CustomEvent('subscan-data-updated', { detail: { - keys: ['payouts', 'unclaimedPayouts', 'poolClaims'], + keys: ['poolClaims'], }, }) ) } } catch (e) { - // Silently fail request. - } - } - - // Fetch nominator payouts from Subscan. NOTE: Payouts with a `block_timestamp` of 0 are - // unclaimed. - static fetchNominatorPayouts = async ( - address: string - ): Promise<{ - payouts: SubscanPayout[] - unclaimedPayouts: SubscanPayout[] - }> => { - try { - const result = await this.makeRequest(this.ENDPOINTS.rewardSlash, { - address, - is_stash: true, - row: 100, - page: 0, - }) - - const payouts = - result?.list?.filter( - ({ block_timestamp }: SubscanPayout) => block_timestamp !== 0 - ) || [] - - let unclaimedPayouts = - result?.list?.filter((l: SubscanPayout) => l.block_timestamp === 0) || - [] - - // Further filter unclaimed payouts to ensure that payout records of `stash` and - // `validator_stash` are not repeated for an era. NOTE: This was introduced to remove errornous - // data where there were duplicated payout records (with different amounts) for a stash - - // validator - era record. from Subscan. - unclaimedPayouts = unclaimedPayouts.filter( - (u: SubscanPayout) => - !payouts.find( - (p: SubscanPayout) => - p.stash === u.stash && - p.validator_stash === u.validator_stash && - p.era === u.era - ) - ) - - return { payouts, unclaimedPayouts } - } catch (e) { - // Silently fail request and return empty records. - return { payouts: [], unclaimedPayouts: [] } + // Silently fail request } } @@ -138,18 +71,22 @@ export class Subscan { if (!result?.list) { return [] } - // Remove claims with a `block_timestamp`. - const poolClaims = result.list.filter( - (l: SubscanPoolClaim) => l.block_timestamp !== 0 - ) + // Remove claims with a `block_timestamp` + const poolClaims = result.list + .filter((l: SubscanPoolClaimRaw) => l.block_timestamp !== 0) + .map((l: SubscanPoolClaimRaw) => ({ + ...l, + reward: l.amount, + timestamp: l.block_timestamp, + type: 'pool', + })) return poolClaims } catch (e) { - // Silently fail request and return empty record. return [] } } - // Fetch a page of pool members from Subscan. + // Fetch a page of pool members from Subscan static fetchPoolMembers = async ( poolId: number, page: number @@ -235,72 +172,6 @@ export class Subscan { this.payoutData = {} } - // Remove unclaimed payouts and dispatch update event. - static removeUnclaimedPayouts = (address: string, eraPayouts: string[]) => { - const newUnclaimedPayouts = (this.payoutData[address]?.unclaimedPayouts || - []) as SubscanPayout[] - - eraPayouts.forEach(([era]) => { - newUnclaimedPayouts.filter((u) => String(u.era) !== era) - }) - this.payoutData[address].unclaimedPayouts = newUnclaimedPayouts - - document.dispatchEvent( - new CustomEvent('subscan-data-updated', { - detail: { - keys: ['unclaimedPayouts'], - }, - }) - ) - } - - // Take non-zero rewards in most-recent order. - static removeNonZeroAmountAndSort = (payouts: SubscanPayout[]) => { - const list = payouts - .filter((p) => Number(p.amount) > 0) - .sort((a, b) => b.block_timestamp - a.block_timestamp) - - // Calculates from the current date. - const fromTimestamp = getUnixTime(subDays(new Date(), this.MAX_PAYOUT_DAYS)) - // Ensure payouts not older than `MAX_PAYOUT_DAYS` are returned. - return list.filter( - ({ block_timestamp }) => block_timestamp >= fromTimestamp - ) - } - - // Calculate the earliest date of a payout list. - static payoutsFromDate = (payouts: SubscanPayout[], locale: Locale) => { - if (!payouts.length) { - return undefined - } - const filtered = this.removeNonZeroAmountAndSort(payouts || []) - if (!filtered.length) { - return undefined - } - return format( - fromUnixTime(filtered[filtered.length - 1].block_timestamp), - 'do MMM', - { - locale, - } - ) - } - - // Calculate the latest date of a payout list. - static payoutsToDate = (payouts: SubscanPayout[], locale: Locale) => { - if (!payouts.length) { - return undefined - } - const filtered = this.removeNonZeroAmountAndSort(payouts || []) - if (!filtered.length) { - return undefined - } - - return format(fromUnixTime(filtered[0].block_timestamp), 'do MMM', { - locale, - }) - } - // Get the public Subscan endpoint. static getEndpoint = () => `https://${this.network}.api.subscan.io` diff --git a/packages/app/src/controllers/Subscan/types.ts b/packages/app/src/controllers/Subscan/types.ts index 9e4d34f427..455889aadf 100644 --- a/packages/app/src/controllers/Subscan/types.ts +++ b/packages/app/src/controllers/Subscan/types.ts @@ -1,29 +1,29 @@ // Copyright 2024 @polkadot-cloud/polkadot-staking-dashboard authors & contributors // SPDX-License-Identifier: GPL-3.0-only -export type PayoutType = 'payouts' | 'unclaimedPayouts' | 'poolClaims' +import type { NominatorReward } from 'plugin-staking-api/types' + +export type PayoutType = 'poolClaims' export type SubscanData = Partial> -export type SubscanPayoutData = Partial> +export interface SubscanPayoutData { + poolClaims: SubscanPoolClaim[] +} + +export type PayoutsAndClaims = (NominatorReward | SubscanPoolClaim)[] export type SubscanRequestBody = - | RewardSlashRequestBody - | RewardRewardsRequestBody - | RewardMembersRequestBody + | PoolRewardsRequestBody + | PoolMembersRequestBody | PoolDetailsRequestBody -export type RewardSlashRequestBody = SubscanRequestPagination & { - address: string - is_stash: boolean -} - -export type RewardRewardsRequestBody = SubscanRequestPagination & { +export type PoolRewardsRequestBody = SubscanRequestPagination & { address: string claimed_filter?: 'claimed' | 'unclaimed' } -export type RewardMembersRequestBody = SubscanRequestPagination & { +export type PoolMembersRequestBody = SubscanRequestPagination & { pool_id: number } @@ -36,12 +36,9 @@ export interface SubscanRequestPagination { page: number } -export type SubscanResult = - | SubscanPayout[] - | SubscanPoolClaim[] - | SubscanPoolMember[] +export type SubscanResult = SubscanPoolClaim[] | SubscanPoolMember[] -export interface SubscanPoolClaim { +export interface SubscanPoolClaimBase { account_display: { address: string display: string @@ -57,18 +54,14 @@ export interface SubscanPoolClaim { pool_id: number } -export interface SubscanPayout { - era: number - stash: string - account: string - validator_stash: string +export type SubscanPoolClaimRaw = SubscanPoolClaimBase & { amount: string block_timestamp: number - event_index: string - module_id: string - event_id: string - extrinsic_index: string - invalid_era: boolean +} + +export type SubscanPoolClaim = SubscanPoolClaimBase & { + reward: string + timestamp: number } export interface SubscanPoolMember { diff --git a/packages/app/src/hooks/useAccountFromUrl/index.tsx b/packages/app/src/hooks/useAccountFromUrl/index.tsx new file mode 100644 index 0000000000..6446cc6451 --- /dev/null +++ b/packages/app/src/hooks/useAccountFromUrl/index.tsx @@ -0,0 +1,35 @@ +// Copyright 2024 @polkadot-cloud/polkadot-staking-dashboard authors & contributors +// SPDX-License-Identifier: GPL-3.0-only + +import { extractUrlValue } from '@w3ux/utils' +import { useActiveAccounts } from 'contexts/ActiveAccounts' +import { useImportedAccounts } from 'contexts/Connect/ImportedAccounts' +import { useOtherAccounts } from 'contexts/Connect/OtherAccounts' +import { Notifications } from 'controllers/Notifications' +import { useEffect } from 'react' +import { useTranslation } from 'react-i18next' + +export const useAccountFromUrl = () => { + const { t } = useTranslation('library') + const { accounts } = useImportedAccounts() + const { accountsInitialised } = useOtherAccounts() + const { activeAccount, setActiveAccount } = useActiveAccounts() + + // Set active account if url var present and accounts initialised + useEffect(() => { + if (accountsInitialised) { + const val = extractUrlValue('a') + if (val) { + const account = accounts.find((a) => a.address === val) + if (account && activeAccount !== val) { + setActiveAccount(account.address) + + Notifications.emit({ + title: t('accountConnected'), + subtitle: `${t('connectedTo')} ${account.name}.`, + }) + } + } + } + }, [accountsInitialised]) +} diff --git a/packages/app/src/hooks/useSubscanData/index.tsx b/packages/app/src/hooks/useSubscanData/index.tsx index 418c385efa..7765121dd7 100644 --- a/packages/app/src/hooks/useSubscanData/index.tsx +++ b/packages/app/src/hooks/useSubscanData/index.tsx @@ -1,54 +1,42 @@ // Copyright 2024 @polkadot-cloud/polkadot-staking-dashboard authors & contributors // SPDX-License-Identifier: GPL-3.0-only -import { setStateWithRef } from '@w3ux/utils' import { useActiveAccounts } from 'contexts/ActiveAccounts' import { useApi } from 'contexts/Api' import { usePlugins } from 'contexts/Plugins' import { Subscan } from 'controllers/Subscan' -import type { - PayoutType, - SubscanData, - SubscanPayout, - SubscanPayoutData, -} from 'controllers/Subscan/types' +import type { PayoutType, SubscanPoolClaim } from 'controllers/Subscan/types' import { isCustomEvent } from 'controllers/utils' +import type { NominatorReward } from 'plugin-staking-api/types' import { useEffect, useRef, useState } from 'react' import { useEventListener } from 'usehooks-ts' import { useErasToTimeLeft } from '../useErasToTimeLeft' -export const useSubscanData = (keys: PayoutType[]) => { +export const useSubscanData = () => { const { activeEra } = useApi() const { pluginEnabled } = usePlugins() const { erasToSeconds } = useErasToTimeLeft() const { activeAccount } = useActiveAccounts() - // Store the most up to date subscan data state. - const [data, setData] = useState({}) - const dataRef = useRef(data) + // Store pool claims data for the active account + const [poolClaims, setPoolClaims] = useState([]) // Listen for updated data callback. When there are new data, fetch the updated values directly // from `Subscan` and commit to component state. const subscanPayoutsUpdatedCallback = (e: Event) => { // NOTE: Subscan has to be enabled to continue. - if (isCustomEvent(e) && pluginEnabled('subscan')) { + if (isCustomEvent(e) && pluginEnabled('subscan') && activeAccount) { const { keys: receivedKeys }: { keys: PayoutType[] } = e.detail - - // Filter out any keys that are not provided to the hook active account is still present. - if (activeAccount) { - const newData: SubscanData = {} - receivedKeys - .filter((key) => keys.includes(key)) - .forEach((key) => { - newData[key] = Subscan.payoutData[activeAccount]?.[key] || [] - }) - - setStateWithRef({ ...dataRef.current, ...newData }, setData, dataRef) + if (receivedKeys.includes('poolClaims')) { + setPoolClaims( + (Subscan.payoutData[activeAccount]?.['poolClaims'] || + []) as SubscanPoolClaim[] + ) } } } - // Listen for new subscan data updates. + // Listen for new subscan data updates const documentRef = useRef(document) useEventListener( 'subscan-data-updated', @@ -56,25 +44,12 @@ export const useSubscanData = (keys: PayoutType[]) => { documentRef ) - // Get data or return an empty array if it is undefined. - const getData = (withKeys: PayoutType[]): SubscanPayoutData => { - const result: SubscanPayoutData = {} - - withKeys.forEach((key: PayoutType) => { - const keyData = (data[key] || []) as SubscanPayout[] - result[key] = keyData - }) - return result - } - - // Inject block_timestamp for unclaimed payouts. We take the timestamp of the start of the - // following payout era - this is the time payouts become available to claim by validators. - const injectBlockTimestamp = (entries: SubscanPayout[]) => { - if (!entries) { - return entries - } + // Inject timestamp for unclaimed payouts. We take the timestamp of the start of the + // following payout era - this is the time payouts become available to claim by validators + // NOTE: Not currently being used + const injectBlockTimestamp = (entries: NominatorReward[]) => { entries.forEach((p) => { - p.block_timestamp = activeEra.start + p.timestamp = activeEra.start .multipliedBy(0.001) .minus(erasToSeconds(activeEra.index.minus(p.era).minus(1))) .toNumber() @@ -82,16 +57,18 @@ export const useSubscanData = (keys: PayoutType[]) => { return entries } - // Populate state on initial render if data is already available. + // Populate state on initial render if data is already available useEffect(() => { if (activeAccount) { - const newData: SubscanData = {} - keys.forEach((key: PayoutType) => { - newData[key] = Subscan.payoutData[activeAccount]?.[key] || [] - }) - setStateWithRef({ ...dataRef.current, ...newData }, setData, dataRef) + setPoolClaims( + (Subscan.payoutData[activeAccount]?.['poolClaims'] || + []) as SubscanPoolClaim[] + ) } }, [activeAccount]) - return { data, getData, injectBlockTimestamp } + return { + poolClaims, + injectBlockTimestamp, + } } diff --git a/packages/app/src/library/Graphs/PayoutBar.tsx b/packages/app/src/library/Graphs/PayoutBar.tsx index 02490c3e62..5a68abf0d9 100644 --- a/packages/app/src/library/Graphs/PayoutBar.tsx +++ b/packages/app/src/library/Graphs/PayoutBar.tsx @@ -1,8 +1,8 @@ // Copyright 2024 @polkadot-cloud/polkadot-staking-dashboard authors & contributors // SPDX-License-Identifier: GPL-3.0-only -import type { AnyJson } from '@w3ux/types' import BigNumber from 'bignumber.js' +import type { TooltipItem } from 'chart.js' import { BarElement, CategoryScale, @@ -14,14 +14,9 @@ import { Title, Tooltip, } from 'chart.js' -import type { AnyApi } from 'common-types' -import { useActiveAccounts } from 'contexts/ActiveAccounts' -import { useBalances } from 'contexts/Balances' import { useNetwork } from 'contexts/Network' -import { useStaking } from 'contexts/Staking' import { useTheme } from 'contexts/Themes' import { format, fromUnixTime } from 'date-fns' -import { useSyncing } from 'hooks/useSyncing' import { DefaultLocale, locales } from 'locales' import { Bar } from 'react-chartjs-2' import { useTranslation } from 'react-i18next' @@ -44,56 +39,43 @@ export const PayoutBar = ({ days, height, data: { payouts, poolClaims, unclaimedPayouts }, + nominating, + inPool, }: PayoutBarProps) => { const { i18n, t } = useTranslation('library') const { mode } = useTheme() - const { inSetup } = useStaking() - const { getPoolMembership } = useBalances() - const { syncing } = useSyncing(['balances']) - const { activeAccount } = useActiveAccounts() - - const membership = getPoolMembership(activeAccount) const { unit, units, colors } = useNetwork().networkData - const notStaking = !syncing && inSetup() && !membership - - // remove slashes from payouts (graph does not support negative values). - const payoutsNoSlash = payouts?.filter((p) => p.event_id !== 'Slashed') || [] - - // remove slashes from unclaimed payouts. - const unclaimedPayoutsNoSlash = - unclaimedPayouts?.filter((p) => p.event_id !== 'Slashed') || [] + const staking = nominating || inPool - // get formatted rewards data for graph. + // Get formatted rewards data const { allPayouts, allPoolClaims, allUnclaimedPayouts } = formatRewardsForGraphs( new Date(), days, units, - payoutsNoSlash, + payouts, poolClaims, - unclaimedPayoutsNoSlash + unclaimedPayouts ) - const { p: graphPayouts } = allPayouts const { p: graphUnclaimedPayouts } = allUnclaimedPayouts const { p: graphPoolClaims } = allPoolClaims - // determine color for payouts - const colorPayouts = notStaking + // Determine color for payouts + const colorPayouts = !staking ? colors.transparent[mode] : colors.primary[mode] - // determine color for poolClaims - const colorPoolClaims = notStaking + // Determine color for poolClaims + const colorPoolClaims = !staking ? colors.transparent[mode] : colors.secondary[mode] - // Bar border radius const borderRadius = 4 - + const pointRadius = 0 const data = { - labels: graphPayouts.map((item: AnyApi) => { - const dateObj = format(fromUnixTime(item.block_timestamp), 'do MMM', { + labels: graphPayouts.map(({ timestamp }: { timestamp: number }) => { + const dateObj = format(fromUnixTime(timestamp), 'do MMM', { locale: locales[i18n.resolvedLanguage ?? DefaultLocale].dateFormat, }) return `${dateObj}` @@ -103,28 +85,30 @@ export const PayoutBar = ({ { order: 1, label: t('payout'), - data: graphPayouts.map((item: AnyApi) => item.amount), + data: graphPayouts.map(({ reward }: { reward: string }) => reward), borderColor: colorPayouts, backgroundColor: colorPayouts, - pointRadius: 0, + pointRadius, borderRadius, }, { order: 2, label: t('poolClaim'), - data: graphPoolClaims.map((item: AnyApi) => item.amount), + data: graphPoolClaims.map(({ reward }: { reward: string }) => reward), borderColor: colorPoolClaims, backgroundColor: colorPoolClaims, - pointRadius: 0, + pointRadius, borderRadius, }, { order: 3, - data: graphUnclaimedPayouts.map((item: AnyApi) => item.amount), + data: graphUnclaimedPayouts.map( + ({ reward }: { reward: string }) => reward + ), label: t('unclaimedPayouts'), borderColor: colorPayouts, backgroundColor: colors.pending[mode], - pointRadius: 0, + pointRadius, borderRadius, }, ], @@ -180,10 +164,10 @@ export const PayoutBar = ({ }, callbacks: { title: () => [], - label: (context: AnyJson) => - `${ - context.dataset.order === 3 ? `${t('pending')}: ` : '' - }${new BigNumber(context.parsed.y) + label: ({ dataset, parsed }: TooltipItem<'bar'>) => + `${dataset.order === 3 ? `${t('pending')}: ` : ''}${new BigNumber( + parsed.y + ) .decimalPlaces(units) .toFormat()} ${unit}`, }, diff --git a/packages/app/src/library/Graphs/PayoutLine.tsx b/packages/app/src/library/Graphs/PayoutLine.tsx index b072df8456..78529407d9 100644 --- a/packages/app/src/library/Graphs/PayoutLine.tsx +++ b/packages/app/src/library/Graphs/PayoutLine.tsx @@ -1,8 +1,8 @@ // Copyright 2024 @polkadot-cloud/polkadot-staking-dashboard authors & contributors // SPDX-License-Identifier: GPL-3.0-only -import type { AnyJson } from '@w3ux/types' import BigNumber from 'bignumber.js' +import type { TooltipItem } from 'chart.js' import { CategoryScale, Chart as ChartJS, @@ -13,13 +13,8 @@ import { Title, Tooltip, } from 'chart.js' -import type { AnyApi } from 'common-types' -import { useActiveAccounts } from 'contexts/ActiveAccounts' -import { useBalances } from 'contexts/Balances' import { useNetwork } from 'contexts/Network' -import { useStaking } from 'contexts/Staking' import { useTheme } from 'contexts/Themes' -import { useSyncing } from 'hooks/useSyncing' import { Line } from 'react-chartjs-2' import { useTranslation } from 'react-i18next' import graphColors from 'styles/graphs/index.json' @@ -46,38 +41,30 @@ export const PayoutLine = ({ height, background, data: { payouts, poolClaims }, + nominating, + inPool, }: PayoutLineProps) => { const { t } = useTranslation('library') const { mode } = useTheme() - const { inSetup } = useStaking() - const { syncing } = useSyncing(['balances']) - const { getPoolMembership } = useBalances() - const { activeAccount } = useActiveAccounts() - const { unit, units, colors } = useNetwork().networkData - const poolMembership = getPoolMembership(activeAccount) - const notStaking = !syncing && inSetup() && !poolMembership - const inPoolOnly = !syncing && inSetup() && !!poolMembership - - // remove slashes from payouts (graph does not support negative values). - const payoutsNoSlash = payouts?.filter((p) => p.event_id !== 'Slashed') || [] - // define the most recent date that we will show on the graph. + const staking = nominating || inPool + const inPoolOnly = !nominating && inPool + // Define the most recent date that we will show on the graph const fromDate = new Date() const { allPayouts, allPoolClaims } = formatRewardsForGraphs( fromDate, days, units, - payoutsNoSlash, + payouts, poolClaims, - [] // Note: we are not using `unclaimedPayouts` here. + [] // Note: we are not using `unclaimedPayouts` here ) - const { p: graphPayouts, a: graphPrePayouts } = allPayouts const { p: graphPoolClaims, a: graphPrePoolClaims } = allPoolClaims - // combine payouts and pool claims into one dataset and calculate averages. + // Combine payouts and pool claims into one dataset and calculate averages const combined = combineRewards(graphPayouts, graphPoolClaims) const preCombined = combineRewards(graphPrePayouts, graphPrePoolClaims) @@ -88,14 +75,13 @@ export const PayoutLine = ({ 10 ) - // determine color for payouts - const color = notStaking + // Determine color for payouts + const color = !staking ? colors.primary[mode] : !inPoolOnly ? colors.primary[mode] : colors.secondary[mode] - // configure graph options const options = { responsive: true, maintainAspectRatio: false, @@ -137,8 +123,8 @@ export const PayoutLine = ({ }, callbacks: { title: () => [], - label: (context: AnyJson) => - ` ${new BigNumber(context.parsed.y) + label: ({ parsed }: TooltipItem<'line'>) => + ` ${new BigNumber(parsed.y) .decimalPlaces(units) .toFormat()} ${unit}`, }, @@ -155,7 +141,7 @@ export const PayoutLine = ({ datasets: [ { label: t('payout'), - data: combinedPayouts.map((item: AnyApi) => item?.amount ?? 0), + data: combinedPayouts.map(({ reward }: { reward: string }) => reward), borderColor: color, pointStyle: undefined, pointRadius: 0, diff --git a/packages/app/src/library/Graphs/Utils.ts b/packages/app/src/library/Graphs/Utils.ts index 6ab83ab5f2..2021c975d7 100644 --- a/packages/app/src/library/Graphs/Utils.ts +++ b/packages/app/src/library/Graphs/Utils.ts @@ -5,19 +5,23 @@ import type { AnyJson } from '@w3ux/types' import BigNumber from 'bignumber.js' import type { AnyApi } from 'common-types' import { MaxPayoutDays } from 'consts' +import type { PayoutsAndClaims } from 'controllers/Subscan/types' +import type { Locale } from 'date-fns' import { addDays, differenceInDays, + format, fromUnixTime, getUnixTime, isSameDay, startOfDay, subDays, } from 'date-fns' +import type { NominatorReward } from 'plugin-staking-api/types' import { planckToUnitBn } from 'utils' import type { PayoutDayCursor } from './types' -// Given payouts, calculate daily income and fill missing days with zero amounts. +// Given payouts, calculate daily income and fill missing days with zero rewards export const calculateDailyPayouts = ( payouts: AnyApi, fromDate: Date, @@ -30,8 +34,7 @@ export const calculateDailyPayouts = ( // remove days that are beyond end day limit payouts = payouts.filter( - (p: AnyApi) => - daysPassed(fromUnixTime(p.block_timestamp), fromDate) <= maxDays + (p: AnyApi) => daysPassed(fromUnixTime(p.timestamp), fromDate) <= maxDays ) // return now if no payouts. @@ -42,146 +45,146 @@ export const calculateDailyPayouts = ( // post-fill any missing days. [current day -> last payout] dailyPayouts = postFillMissingDays(payouts, fromDate, maxDays) - // start iterating payouts, most recent first. + // start iterating payouts, most recent first // - // payouts passed. + // payouts passed let p = 0 - // current day cursor. + // current day cursor let curDay: Date = fromDate - // current payout cursor. + // current payout cursor let curPayout: PayoutDayCursor = { - amount: new BigNumber(0), - event_id: '', + reward: new BigNumber(0), } for (const payout of payouts) { p++ - // extract day from current payout. - const thisDay = startOfDay(fromUnixTime(payout.block_timestamp)) + // extract day from current payout + const thisDay = startOfDay(fromUnixTime(payout.timestamp)) - // initialise current day if first payout. + // initialise current day if first payout if (p === 1) { curDay = thisDay } - // handle surpassed maximum days. + // handle surpassed maximum days if (daysPassed(thisDay, fromDate) >= maxDays) { dailyPayouts.push({ - amount: planckToUnitBn(curPayout.amount, units), - event_id: getEventId(curPayout), - block_timestamp: getUnixTime(curDay), + reward: planckToUnitBn(curPayout.reward, units), + timestamp: getUnixTime(curDay), }) break } - // get day difference between cursor and current payout. + // get day difference between cursor and current payout const daysDiff = daysPassed(thisDay, curDay) // handle new day. if (daysDiff > 0) { - // add current payout cursor to dailyPayouts. + // add current payout cursor to dailyPayouts dailyPayouts.push({ - amount: planckToUnitBn(curPayout.amount, units), - event_id: getEventId(curPayout), - block_timestamp: getUnixTime(curDay), + reward: planckToUnitBn(curPayout.reward, units), + timestamp: getUnixTime(curDay), }) - // update day cursor to the new day. + // update day cursor to the new day curDay = thisDay - // reset current payout cursor for the new day. + // reset current payout cursor for the new day curPayout = { - amount: new BigNumber(payout.amount), - event_id: new BigNumber(payout.amount).isLessThan(0) - ? 'Slash' - : 'Reward', + reward: new BigNumber(payout.reward), } } else { - // in same day. Aadd payout amount to current payout cursor. - curPayout.amount = curPayout.amount.plus(payout.amount) + // in same day. Aadd payout reward to current payout cursor + curPayout.reward = curPayout.reward.plus(payout.reward) } - // if only 1 payout exists, or at the last unresolved payout, exit here. + // if only 1 payout exists, or at the last unresolved payout, exit here if ( payouts.length === 1 || - (p === payouts.length && !curPayout.amount.isZero()) + (p === payouts.length && !curPayout.reward.isZero()) ) { dailyPayouts.push({ - amount: planckToUnitBn(curPayout.amount, units), - event_id: getEventId(curPayout), - block_timestamp: getUnixTime(curDay), + reward: planckToUnitBn(curPayout.reward, units), + timestamp: getUnixTime(curDay), }) break } } - // return payout amounts as plain numbers. + // return payout rewards as plain numbers return dailyPayouts.map((q: AnyApi) => ({ ...q, - amount: Number(q.amount.toString()), + reward: Number(q.reward.toString()), })) } -// Calculate average payouts per day. +// Calculate average payouts per day export const calculatePayoutAverages = ( payouts: AnyApi, fromDate: Date, days: number, avgDays: number ) => { - // if we don't need to take an average, just return `payouts`. + // if we don't need to take an average, just return `payouts` if (avgDays <= 1) { return payouts } - // create moving average value over `avgDays` past days, if any. - let payoutsAverages = [] + // create moving average value over `avgDays` past days, if any + let payoutsAverages: { reward: number; timestamp: number }[] = [] for (let i = 0; i < payouts.length; i++) { // average period end. const end = Math.max(0, i - avgDays) - // the total amount earned in period. + // the total reward earned in period let total = 0 - // period length to be determined. + // period length to be determined let num = 0 for (let j = i; j >= end; j--) { if (payouts[j]) { - total += payouts[j].amount + total += payouts[j].reward } // increase by one to treat non-existent as zero value num += 1 } if (total === 0) { - total = payouts[i].amount + total = payouts[i].reward } + // If on last reward and is a zero (current era still processing), use previous reward to + // prevent misleading dip + const reward = + i === payouts.length - 1 && payouts[i].reward === 0 + ? payoutsAverages[i - 1].reward + : total / num + payoutsAverages.push({ - amount: total / num, - block_timestamp: payouts[i].block_timestamp, + reward, + timestamp: payouts[i].timestamp, }) } // return an array with the expected number of items payoutsAverages = payoutsAverages.filter( - (p: AnyApi) => daysPassed(fromUnixTime(p.block_timestamp), fromDate) <= days + (p: AnyApi) => daysPassed(fromUnixTime(p.timestamp), fromDate) <= days ) return payoutsAverages } -// Fetch rewards and graph meta data. +// Fetch rewards and graph meta data // -// Format provided payouts and returns the last payment. +// Format provided payouts and returns the last payment export const formatRewardsForGraphs = ( fromDate: Date, days: number, units: number, - payouts: AnyApi, + payouts: NominatorReward[], poolClaims: AnyApi, - unclaimedPayouts: AnyApi + unclaimedPayouts: NominatorReward[] ) => { - // process nominator payouts. + // process nominator payouts const allPayouts = processPayouts(payouts, fromDate, days, units, 'nominate') // process unclaimed nominator payouts. @@ -193,7 +196,7 @@ export const formatRewardsForGraphs = ( 'nominate' ) - // process pool claims. + // process pool claims const allPoolClaims = processPayouts( poolClaims, fromDate, @@ -210,10 +213,9 @@ export const formatRewardsForGraphs = ( lastReward: getLatestReward(payouts, poolClaims), } } - -// Process payouts. +// Process payouts // -// calls the relevant functions on raw payouts to format them correctly. +// calls the relevant functions on raw payouts to format them correctly const processPayouts = ( payouts: AnyApi, fromDate: Date, @@ -223,16 +225,16 @@ const processPayouts = ( ) => { // normalise payout timestamps. const normalised = normalisePayouts(payouts) - // calculate payouts per day from the current day. + // calculate payouts per day from the current day let p = calculateDailyPayouts(normalised, fromDate, days, units, subject) - // pre-fill payouts if max days have not been reached. + // pre-fill payouts if max days have not been reached p = p.concat(prefillMissingDays(p, fromDate, days)) - // fill in gap days between payouts with zero values. + // fill in gap days between payouts with zero values p = fillGapDays(p, fromDate) - // reverse payouts: most recent last. + // reverse payouts: most recent last p = p.reverse() - // use normalised payouts for calculating the 10-day average prior to the start of the payout graph. + // use normalised payouts for calculating the 10-day average prior to the start of the payout graph const avgDays = 10 const preNormalised = getPreMaxDaysPayouts( normalised, @@ -240,7 +242,7 @@ const processPayouts = ( days, avgDays ) - // start of average calculation should be the earliest date. + // start of average calculation should be the earliest date const averageFromDate = subDays(fromDate, MaxPayoutDays) let a = calculateDailyPayouts( @@ -250,11 +252,11 @@ const processPayouts = ( units, subject ) - // prefill payouts if we are missing the earlier dates. + // prefill payouts if we are missing the earlier dates a = a.concat(prefillMissingDays(a, averageFromDate, avgDays)) - // fill in gap days between payouts with zero values. + // fill in gap days between payouts with zero values a = fillGapDays(a, averageFromDate) - // reverse payouts: most recent last. + // reverse payouts: most recent last a = a.reverse() return { p, a } @@ -263,95 +265,95 @@ const processPayouts = ( // Get payout average in `avgDays` day period after to `days` threshold // // These payouts are used for calculating the `avgDays`-day average prior to the start of the payout -// graph. +// graph const getPreMaxDaysPayouts = ( payouts: AnyApi, fromDate: Date, days: number, avgDays: number ) => - // remove payouts that are not within `avgDays` `days` pre-graph window. + // remove payouts that are not within `avgDays` `days` pre-graph window payouts.filter( (p: AnyApi) => - daysPassed(fromUnixTime(p.block_timestamp), fromDate) > days && - daysPassed(fromUnixTime(p.block_timestamp), fromDate) <= days + avgDays + daysPassed(fromUnixTime(p.timestamp), fromDate) > days && + daysPassed(fromUnixTime(p.timestamp), fromDate) <= days + avgDays ) // Combine payouts and pool claims. // -// combines payouts and pool claims into daily records. Removes the `event_id` field from records. +// combines payouts and pool claims into daily records export const combineRewards = (payouts: AnyApi, poolClaims: AnyApi) => { - // we first check if actual payouts exist, e.g. there are non-zero payout - // amounts present in either payouts or pool claims. - const poolClaimExists = poolClaims.find((p: AnyApi) => p.amount > 0) || null - const payoutExists = payouts.find((p: AnyApi) => p.amount > 0) || null + // we first check if actual payouts exist, e.g. there are non-zero payout rewards present in + // either payouts or pool claims. + const poolClaimExists = poolClaims.find((p: AnyApi) => p.reward > 0) || null + const payoutExists = payouts.find((p: AnyApi) => p.reward > 0) || null - // if no pool claims exist but payouts do, return payouts w.o. event_id - // also do this if there are no payouts period. + // if no pool claims exist but payouts do, return payouts. Also do this if there are no payouts + // period if ( (!poolClaimExists && payoutExists) || (!payoutExists && !poolClaimExists) ) { return payouts.map((p: AnyApi) => ({ - amount: p.amount, - block_timestamp: p.block_timestamp, + reward: p.reward, + timestamp: p.timestamp, })) } - // if no payouts exist but pool claims do, return pool claims w.o. event_id + // if no payouts exist but pool claims do, return pool claims if (!payoutExists && poolClaimExists) { return poolClaims.map((p: AnyApi) => ({ - amount: p.amount, - block_timestamp: p.block_timestamp, + reward: p.reward, + timestamp: p.timestamp, })) } - // We now know pool claims *and* payouts exist. + // We now know pool claims *and* payouts exist // - // Now determine which dates to display. + // Now determine which dates to display let payoutDays: AnyJson[] = [] // prefill `dates` with all pool claim and payout days poolClaims.forEach((p: AnyApi) => { - const dayStart = getUnixTime(startOfDay(fromUnixTime(p.block_timestamp))) + const dayStart = getUnixTime(startOfDay(fromUnixTime(p.timestamp))) if (!payoutDays.includes(dayStart)) { payoutDays.push(dayStart) } }) payouts.forEach((p: AnyApi) => { - const dayStart = getUnixTime(startOfDay(fromUnixTime(p.block_timestamp))) + const dayStart = getUnixTime(startOfDay(fromUnixTime(p.timestamp))) if (!payoutDays.includes(dayStart)) { payoutDays.push(dayStart) } }) - // sort payoutDays by `block_timestamp`; + // sort payoutDays by `timestamp` payoutDays = payoutDays.sort((a: AnyApi, b: AnyApi) => a - b) // Iterate payout days. // - // Combine payouts into one unified `rewards` array. + // Combine payouts into one unified `rewards` array const rewards: AnyApi = [] // loop pool claims and consume / combine payouts payoutDays.forEach((d: AnyApi) => { - let amount = 0 + let reward = 0 // check payouts exist on this day const payoutsThisDay = payouts.filter((p: AnyApi) => - isSameDay(fromUnixTime(p.block_timestamp), fromUnixTime(d)) + isSameDay(fromUnixTime(p.timestamp), fromUnixTime(d)) ) // check pool claims exist on this day const poolClaimsThisDay = poolClaims.filter((p: AnyApi) => - isSameDay(fromUnixTime(p.block_timestamp), fromUnixTime(d)) + isSameDay(fromUnixTime(p.timestamp), fromUnixTime(d)) ) - // add amounts + // add rewards if (payoutsThisDay.concat(poolClaimsThisDay).length) { for (const payout of payoutsThisDay) { - amount += payout.amount + reward += payout.reward } } rewards.push({ - amount, - block_timestamp: d, + reward, + timestamp: d, }) }) return rewards @@ -359,14 +361,14 @@ export const combineRewards = (payouts: AnyApi, poolClaims: AnyApi) => { // Get latest reward. // -// Gets the latest reward from pool claims and nominator payouts. +// Gets the latest reward from pool claims and nominator payouts export const getLatestReward = (payouts: AnyApi, poolClaims: AnyApi) => { // get most recent payout const payoutExists = - payouts.find((p: AnyApi) => new BigNumber(p.amount).isGreaterThan(0)) ?? + payouts.find((p: AnyApi) => new BigNumber(p.reward).isGreaterThan(0)) ?? null const poolClaimExists = - poolClaims.find((p: AnyApi) => new BigNumber(p.amount).isGreaterThan(0)) ?? + poolClaims.find((p: AnyApi) => new BigNumber(p.reward).isGreaterThan(0)) ?? null // calculate which payout was most recent @@ -381,16 +383,16 @@ export const getLatestReward = (payouts: AnyApi, poolClaims: AnyApi) => { } else { // both `payoutExists` and `poolClaimExists` are present lastReward = - payoutExists.block_timestamp > poolClaimExists.block_timestamp + payoutExists.timestamp > poolClaimExists.timestamp ? payoutExists : poolClaimExists } return lastReward } -// Fill in the days from the earliest payout day to `maxDays`. +// Fill in the days from the earliest payout day to `maxDays` // -// Takes the last (earliest) payout and fills the missing days from that payout day to `maxDays`. +// Takes the last (earliest) payout and fills the missing days from that payout day to `maxDays` export const prefillMissingDays = ( payouts: AnyApi, fromDate: Date, @@ -400,32 +402,31 @@ export const prefillMissingDays = ( const payoutStartDay = subDays(startOfDay(fromDate), maxDays) const payoutEndDay = !payouts.length ? startOfDay(fromDate) - : startOfDay(fromUnixTime(payouts[payouts.length - 1].block_timestamp)) + : startOfDay(fromUnixTime(payouts[payouts.length - 1].timestamp)) const daysToPreFill = daysPassed(payoutStartDay, payoutEndDay) if (daysToPreFill > 0) { for (let i = 1; i < daysToPreFill; i++) { newPayouts.push({ - amount: 0, - event_id: 'Reward', - block_timestamp: getUnixTime(subDays(payoutEndDay, i)), + reward: 0, + timestamp: getUnixTime(subDays(payoutEndDay, i)), }) } } return newPayouts } -// Fill in the days from the current day to the last payout. +// Fill in the days from the current day to the last payout // -// Takes the first payout (most recent) and fills the missing days from current day. +// Takes the first payout (most recent) and fills the missing days from current day export const postFillMissingDays = ( payouts: AnyApi, fromDate: Date, maxDays: number ) => { const newPayouts = [] - const payoutsEndDay = startOfDay(fromUnixTime(payouts[0].block_timestamp)) + const payoutsEndDay = startOfDay(fromUnixTime(payouts[0].timestamp)) const daysSinceLast = Math.min( daysPassed(payoutsEndDay, startOfDay(fromDate)), maxDays @@ -433,63 +434,57 @@ export const postFillMissingDays = ( for (let i = daysSinceLast; i > 0; i--) { newPayouts.push({ - amount: 0, - event_id: 'Reward', - block_timestamp: getUnixTime(addDays(payoutsEndDay, i)), + reward: 0, + timestamp: getUnixTime(addDays(payoutsEndDay, i)), }) } return newPayouts } -// Fill gap days within payouts with zero amounts. +// Fill gap days within payouts with zero rewards export const fillGapDays = (payouts: AnyApi, fromDate: Date) => { const finalPayouts: AnyApi = [] - // current day cursor. + // current day cursor let curDay = fromDate for (const p of payouts) { - const thisDay = fromUnixTime(p.block_timestamp) + const thisDay = fromUnixTime(p.timestamp) const gapDays = Math.max(0, daysPassed(thisDay, curDay) - 1) if (gapDays > 0) { - // add any gap days. + // add any gap days if (gapDays > 0) { for (let j = 1; j <= gapDays; j++) { finalPayouts.push({ - amount: 0, - event_id: 'Reward', - block_timestamp: getUnixTime(subDays(curDay, j)), + reward: 0, + timestamp: getUnixTime(subDays(curDay, j)), }) } } } - // add the current day. + // add the current day finalPayouts.push(p) - // day cursor is now the new day. + // day cursor is now the new day curDay = thisDay } return finalPayouts } -// Utiltiy: normalise payout timestamps to start of day. +// Utiltiy: normalise payout timestamps to start of day export const normalisePayouts = (payouts: AnyApi) => payouts.map((p: AnyApi) => ({ ...p, - block_timestamp: getUnixTime(startOfDay(fromUnixTime(p.block_timestamp))), + timestamp: getUnixTime(startOfDay(fromUnixTime(p.timestamp))), })) -// Utility: days passed since 2 dates. +// Utility: days passed since 2 dates export const daysPassed = (from: Date, to: Date) => differenceInDays(startOfDay(to), startOfDay(from)) -// Utility: extract whether an event id should be a slash or reward, based on the net day amount. -const getEventId = (c: PayoutDayCursor) => - c.amount.isLessThan(0) ? 'Slash' : 'Reward' - -// Utility: Formats a width and height pair. +// Utility: Formats a width and height pair export const formatSize = ( { width, @@ -504,3 +499,50 @@ export const formatSize = ( height: height || minHeight, minHeight, }) + +// Take non-zero rewards in most-recent order +export const removeNonZeroAmountAndSort = (payouts: PayoutsAndClaims) => { + const list = payouts + .filter((p) => Number(p.reward) > 0) + .sort((a, b) => b.timestamp - a.timestamp) + + // Calculates from the current date. + const fromTimestamp = getUnixTime(subDays(new Date(), MaxPayoutDays)) + // Ensure payouts not older than `MaxPayoutDays` are returned. + return list.filter(({ timestamp }) => timestamp >= fromTimestamp) +} + +// Calculate the earliest date of a payout list +export const getPayoutsFromDate = ( + payouts: PayoutsAndClaims, + locale: Locale +) => { + if (!payouts.length) { + return undefined + } + const filtered = removeNonZeroAmountAndSort(payouts) + if (!filtered.length) { + return undefined + } + return format( + fromUnixTime(filtered[filtered.length - 1].timestamp), + 'do MMM', + { + locale, + } + ) +} + +// Calculate the latest date of a payout list +export const getPayoutsToDate = (payouts: PayoutsAndClaims, locale: Locale) => { + if (!payouts.length) { + return undefined + } + const filtered = removeNonZeroAmountAndSort(payouts || []) + if (!filtered.length) { + return undefined + } + return format(fromUnixTime(filtered[0].timestamp), 'do MMM', { + locale, + }) +} diff --git a/packages/app/src/library/Graphs/types.ts b/packages/app/src/library/Graphs/types.ts index 401b77c110..503a76066d 100644 --- a/packages/app/src/library/Graphs/types.ts +++ b/packages/app/src/library/Graphs/types.ts @@ -3,7 +3,8 @@ import type BigNumber from 'bignumber.js' import type { AnyApi } from 'common-types' -import type { SubscanPayoutData } from 'controllers/Subscan/types' +import type { SubscanPoolClaim } from 'controllers/Subscan/types' +import type { NominatorReward } from 'plugin-staking-api/types' export interface BondedProps { active: BigNumber @@ -21,7 +22,9 @@ export interface EraPointsProps { export interface PayoutBarProps { days: number height: string - data: SubscanPayoutData + data: GraphPayoutData + nominating: boolean + inPool: boolean } export interface PayoutLineProps { @@ -29,7 +32,15 @@ export interface PayoutLineProps { average: number height: string background?: string - data: SubscanPayoutData + data: GraphPayoutData + nominating: boolean + inPool: boolean +} + +export interface GraphPayoutData { + payouts: NominatorReward[] + unclaimedPayouts: NominatorReward[] + poolClaims: SubscanPoolClaim[] } export interface CardHeaderWrapperProps { @@ -42,8 +53,7 @@ export interface CardWrapperProps { } export interface PayoutDayCursor { - amount: BigNumber - event_id: string + reward: BigNumber } export interface GeoDonutProps { diff --git a/packages/app/src/library/Headers/Sync.tsx b/packages/app/src/library/Headers/Sync.tsx index 6eb7fe1a25..0cf095866f 100644 --- a/packages/app/src/library/Headers/Sync.tsx +++ b/packages/app/src/library/Headers/Sync.tsx @@ -2,7 +2,6 @@ // SPDX-License-Identifier: GPL-3.0-only import { pageFromUri } from '@w3ux/utils' -import { usePayouts } from 'contexts/Payouts' import { useBondedPools } from 'contexts/Pools/BondedPools' import { useTxMeta } from 'contexts/TxMeta' import { useValidators } from 'contexts/Validators/ValidatorEntries' @@ -14,23 +13,11 @@ export const Sync = () => { const { uids } = useTxMeta() const { syncing } = useSyncing() const { pathname } = useLocation() - const { payoutsSynced } = usePayouts() const { validators } = useValidators() const { bondedPools } = useBondedPools() - // Keep syncing if on nominate page and still fetching payouts. - const onNominateSyncing = () => { - if ( - pageFromUri(pathname, 'overview') === 'nominate' && - payoutsSynced !== 'synced' - ) { - return true - } - return false - } - // Keep syncing if on pools page and still fetching bonded pools or pool members. Ignore pool - // member sync if Subscan is enabled. + // member sync if Subscan is enabled const onPoolsSyncing = () => { if (pageFromUri(pathname, 'overview') === 'pools') { if (!bondedPools.length) { @@ -40,7 +27,7 @@ export const Sync = () => { return false } - // Keep syncing if on validators page and still fetching. + // Keep syncing if on validators page and still fetching const onValidatorsSyncing = () => { if ( pageFromUri(pathname, 'overview') === 'validators' && @@ -54,7 +41,6 @@ export const Sync = () => { const isSyncing = syncing || onPoolsSyncing() || - onNominateSyncing() || onValidatorsSyncing() || uids.filter(({ processing }) => processing === true).length > 0 diff --git a/packages/app/src/library/StatusLabel/index.tsx b/packages/app/src/library/StatusLabel/index.tsx index 8920286936..b22662c06a 100644 --- a/packages/app/src/library/StatusLabel/index.tsx +++ b/packages/app/src/library/StatusLabel/index.tsx @@ -27,7 +27,6 @@ export const StatusLabel = ({ const { inSetup } = useStaking() const { getPoolMembership } = useBalances() const { activeAccount } = useActiveAccounts() - const membership = getPoolMembership(activeAccount) // syncing or not staking diff --git a/packages/app/src/overlay/modals/ClaimPayouts/Forms.tsx b/packages/app/src/overlay/modals/ClaimPayouts/Forms.tsx index 6d66ea27ec..5e7d5b80db 100644 --- a/packages/app/src/overlay/modals/ClaimPayouts/Forms.tsx +++ b/packages/app/src/overlay/modals/ClaimPayouts/Forms.tsx @@ -5,10 +5,10 @@ import { faChevronLeft } from '@fortawesome/free-solid-svg-icons' import { planckToUnit } from '@w3ux/utils' import { PayoutStakersByPage } from 'api/tx/payoutStakersByPage' import BigNumber from 'bignumber.js' +import type { AnyApi } from 'common-types' import { useActiveAccounts } from 'contexts/ActiveAccounts' import { useNetwork } from 'contexts/Network' import { usePayouts } from 'contexts/Payouts' -import { Subscan } from 'controllers/Subscan' import { useBatchCall } from 'hooks/useBatchCall' import { useSignerWarnings } from 'hooks/useSignerWarnings' import { useSubmitExtrinsic } from 'hooks/useSubmitExtrinsic' @@ -36,20 +36,19 @@ export const Forms = forwardRef( networkData: { units, unit }, } = useNetwork() const { newBatchCall } = useBatchCall() - const { removeEraPayout } = usePayouts() const { setModalStatus } = useOverlay().modal const { activeAccount } = useActiveAccounts() const { getSignerWarnings } = useSignerWarnings() + const { unclaimedRewards, setUnclaimedRewards } = usePayouts() - // Get the total payout amount. + // Get the total payout amount const totalPayout = payouts?.reduce( (total: BigNumber, cur: ActivePayout) => total.plus(cur.payout), new BigNumber(0) ) || new BigNumber(0) - // Get the total number of validators to payout (the same validator can repeat for separate - // eras). + // Get the total number of validators per payout per era const totalPayoutValidators = payouts?.reduce( (prev, { paginatedValidators }) => @@ -57,33 +56,31 @@ export const Forms = forwardRef( 0 ) || 0 - const getCalls = () => { - const calls = payouts?.reduce((acc, { era, paginatedValidators }) => { - if (!paginatedValidators) { - return acc - } - paginatedValidators.forEach(([page, v]) => { - const tx = new PayoutStakersByPage(network, v, Number(era), page).tx() - - if (tx) { - acc.push() - } - }) - return acc - }, []) - return calls || [] - } - - // Store whether form is valid to submit transaction. const [valid, setValid] = useState( totalPayout.isGreaterThan(0) && totalPayoutValidators > 0 ) - // Ensure payouts value is valid. - useEffect( - () => setValid(totalPayout.isGreaterThan(0) && totalPayoutValidators > 0), - [payouts] - ) + const getCalls = () => { + const calls = + payouts?.reduce((acc: AnyApi[], { era, paginatedValidators }) => { + if (!paginatedValidators.length) { + return acc + } + paginatedValidators.forEach(([page, v]) => { + const tx = new PayoutStakersByPage( + network, + v, + Number(era), + page + ).tx() + if (tx) { + acc.push(tx) + } + }) + return acc + }, []) || [] + return calls + } const getTx = () => { const tx = null @@ -91,7 +88,6 @@ export const Forms = forwardRef( if (!valid || !calls.length) { return tx } - return calls.length === 1 ? calls.pop() : newBatchCall(calls, activeAccount) @@ -106,21 +102,22 @@ export const Forms = forwardRef( }, callbackInBlock: () => { if (payouts && activeAccount) { - // Remove Subscan unclaimed payout record(s) if they exist. + // Deduct unclaimed payout value from state value const eraPayouts: string[] = [] payouts.forEach(({ era }) => { eraPayouts.push(String(era)) }) - Subscan.removeUnclaimedPayouts(activeAccount, eraPayouts) - - // Deduct from `unclaimedPayouts` in Payouts context. - payouts.forEach(({ era, paginatedValidators }) => { - for (const v of paginatedValidators || []) { - removeEraPayout(era, v[1]) - } - }) + const newUnclaimedRewards = { + total: new BigNumber(unclaimedRewards.total) + .minus(totalPayout) + .toString(), + entries: unclaimedRewards.entries.filter( + (entry) => !eraPayouts.includes(String(entry.era)) + ), + } + setUnclaimedRewards(newUnclaimedRewards) } - // Reset active form payouts for this modal. + // Reset active form payouts for this modal setPayouts([]) }, }) @@ -131,6 +128,12 @@ export const Forms = forwardRef( submitExtrinsic.proxySupported ) + // Ensure payouts value is valid + useEffect( + () => setValid(totalPayout.isGreaterThan(0) && totalPayoutValidators > 0), + [payouts] + ) + return (
diff --git a/packages/app/src/overlay/modals/ClaimPayouts/Item.tsx b/packages/app/src/overlay/modals/ClaimPayouts/Item.tsx index d4267f5aed..f07ca582e5 100644 --- a/packages/app/src/overlay/modals/ClaimPayouts/Item.tsx +++ b/packages/app/src/overlay/modals/ClaimPayouts/Item.tsx @@ -11,7 +11,7 @@ import { ItemWrapper } from './Wrappers' export const Item = ({ era, - unclaimedPayout, + validators, setPayouts, setSection, }: ItemProps) => { @@ -19,9 +19,8 @@ export const Item = ({ const { networkData: { units, unit }, } = useNetwork() - - const totalPayout = getTotalPayout(unclaimedPayout) - const numPayouts = Object.values(unclaimedPayout).length + const totalPayout = getTotalPayout(validators) + const numPayouts = validators.length return ( @@ -39,7 +38,6 @@ export const Item = ({ {planckToUnitBn(totalPayout, units).toString()} {unit} -
[page, v] + paginatedValidators: validators.map( + ({ page, validator }) => [page || 0, validator] ), }, ]) diff --git a/packages/app/src/overlay/modals/ClaimPayouts/Overview.tsx b/packages/app/src/overlay/modals/ClaimPayouts/Overview.tsx index ca9b253da8..8bdfb2ec3f 100644 --- a/packages/app/src/overlay/modals/ClaimPayouts/Overview.tsx +++ b/packages/app/src/overlay/modals/ClaimPayouts/Overview.tsx @@ -1,6 +1,7 @@ // Copyright 2024 @polkadot-cloud/polkadot-staking-dashboard authors & contributors // SPDX-License-Identifier: GPL-3.0-only +import BigNumber from 'bignumber.js' import { usePayouts } from 'contexts/Payouts' import { ModalNotes } from 'kits/Overlay/structure/ModalNotes' import { ModalPadding } from 'kits/Overlay/structure/ModalPadding' @@ -9,30 +10,28 @@ import { Fragment, forwardRef } from 'react' import { useTranslation } from 'react-i18next' import { Item } from './Item' import type { OverviewProps } from './types' -import { getTotalPayout } from './Utils' import { ContentWrapper } from './Wrappers' export const Overview = forwardRef( ({ setSection, setPayouts }: OverviewProps, ref: Ref) => { const { t } = useTranslation('modals') - const { unclaimedPayouts } = usePayouts() + const { unclaimedRewards } = usePayouts() return ( - {Object.entries(unclaimedPayouts || {}).map( - ([era, unclaimedPayout], i) => - getTotalPayout(unclaimedPayout).isZero() ? ( - - ) : ( - - ) + {unclaimedRewards.entries.map(({ era, reward, validators }, i) => + new BigNumber(reward).isZero() ? ( + + ) : ( + + ) )}

{t('claimsOnBehalf')}

diff --git a/packages/app/src/overlay/modals/ClaimPayouts/Utils.ts b/packages/app/src/overlay/modals/ClaimPayouts/Utils.ts index 92b9f1e69a..b757144e87 100644 --- a/packages/app/src/overlay/modals/ClaimPayouts/Utils.ts +++ b/packages/app/src/overlay/modals/ClaimPayouts/Utils.ts @@ -2,13 +2,12 @@ // SPDX-License-Identifier: GPL-3.0-only import BigNumber from 'bignumber.js' -import type { EraUnclaimedPayouts } from 'contexts/Payouts/types' +import type { ValidatorUnclaimedReward } from 'plugin-staking-api/types' export const getTotalPayout = ( - unclaimedPayout: EraUnclaimedPayouts + validators: ValidatorUnclaimedReward[] ): BigNumber => - Object.values(unclaimedPayout).reduce( - (acc: BigNumber, paginatedValidator: [number, string]) => - acc.plus(paginatedValidator[1]), + validators.reduce( + (acc: BigNumber, { reward }: ValidatorUnclaimedReward) => acc.plus(reward), new BigNumber(0) ) diff --git a/packages/app/src/overlay/modals/ClaimPayouts/index.tsx b/packages/app/src/overlay/modals/ClaimPayouts/index.tsx index 6381d584f2..fd2b2eae42 100644 --- a/packages/app/src/overlay/modals/ClaimPayouts/index.tsx +++ b/packages/app/src/overlay/modals/ClaimPayouts/index.tsx @@ -16,7 +16,7 @@ import type { ActivePayout } from './types' export const ClaimPayouts = () => { const { t } = useTranslation('modals') - const { unclaimedPayouts } = usePayouts() + const { unclaimedRewards } = usePayouts() const { setModalHeight, modalMaxHeight } = useOverlay().modal // Active modal section. @@ -51,7 +51,7 @@ export const ClaimPayouts = () => { // Resize modal on state change. useEffect(() => { onResize() - }, [unclaimedPayouts, section]) + }, [unclaimedRewards.total, section]) // Resize this modal on window resize. useEffect(() => { diff --git a/packages/app/src/overlay/modals/ClaimPayouts/types.ts b/packages/app/src/overlay/modals/ClaimPayouts/types.ts index 7fdfb2816a..d665b7f9a6 100644 --- a/packages/app/src/overlay/modals/ClaimPayouts/types.ts +++ b/packages/app/src/overlay/modals/ClaimPayouts/types.ts @@ -1,11 +1,11 @@ // Copyright 2024 @polkadot-cloud/polkadot-staking-dashboard authors & contributors // SPDX-License-Identifier: GPL-3.0-only -import type { EraUnclaimedPayouts } from 'contexts/Payouts/types' +import type { ValidatorUnclaimedReward } from 'plugin-staking-api/types' export interface ItemProps { era: string - unclaimedPayout: EraUnclaimedPayouts + validators: ValidatorUnclaimedReward[] setSection: (v: number) => void setPayouts: (payout: ActivePayout[] | null) => void } diff --git a/packages/app/src/pages/Nominate/Active/Status/UnclaimedPayoutsStatus.tsx b/packages/app/src/pages/Nominate/Active/Status/UnclaimedPayoutsStatus.tsx index fc0e54826e..b26e85f52a 100644 --- a/packages/app/src/pages/Nominate/Active/Status/UnclaimedPayoutsStatus.tsx +++ b/packages/app/src/pages/Nominate/Active/Status/UnclaimedPayoutsStatus.tsx @@ -2,17 +2,16 @@ // SPDX-License-Identifier: GPL-3.0-only import { faCircleDown } from '@fortawesome/free-solid-svg-icons' -import { minDecimalPlaces } from '@w3ux/utils' -import BigNumber from 'bignumber.js' +import { minDecimalPlaces, planckToUnit } from '@w3ux/utils' import { useActiveAccounts } from 'contexts/ActiveAccounts' import { useApi } from 'contexts/Api' import { useImportedAccounts } from 'contexts/Connect/ImportedAccounts' import { useNetwork } from 'contexts/Network' import { usePayouts } from 'contexts/Payouts' +import { usePlugins } from 'contexts/Plugins' import { useOverlay } from 'kits/Overlay/Provider' import { Stat } from 'library/Stat' import { useTranslation } from 'react-i18next' -import { planckToUnitBn } from 'utils' export const UnclaimedPayoutsStatus = ({ dimmed }: { dimmed: boolean }) => { const { t } = useTranslation() @@ -21,33 +20,27 @@ export const UnclaimedPayoutsStatus = ({ dimmed }: { dimmed: boolean }) => { } = useNetwork() const { isReady } = useApi() const { openModal } = useOverlay().modal - const { unclaimedPayouts } = usePayouts() + const { + unclaimedRewards: { total }, + } = usePayouts() + const { pluginEnabled } = usePlugins() const { activeAccount } = useActiveAccounts() const { isReadOnlyAccount } = useImportedAccounts() - const totalUnclaimed = Object.values(unclaimedPayouts || {}).reduce( - (total, paginatedValidators) => - Object.values(paginatedValidators) - .reduce((amount, [, value]) => amount.plus(value), new BigNumber(0)) - .plus(total), - new BigNumber(0) - ) - return ( 0 && - !totalUnclaimed.isZero() + total !== '0' && pluginEnabled('staking_api') ? [ { title: t('claim', { ns: 'modals' }), diff --git a/packages/app/src/pages/Nominate/Active/Status/index.tsx b/packages/app/src/pages/Nominate/Active/Status/index.tsx index 60ea4e26f6..12c6bf3ac3 100644 --- a/packages/app/src/pages/Nominate/Active/Status/index.tsx +++ b/packages/app/src/pages/Nominate/Active/Status/index.tsx @@ -3,6 +3,7 @@ import { useActiveAccounts } from 'contexts/ActiveAccounts' import { useImportedAccounts } from 'contexts/Connect/ImportedAccounts' +import { usePlugins } from 'contexts/Plugins' import { useStaking } from 'contexts/Staking' import { useSyncing } from 'hooks/useSyncing' import { CardWrapper } from 'library/Card/Wrappers' @@ -15,6 +16,7 @@ import { UnclaimedPayoutsStatus } from './UnclaimedPayoutsStatus' export const Status = ({ height }: { height: number }) => { const { syncing } = useSyncing() const { inSetup } = useStaking() + const { pluginEnabled } = usePlugins() const { activeAccount } = useActiveAccounts() const { isReadOnlyAccount } = useImportedAccounts() @@ -25,7 +27,9 @@ export const Status = ({ height }: { height: number }) => { > - + {!syncing ? ( !inSetup() ? ( diff --git a/packages/app/src/pages/Overview/Payouts/ActiveGraph.tsx b/packages/app/src/pages/Overview/Payouts/ActiveGraph.tsx new file mode 100644 index 0000000000..04cc4f2a5f --- /dev/null +++ b/packages/app/src/pages/Overview/Payouts/ActiveGraph.tsx @@ -0,0 +1,75 @@ +// Copyright 2024 @polkadot-cloud/polkadot-staking-dashboard authors & contributors +// SPDX-License-Identifier: GPL-3.0-only + +import { useActiveAccounts } from 'contexts/ActiveAccounts' +import { useApi } from 'contexts/Api' +import { useNetwork } from 'contexts/Network' +import { useSubscanData } from 'hooks/useSubscanData' +import { PayoutBar } from 'library/Graphs/PayoutBar' +import { PayoutLine } from 'library/Graphs/PayoutLine' +import { ApolloProvider, client, useRewards } from 'plugin-staking-api' +import type { NominatorReward } from 'plugin-staking-api/types' +import { useEffect } from 'react' + +interface Props { + nominating: boolean + inPool: boolean + lineMarginTop: string + setLastReward: (reward: NominatorReward | undefined) => void +} +export const ActiveGraphInner = ({ + nominating, + inPool, + lineMarginTop, + setLastReward, +}: Props) => { + const { activeEra } = useApi() + const { network } = useNetwork() + const { poolClaims } = useSubscanData() + const { activeAccount } = useActiveAccounts() + + const { data } = useRewards({ + chain: network, + who: activeAccount || '', + fromEra: Math.max(activeEra.index.minus(1).toNumber(), 0), + }) + const allRewards = data?.allRewards ?? [] + const payouts = + allRewards.filter((reward: NominatorReward) => reward.claimed === true) ?? + [] + const unclaimedPayouts = + allRewards.filter((reward: NominatorReward) => reward.claimed === false) ?? + [] + + useEffect(() => { + setLastReward(payouts[0]) + }, [JSON.stringify(payouts[0])]) + + return ( + <> + +
+ +
+ + ) +} + +export const ActiveGraph = (props: Props) => ( + + + +) diff --git a/packages/app/src/pages/Overview/Payouts/InactiveGraph.tsx b/packages/app/src/pages/Overview/Payouts/InactiveGraph.tsx new file mode 100644 index 0000000000..f6cf38b401 --- /dev/null +++ b/packages/app/src/pages/Overview/Payouts/InactiveGraph.tsx @@ -0,0 +1,39 @@ +// Copyright 2024 @polkadot-cloud/polkadot-staking-dashboard authors & contributors +// SPDX-License-Identifier: GPL-3.0-only + +import { PayoutBar } from 'library/Graphs/PayoutBar' +import { PayoutLine } from 'library/Graphs/PayoutLine' +import type { NominatorReward } from 'plugin-staking-api/types' +import { useEffect } from 'react' + +export const InactiveGraph = ({ + setLastReward, +}: { + setLastReward: (reward: NominatorReward | undefined) => void +}) => { + useEffect(() => { + setLastReward(undefined) + }, []) + + return ( + <> + +
+ +
+ + ) +} diff --git a/packages/app/src/pages/Overview/Payouts.tsx b/packages/app/src/pages/Overview/Payouts/index.tsx similarity index 64% rename from packages/app/src/pages/Overview/Payouts.tsx rename to packages/app/src/pages/Overview/Payouts/index.tsx index cc1bd3ad6b..de8fa613e1 100644 --- a/packages/app/src/pages/Overview/Payouts.tsx +++ b/packages/app/src/pages/Overview/Payouts/index.tsx @@ -5,23 +5,25 @@ import { useSize } from '@w3ux/hooks' import { Odometer } from '@w3ux/react-odometer' import { minDecimalPlaces } from '@w3ux/utils' import BigNumber from 'bignumber.js' +import { useActiveAccounts } from 'contexts/ActiveAccounts' +import { useBalances } from 'contexts/Balances' import { useNetwork } from 'contexts/Network' import { usePlugins } from 'contexts/Plugins' import { useStaking } from 'contexts/Staking' import { useUi } from 'contexts/UI' import { formatDistance, fromUnixTime, getUnixTime } from 'date-fns' -import { useSubscanData } from 'hooks/useSubscanData' import { useSyncing } from 'hooks/useSyncing' import { CardHeaderWrapper } from 'library/Card/Wrappers' -import { PayoutBar } from 'library/Graphs/PayoutBar' -import { PayoutLine } from 'library/Graphs/PayoutLine' -import { formatRewardsForGraphs, formatSize } from 'library/Graphs/Utils' +import { formatSize } from 'library/Graphs/Utils' import { GraphWrapper } from 'library/Graphs/Wrapper' import { StatusLabel } from 'library/StatusLabel' import { DefaultLocale, locales } from 'locales' -import { useRef } from 'react' +import type { NominatorReward } from 'plugin-staking-api/types' +import { useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { planckToUnitBn } from 'utils' +import { ActiveGraph } from './ActiveGraph' +import { InactiveGraph } from './InactiveGraph' export const Payouts = () => { const { i18n, t } = useTranslation('pages') @@ -33,46 +35,33 @@ export const Payouts = () => { } = useNetwork() const { inSetup } = useStaking() const { syncing } = useSyncing() - const { plugins } = usePlugins() const { containerRefs } = useUi() - const { getData, injectBlockTimestamp } = useSubscanData([ - 'payouts', - 'unclaimedPayouts', - 'poolClaims', - ]) - const notStaking = !syncing && inSetup() + const { pluginEnabled } = usePlugins() + const { getPoolMembership } = useBalances() + const { activeAccount } = useActiveAccounts() - // Get data safely from subscan hook. - const data = getData(['payouts', 'unclaimedPayouts', 'poolClaims']) + const membership = getPoolMembership(activeAccount) + const nominating = !inSetup() + const inPool = membership !== null + const staking = nominating || inPool + const notStaking = !syncing && !staking - // Inject `block_timestamp` for unclaimed payouts. - data['unclaimedPayouts'] = injectBlockTimestamp(data?.unclaimedPayouts || []) + const [lastReward, setLastReward] = useState() - // Ref to the graph container. + // Ref to the graph container const graphInnerRef = useRef(null) - // Get the size of the graph container. + // Get the size of the graph container const size = useSize(graphInnerRef, { outerElement: containerRefs?.mainInterface, }) const { width, height, minHeight } = formatSize(size, 260) - // Get the last reward with its timestmap. - const { lastReward } = formatRewardsForGraphs( - new Date(), - 14, - units, - data.payouts, - data.poolClaims, - data.unclaimedPayouts - ) let formatFrom = new Date() let formatTo = new Date() let formatOpts = {} - if (lastReward !== null) { - formatFrom = fromUnixTime( - lastReward?.block_timestamp ?? getUnixTime(new Date()) - ) + if (lastReward !== undefined) { + formatFrom = fromUnixTime(lastReward.timestamp ?? getUnixTime(new Date())) formatTo = new Date() formatOpts = { addSuffix: true, @@ -88,17 +77,17 @@ export const Payouts = () => { - {lastReward === null ? ( + {lastReward === undefined ? ( '' ) : ( <> {formatDistance(formatFrom, formatTo, formatOpts)} @@ -107,11 +96,11 @@ export const Payouts = () => {
- {!plugins.includes('subscan') ? ( + {!pluginEnabled('staking_api') ? ( ) : ( @@ -130,10 +119,16 @@ export const Payouts = () => { transition: 'opacity 0.5s', }} > - -
- -
+ {staking && pluginEnabled('staking_api') ? ( + + ) : ( + + )}
diff --git a/packages/app/src/pages/Overview/index.tsx b/packages/app/src/pages/Overview/index.tsx index 079b02074b..e2b2c18371 100644 --- a/packages/app/src/pages/Overview/index.tsx +++ b/packages/app/src/pages/Overview/index.tsx @@ -2,7 +2,6 @@ // SPDX-License-Identifier: GPL-3.0-only import { CardWrapper } from 'library/Card/Wrappers' -import { PluginLabel } from 'library/PluginLabel' import { StatBoxList } from 'library/StatBoxList' import { useTranslation } from 'react-i18next' import { PageHeading, PageRow, PageTitle, RowSection } from 'ui-structure' @@ -45,7 +44,6 @@ export const Overview = () => { - diff --git a/packages/app/src/pages/Payouts/ActiveGraph.tsx b/packages/app/src/pages/Payouts/ActiveGraph.tsx new file mode 100644 index 0000000000..4a9c8b483b --- /dev/null +++ b/packages/app/src/pages/Payouts/ActiveGraph.tsx @@ -0,0 +1,81 @@ +// Copyright 2024 @polkadot-cloud/polkadot-staking-dashboard authors & contributors +// SPDX-License-Identifier: GPL-3.0-only + +import type { AnyApi } from 'common-types' +import { MaxPayoutDays } from 'consts' +import { useActiveAccounts } from 'contexts/ActiveAccounts' +import { useApi } from 'contexts/Api' +import { useNetwork } from 'contexts/Network' +import type { PayoutsAndClaims } from 'controllers/Subscan/types' +import { useSubscanData } from 'hooks/useSubscanData' +import { PayoutBar } from 'library/Graphs/PayoutBar' +import { PayoutLine } from 'library/Graphs/PayoutLine' +import { removeNonZeroAmountAndSort } from 'library/Graphs/Utils' +import { ApolloProvider, client, useRewards } from 'plugin-staking-api' +import type { NominatorReward } from 'plugin-staking-api/types' +import { useEffect } from 'react' + +interface Props { + nominating: boolean + inPool: boolean + setPayoutLists: (payouts: AnyApi[]) => void +} + +export const ActiveGraphInner = ({ + nominating, + inPool, + setPayoutLists, +}: Props) => { + const { activeEra } = useApi() + const { network } = useNetwork() + const { poolClaims } = useSubscanData() + const { activeAccount } = useActiveAccounts() + + const { data } = useRewards({ + chain: network, + who: activeAccount || '', + fromEra: Math.max(activeEra.index.minus(1).toNumber(), 0), + }) + + const allRewards = data?.allRewards ?? [] + const payouts = + allRewards.filter((reward: NominatorReward) => reward.claimed === true) ?? + [] + const unclaimedPayouts = + allRewards.filter((reward: NominatorReward) => reward.claimed === false) ?? + [] + + useEffect(() => { + // filter zero rewards and order via timestamp, most recent first + const payoutsList = (allRewards as PayoutsAndClaims).concat( + poolClaims + ) as PayoutsAndClaims + setPayoutLists(removeNonZeroAmountAndSort(payoutsList)) + }, [JSON.stringify(payouts), JSON.stringify(poolClaims)]) + + return ( + <> + + + + ) +} + +export const ActiveGraph = (props: Props) => ( + + + +) diff --git a/packages/app/src/pages/Payouts/InactiveGraph.tsx b/packages/app/src/pages/Payouts/InactiveGraph.tsx new file mode 100644 index 0000000000..6169f03258 --- /dev/null +++ b/packages/app/src/pages/Payouts/InactiveGraph.tsx @@ -0,0 +1,26 @@ +// Copyright 2024 @polkadot-cloud/polkadot-staking-dashboard authors & contributors +// SPDX-License-Identifier: GPL-3.0-only + +import { MaxPayoutDays } from 'consts' +import { PayoutBar } from 'library/Graphs/PayoutBar' +import { PayoutLine } from 'library/Graphs/PayoutLine' + +export const InactiveGraph = () => ( + <> + + + +) diff --git a/packages/app/src/pages/Payouts/PayoutList/index.tsx b/packages/app/src/pages/Payouts/PayoutList/index.tsx index da97c0b7d2..5748e3f7db 100644 --- a/packages/app/src/pages/Payouts/PayoutList/index.tsx +++ b/packages/app/src/pages/Payouts/PayoutList/index.tsx @@ -40,30 +40,28 @@ export const PayoutListInner = ({ const { networkData: { units, unit, colors }, } = useNetwork() - const { listFormat, setListFormat } = usePayoutList() const { validators } = useValidators() const { bondedPools } = useBondedPools() + const { listFormat, setListFormat } = usePayoutList() - // current page const [page, setPage] = useState(1) - // manipulated list (ordering, filtering) of payouts + // Manipulated list (ordering, filtering) of payouts const [payouts, setPayouts] = useState(initialPayouts) - // is this the initial fetch + // Whether still in initial fetch const [fetched, setFetched] = useState(false) - // pagination const totalPages = Math.ceil(payouts.length / payoutsPerPage) const pageEnd = page * payoutsPerPage - 1 const pageStart = pageEnd - (payoutsPerPage - 1) - // refetch list when list changes + // Refetch list when list changes useEffect(() => { setFetched(false) }, [initialPayouts]) - // configure list when network is ready to fetch + // Configure list when network is ready to fetch useEffect(() => { if (isReady && !activeEra.index.isZero() && !fetched) { setPayouts(initialPayouts) @@ -71,13 +69,8 @@ export const PayoutListInner = ({ } }, [isReady, fetched, activeEra.index]) - // get list items to render - let listPayouts = [] - - // get throttled subset or entire list - listPayouts = payouts.slice(pageStart).slice(0, payoutsPerPage) - - if (!payouts.length) { + const listPayouts = payouts.slice(pageStart).slice(0, payoutsPerPage) + if (!listPayouts.length) { return null } @@ -109,25 +102,9 @@ export const PayoutListInner = ({ {listPayouts.map((p: AnyApi, index: number) => { const label = - p.event_id === 'PaidOut' - ? t('payouts.poolClaim') - : p.event_id === 'Rewarded' - ? t('payouts.payout') - : p.event_id - - const labelClass = - p.event_id === 'PaidOut' - ? 'claim' - : p.event_id === 'Rewarded' - ? 'reward' - : undefined - - // get validator if it exists - const validator = validators.find( - (v) => v.address === p.validator_stash - ) - - // get pool if it exists + p.type === 'pool' ? t('payouts.poolClaim') : t('payouts.payout') + const labelClass = p.type === 'pool' ? 'claim' : 'reward' + const validator = validators.find((v) => v.address === p.validator) const pool = bondedPools.find(({ id }) => id === p.pool_id) const batchIndex = validator @@ -158,9 +135,9 @@ export const PayoutListInner = ({

<> - {p.event_id === 'Slashed' ? '-' : '+'} + + {planckToUnitBn( - new BigNumber(p.amount), + new BigNumber(p.reward), units ).toString()}{' '} {unit} @@ -177,9 +154,9 @@ export const PayoutListInner = ({
{label === t('payouts.payout') && (batchIndex > 0 ? ( - + ) : ( -
{ellipsisFn(p.validator_stash)}
+
{ellipsisFn(p.validator)}
))} {label === t('payouts.poolClaim') && (pool ? ( @@ -196,7 +173,7 @@ export const PayoutListInner = ({
{formatDistance( - fromUnixTime(p.block_timestamp), + fromUnixTime(p.timestamp), new Date(), { addSuffix: true, diff --git a/packages/app/src/pages/Payouts/index.tsx b/packages/app/src/pages/Payouts/index.tsx index c50f9da85a..57847c3df1 100644 --- a/packages/app/src/pages/Payouts/index.tsx +++ b/packages/app/src/pages/Payouts/index.tsx @@ -3,20 +3,20 @@ import { useSize } from '@w3ux/hooks' import type { AnyApi, PageProps } from 'common-types' -import { MaxPayoutDays } from 'consts' +import { useActiveAccounts } from 'contexts/ActiveAccounts' +import { useBalances } from 'contexts/Balances' import { useHelp } from 'contexts/Help' import { usePlugins } from 'contexts/Plugins' import { useStaking } from 'contexts/Staking' import { useUi } from 'contexts/UI' -import { Subscan } from 'controllers/Subscan' -import { useSubscanData } from 'hooks/useSubscanData' import { useSyncing } from 'hooks/useSyncing' import { CardHeaderWrapper, CardWrapper } from 'library/Card/Wrappers' -import { PayoutBar } from 'library/Graphs/PayoutBar' -import { PayoutLine } from 'library/Graphs/PayoutLine' -import { formatSize } from 'library/Graphs/Utils' +import { + formatSize, + getPayoutsFromDate, + getPayoutsToDate, +} from 'library/Graphs/Utils' import { GraphWrapper } from 'library/Graphs/Wrapper' -import { PluginLabel } from 'library/PluginLabel' import { StatBoxList } from 'library/StatBoxList' import { StatusLabel } from 'library/StatusLabel' import { DefaultLocale, locales } from 'locales' @@ -24,24 +24,28 @@ import { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { ButtonHelp } from 'ui-buttons' import { PageRow, PageTitle } from 'ui-structure' +import { ActiveGraph } from './ActiveGraph' +import { InactiveGraph } from './InactiveGraph' import { PayoutList } from './PayoutList' import { LastEraPayoutStat } from './Stats/LastEraPayout' export const Payouts = ({ page: { key } }: PageProps) => { const { i18n, t } = useTranslation() const { openHelp } = useHelp() - const { plugins } = usePlugins() const { inSetup } = useStaking() const { syncing } = useSyncing() const { containerRefs } = useUi() - const { getData, injectBlockTimestamp } = useSubscanData([ - 'payouts', - 'unclaimedPayouts', - 'poolClaims', - ]) - const notStaking = !syncing && inSetup() + const { pluginEnabled } = usePlugins() + const { getPoolMembership } = useBalances() + const { activeAccount } = useActiveAccounts() - const [payoutsList, setPayoutLists] = useState([]) + const membership = getPoolMembership(activeAccount) + const nominating = !inSetup() + const inPool = membership !== null + const staking = nominating || inPool + const notStaking = !syncing && !staking + + const [payoutsList, setPayoutLists] = useState([]) const ref = useRef(null) const size = useSize(ref, { @@ -49,33 +53,20 @@ export const Payouts = ({ page: { key } }: PageProps) => { }) const { width, height, minHeight } = formatSize(size, 280) - // Get data safely from subscan hook. - const data = getData(['payouts', 'unclaimedPayouts', 'poolClaims']) - - // Inject `block_timestamp` for unclaimed payouts. - data['unclaimedPayouts'] = injectBlockTimestamp(data?.unclaimedPayouts || []) - - const payoutsFromDate = Subscan.payoutsFromDate( - (data?.payouts || []).concat(data?.poolClaims || []), + const payoutsFromDate = getPayoutsFromDate( + payoutsList, locales[i18n.resolvedLanguage ?? DefaultLocale].dateFormat ) - - const payoutsToDate = Subscan.payoutsToDate( - (data?.payouts || []).concat(data?.poolClaims || []), + const payoutsToDate = getPayoutsToDate( + payoutsList, locales[i18n.resolvedLanguage ?? DefaultLocale].dateFormat ) useEffect(() => { - // filter zero rewards and order via block timestamp, most recent first. - setPayoutLists( - Subscan.removeNonZeroAmountAndSort( - (data?.payouts || []).concat(data?.poolClaims || []) - ) - ) - }, [ - JSON.stringify(data?.payouts || {}), - JSON.stringify(data?.poolClaims || {}), - ]) + if (!pluginEnabled('staking_api')) { + setPayoutLists([]) + } + }, [pluginEnabled('staking_api')]) return ( <> @@ -85,7 +76,6 @@ export const Payouts = ({ page: { key } }: PageProps) => { -

{t('payouts.payoutHistory', { ns: 'pages' })} @@ -108,11 +98,11 @@ export const Payouts = ({ page: { key } }: PageProps) => {

- {!plugins.includes('subscan') ? ( + {!pluginEnabled('staking_api') ? ( ) : ( @@ -122,7 +112,6 @@ export const Payouts = ({ page: { key } }: PageProps) => { topOffset="30%" /> )} - { transition: 'opacity 0.5s', }} > - - + {staking && pluginEnabled('staking_api') ? ( + + ) : ( + + )}
diff --git a/packages/app/src/workers/stakers.ts b/packages/app/src/workers/stakers.ts index 8fbb704e12..761b8164fc 100644 --- a/packages/app/src/workers/stakers.ts +++ b/packages/app/src/workers/stakers.ts @@ -4,12 +4,12 @@ import type { AnyJson } from '@w3ux/types' import { planckToUnit, rmCommas } from '@w3ux/utils' import BigNumber from 'bignumber.js' -import type { LocalValidatorExposure } from 'contexts/Payouts/types' import type { ActiveAccountStaker, ExposureOther, Staker, } from 'contexts/Staking/types' +import type { LocalValidatorExposure } from 'contexts/Validators/types' import type { ProcessEraForExposureArgs, ProcessExposuresArgs } from './types' // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/packages/app/tests/graphs.test.ts b/packages/app/tests/graphs.test.ts index cf062de510..074ec3ff24 100644 --- a/packages/app/tests/graphs.test.ts +++ b/packages/app/tests/graphs.test.ts @@ -13,36 +13,30 @@ import { expect, test } from 'vitest' // payouts that were made 2, 3 and 4 days ago. const mockPayouts = [ { - amount: '10000000000', - block_timestamp: getUnixTime(subDays(new Date(), 2)), + reward: '10000000000', + timestamp: getUnixTime(subDays(new Date(), 2)), }, { - amount: '15000000000', - block_timestamp: getUnixTime(subDays(new Date(), 3)), + reward: '15000000000', + timestamp: getUnixTime(subDays(new Date(), 3)), }, { - amount: '5000000000', - block_timestamp: getUnixTime(subDays(new Date(), 4)), + reward: '5000000000', + timestamp: getUnixTime(subDays(new Date(), 4)), }, ] -// Get the correct amount of days passed between 2 payout timestamps. +// Get the correct reward of days passed between 2 payout timestamps. // // `daysPassed` is a utility function that is used throughout the graph data accumulation process. test('days passed works', () => { const payouts = normalisePayouts(mockPayouts) // days passed works on `mockPayouts`. - expect( - daysPassed(fromUnixTime(payouts[0].block_timestamp), startOfToday()) - ).toBe(2) - expect( - daysPassed(fromUnixTime(payouts[1].block_timestamp), startOfToday()) - ).toBe(3) - expect( - daysPassed(fromUnixTime(payouts[2].block_timestamp), startOfToday()) - ).toBe(4) - - // max amount of missing days to process should be correct. + expect(daysPassed(fromUnixTime(payouts[0].timestamp), startOfToday())).toBe(2) + expect(daysPassed(fromUnixTime(payouts[1].timestamp), startOfToday())).toBe(3) + expect(daysPassed(fromUnixTime(payouts[2].timestamp), startOfToday())).toBe(4) + + // max reward of missing days to process should be correct. for (let i = 1; i < 368; i++) { expect(daysPassed(subDays(new Date(), i), new Date())).toBe(i) } @@ -61,7 +55,7 @@ test('post fill missing days works', () => { // post fill the missing days for mock payouts. const missingDays = postFillMissingDays(payouts, fromDate, maxDays) - // amount of missing days returned should be correct. + // reward of missing days returned should be correct. expect(missingDays.length).toBe(2) // concatenated payouts are correct @@ -72,12 +66,12 @@ test('post fill missing days works', () => { if (i > 0) { expect( daysPassed( - fromUnixTime(concatPayouts[i].block_timestamp), - fromUnixTime(concatPayouts[i - 1].block_timestamp) + fromUnixTime(concatPayouts[i].timestamp), + fromUnixTime(concatPayouts[i - 1].timestamp) ) ).toBe(1) - expect(concatPayouts[i].block_timestamp).toBeLessThan( - concatPayouts[i - 1].block_timestamp + expect(concatPayouts[i].timestamp).toBeLessThan( + concatPayouts[i - 1].timestamp ) } } @@ -96,7 +90,7 @@ test('pre fill missing days works', () => { // post fill the missing days for mock payouts. const missingDays = prefillMissingDays(payouts, fromDate, maxDays) - // expect amount of missing days to be 2 + // expect reward of missing days to be 2 expect(missingDays.length).toBe(2) // concatenated payouts are correct @@ -107,12 +101,12 @@ test('pre fill missing days works', () => { if (i > 0) { expect( daysPassed( - fromUnixTime(concatPayouts[i].block_timestamp), - fromUnixTime(concatPayouts[i - 1].block_timestamp) + fromUnixTime(concatPayouts[i].timestamp), + fromUnixTime(concatPayouts[i - 1].timestamp) ) ).toBe(1) - expect(concatPayouts[i].block_timestamp).toBeLessThan( - concatPayouts[i - 1].block_timestamp + expect(concatPayouts[i].timestamp).toBeLessThan( + concatPayouts[i - 1].timestamp ) } } @@ -143,12 +137,12 @@ test('pre fill and post fill missing days work together', () => { if (i > 0) { expect( daysPassed( - fromUnixTime(finalPayouts[i].block_timestamp), - fromUnixTime(finalPayouts[i - 1].block_timestamp) + fromUnixTime(finalPayouts[i].timestamp), + fromUnixTime(finalPayouts[i - 1].timestamp) ) ).toBe(1) - expect(finalPayouts[i].block_timestamp).toBeLessThan( - finalPayouts[i - 1].block_timestamp + expect(finalPayouts[i].timestamp).toBeLessThan( + finalPayouts[i - 1].timestamp ) } } diff --git a/packages/locales/src/resources/cn/pages.json b/packages/locales/src/resources/cn/pages.json index 72dc9071ae..e2cc0b0876 100644 --- a/packages/locales/src/resources/cn/pages.json +++ b/packages/locales/src/resources/cn/pages.json @@ -1,5 +1,8 @@ { "pages": { + "common": { + "stakingApiDisabled": "Staking API己断开" + }, "community": { "bio": "简介", "connecting": "连接中", diff --git a/packages/locales/src/resources/en/pages.json b/packages/locales/src/resources/en/pages.json index 7d5501c54c..22bb0e9062 100644 --- a/packages/locales/src/resources/en/pages.json +++ b/packages/locales/src/resources/en/pages.json @@ -1,5 +1,8 @@ { "pages": { + "common": { + "stakingApiDisabled": "Staking API Disabled" + }, "community": { "bio": "Bio", "connecting": "Connecting", diff --git a/packages/plugin-staking-api/package.json b/packages/plugin-staking-api/package.json index 913cb5ee61..190b0bad2e 100644 --- a/packages/plugin-staking-api/package.json +++ b/packages/plugin-staking-api/package.json @@ -8,6 +8,10 @@ "clear": "rm -rf build tsconfig.tsbuildinfo dist", "reset": "yarn run clear && rm -rf node_modules yarn.lock && yarn" }, + "exports": { + ".": "./src/index.tsx", + "./types": "./src/types.ts" + }, "dependencies": { "@apollo/client": "^3.11.10", "graphql": "^16.9.0" diff --git a/packages/plugin-staking-api/src/index.tsx b/packages/plugin-staking-api/src/index.tsx index f1e220ec69..12eff909c0 100644 --- a/packages/plugin-staking-api/src/index.tsx +++ b/packages/plugin-staking-api/src/index.tsx @@ -4,6 +4,8 @@ import { ApolloProvider } from '@apollo/client' export * from './Client' +export * from './queries/useRewards' export * from './queries/useTokenPrice' +export * from './queries/useUnclaimedRewards' export { ApolloProvider } diff --git a/packages/plugin-staking-api/src/queries/useRewards.tsx b/packages/plugin-staking-api/src/queries/useRewards.tsx new file mode 100644 index 0000000000..3a5525d0db --- /dev/null +++ b/packages/plugin-staking-api/src/queries/useRewards.tsx @@ -0,0 +1,33 @@ +// Copyright 2024 @polkadot-cloud/polkadot-staking-dashboard authors & contributors +// SPDX-License-Identifier: GPL-3.0-only + +import { gql, useQuery } from '@apollo/client' +import type { AllRewardsResult } from '../types' + +const QUERY = gql` + query AllRewards($chain: String!, $who: String!, $fromEra: Int!) { + allRewards(chain: $chain, who: $who, fromEra: $fromEra) { + claimed + era + reward + timestamp + validator + type + } + } +` + +export const useRewards = ({ + chain, + who, + fromEra, +}: { + chain: string + who: string + fromEra: number +}): AllRewardsResult => { + const { loading, error, data, refetch } = useQuery(QUERY, { + variables: { chain, who, fromEra }, + }) + return { loading, error, data, refetch } +} diff --git a/packages/plugin-staking-api/src/queries/useTokenPrice.tsx b/packages/plugin-staking-api/src/queries/useTokenPrice.tsx index 55d3ace3d6..a43b8d5cc1 100644 --- a/packages/plugin-staking-api/src/queries/useTokenPrice.tsx +++ b/packages/plugin-staking-api/src/queries/useTokenPrice.tsx @@ -5,7 +5,7 @@ import type { ApolloError } from '@apollo/client' import { gql, useQuery } from '@apollo/client' import type { TokenPriceResult, UseTokenPriceResult } from '../types' -const TOKEN_PRICE_QUERY = gql` +const QUERY = gql` query TokenPrice($ticker: String!) { tokenPrice(ticker: $ticker) { price @@ -19,7 +19,7 @@ export const useTokenPrice = ({ }: { ticker: string }): UseTokenPriceResult => { - const { loading, error, data, refetch } = useQuery(TOKEN_PRICE_QUERY, { + const { loading, error, data, refetch } = useQuery(QUERY, { variables: { ticker }, }) return { loading, error, data, refetch } diff --git a/packages/plugin-staking-api/src/queries/useUnclaimedRewards.tsx b/packages/plugin-staking-api/src/queries/useUnclaimedRewards.tsx new file mode 100644 index 0000000000..acc99e4a87 --- /dev/null +++ b/packages/plugin-staking-api/src/queries/useUnclaimedRewards.tsx @@ -0,0 +1,37 @@ +// Copyright 2024 @polkadot-cloud/polkadot-staking-dashboard authors & contributors +// SPDX-License-Identifier: GPL-3.0-only + +import { gql, useQuery } from '@apollo/client' +import type { UnclaimedRewardsResult } from '../types' + +const QUERY = gql` + query UnclaimedRewards($chain: String!, $who: String!, $fromEra: Int!) { + unclaimedRewards(chain: $chain, who: $who, fromEra: $fromEra) { + total + entries { + era + reward + validators { + page + reward + validator + } + } + } + } +` + +export const useUnclaimedRewards = ({ + chain, + who, + fromEra, +}: { + chain: string + who: string + fromEra: number +}): UnclaimedRewardsResult => { + const { loading, error, data, refetch } = useQuery(QUERY, { + variables: { chain, who, fromEra }, + }) + return { loading, error, data, refetch } +} diff --git a/packages/plugin-staking-api/src/types.ts b/packages/plugin-staking-api/src/types.ts index b2ad34a2e6..83e2fc924b 100644 --- a/packages/plugin-staking-api/src/types.ts +++ b/packages/plugin-staking-api/src/types.ts @@ -16,11 +16,51 @@ export type TokenPriceResult = { tokenPrice: TokenPrice } | null -export interface UseTokenPriceResult { +interface Query { loading: boolean error: ApolloError | undefined - data: TokenPriceResult refetch: ( variables?: Partial | undefined ) => Promise> } + +export type UseTokenPriceResult = Query & { + data: TokenPriceResult +} + +export type AllRewardsResult = Query & { + data: { + allRewards: NominatorReward[] + } +} + +export interface NominatorReward { + era: number + reward: number + claimed: boolean + timestamp: number + validator: string + type: string +} + +export type UnclaimedRewardsResult = Query & { + data: { + unclaimedRewards: UnclaimedRewards + } +} + +export interface UnclaimedRewards { + total: string + entries: EraUnclaimedReward[] +} +export interface EraUnclaimedReward { + era: number + reward: string + validators: ValidatorUnclaimedReward[] +} + +export interface ValidatorUnclaimedReward { + validator: string + reward: string + page: number | null +}