diff --git a/packages/app-staking/src/Query/Validator.tsx b/packages/app-staking/src/Query/Validator.tsx index a8147d4563..8cdd80afdb 100644 --- a/packages/app-staking/src/Query/Validator.tsx +++ b/packages/app-staking/src/Query/Validator.tsx @@ -4,6 +4,7 @@ import { I18nProps } from '@polkadot/react-components/types'; import { Balance, BlockNumber, Hash, Exposure, SessionIndex } from '@polkadot/types/interfaces'; +import { SessionRewards } from '@polkadot/react-hooks/types'; import BN from 'bn.js'; import React, { useContext, useEffect, useState } from 'react'; @@ -19,6 +20,7 @@ interface Props extends I18nProps { blockCounts?: BN[]; className?: string; currentIndex: SessionIndex; + stakingRewards: SessionRewards[]; startNumber: BlockNumber; validatorId: string; } @@ -87,16 +89,17 @@ function extractSplit (values: [BN, Hash, Exposure][], validatorId: string): Spl })); } -function Validator ({ blockCounts, className, currentIndex, startNumber, t, validatorId }: Props): React.ReactElement { +function Validator ({ blockCounts, className, currentIndex, stakingRewards, startNumber, t, validatorId }: Props): React.ReactElement { const { api } = useContext(ApiContext); const [blocksLabels, setBlocksLabels] = useState([]); const [blocksChart, setBlocksChart] = useState(null); + const [{ rewardsChart, rewardsLabels }, setRewardsInfo] = useState<{ rewardsChart: LineData | null; rewardsLabels: string[] }>({ rewardsChart: null, rewardsLabels: [] }); const [splitChart, setSplitInfo] = useState(null); const [{ stakeChart, stakeLabels }, setStakeInfo] = useState<{ stakeChart: LineData | null; stakeLabels: string[]}>({ stakeChart: null, stakeLabels: [] }); + const divisor = new BN('1'.padEnd(formatBalance.getDefaults().decimals + 1, '0')); useEffect((): void => { api.isReady.then(async (): Promise => { - const divisor = new BN('1'.padEnd(formatBalance.getDefaults().decimals + 1, '0')); const values = await getHistoric(api, 'staking.stakers', [validatorId], { interval: (api.consts.babe?.epochDuration as BlockNumber || new BN(500)).muln(2).divn(3), max: SESSIONS, @@ -110,6 +113,24 @@ function Validator ({ blockCounts, className, currentIndex, startNumber, t, vali }); }, []); + useEffect((): void => { + const rewardsLabels: string[] = []; + const rewardsChart: LineData = [[]]; + + stakingRewards.forEach(({ sessionIndex, slashes }): void => { + rewardsLabels.push(formatNumber(sessionIndex)); + rewardsChart[0].push( + slashes.reduce((total: BN, { accountId, amount }): BN => { + return accountId.eq(validatorId) + ? total.sub(amount) + : total; + }, new BN(0)).muln(1000).div(divisor).toNumber() / 1000 + ); + }); + + setRewardsInfo({ rewardsChart, rewardsLabels }); + }, [stakingRewards]); + useEffect((): void => { setBlocksLabels( getIndexRange(currentIndex).map((index): string => formatNumber(index)) @@ -137,16 +158,31 @@ function Validator ({ blockCounts, className, currentIndex, startNumber, t, vali return ( - {blocksChart && ( -
-

{t('blocks per session')}

- -
+ {(rewardsChart || blocksChart) && ( + <> + {blocksChart && ( +
+

{t('blocks per session')}

+ +
+ )} + {rewardsChart && ( +
+

{t('slashed per session')}

+ +
+ )} + )}
diff --git a/packages/app-staking/src/Query/index.tsx b/packages/app-staking/src/Query/index.tsx index c3ff9f5c8d..743823a962 100644 --- a/packages/app-staking/src/Query/index.tsx +++ b/packages/app-staking/src/Query/index.tsx @@ -47,7 +47,7 @@ interface Props extends I18nProps, ComponentProps, RouteComponentProps<{}> { // /> // -function Query ({ bestNumber, className, stakingOverview, match: { params: { value } }, t }: Props): React.ReactElement { +function Query ({ bestNumber, className, stakingRewards, stakingOverview, match: { params: { value } }, t }: Props): React.ReactElement { const [startNumber, setStartNumber] = useState(); const [validatorId, setValidatorId] = useState(value || null); @@ -82,6 +82,7 @@ function Query ({ bestNumber, className, stakingOverview, match: { params: { val {value && startNumber && stakingOverview && ( diff --git a/packages/app-staking/src/index.tsx b/packages/app-staking/src/index.tsx index 83c8a34800..d85057f203 100644 --- a/packages/app-staking/src/index.tsx +++ b/packages/app-staking/src/index.tsx @@ -17,6 +17,7 @@ import { Option } from '@polkadot/types'; import { HelpOverlay } from '@polkadot/react-components'; import Tabs from '@polkadot/react-components/Tabs'; import { ApiContext, withCalls, withMulti, withObservable } from '@polkadot/react-api'; +import { useSessionSlashes } from '@polkadot/react-hooks'; import accountObservable from '@polkadot/ui-keyring/observable/accounts'; import Accounts from './Actions/Accounts'; @@ -38,6 +39,7 @@ const EMPTY_ALL: [string[], string[]] = [EMPY_ACCOUNTS, EMPY_ACCOUNTS]; function App ({ allAccounts, allStashesAndControllers: [allStashes, allControllers] = EMPTY_ALL, basePath, bestNumber, className, recentlyOnline, stakingOverview, t }: Props): React.ReactElement { const { api } = useContext(ApiContext); + const stakingRewards = useSessionSlashes(); const routeMatch = useRouteMatch({ path: basePath, strict: true }); const _renderComponent = (Component: React.ComponentType, className?: string): () => React.ReactNode => { // eslint-disable-next-line react/display-name @@ -54,6 +56,7 @@ function App ({ allAccounts, allStashesAndControllers: [allStashes, allControlle bestNumber={bestNumber} className={className} recentlyOnline={recentlyOnline} + stakingRewards={stakingRewards} stakingOverview={stakingOverview} /> ); diff --git a/packages/app-staking/src/types.ts b/packages/app-staking/src/types.ts index b088c143ab..a8c1f15812 100644 --- a/packages/app-staking/src/types.ts +++ b/packages/app-staking/src/types.ts @@ -4,6 +4,7 @@ import { DerivedFees, DerivedBalances, DerivedHeartbeats, DerivedStakingOverview } from '@polkadot/api-derive/types'; import { BlockNumber } from '@polkadot/types/interfaces'; +import { SessionRewards } from '@polkadot/react-hooks/types'; import { SubjectInfo } from '@polkadot/ui-keyring/observable/types'; export type Nominators = Record; @@ -15,6 +16,7 @@ export interface ComponentProps { bestNumber?: BlockNumber; className?: string; recentlyOnline?: DerivedHeartbeats; + stakingRewards: SessionRewards[]; stakingOverview?: DerivedStakingOverview; } diff --git a/packages/react-hooks/src/index.ts b/packages/react-hooks/src/index.ts index 40a542273c..e42b45bc35 100644 --- a/packages/react-hooks/src/index.ts +++ b/packages/react-hooks/src/index.ts @@ -4,3 +4,4 @@ export { default as useDebounce } from './debounce'; export { default as useFavorites } from './favorites'; +export { default as useSessionSlashes } from './sessionSlashes'; diff --git a/packages/react-hooks/src/sessionSlashes.tsx b/packages/react-hooks/src/sessionSlashes.tsx new file mode 100644 index 0000000000..0bc33cb8a6 --- /dev/null +++ b/packages/react-hooks/src/sessionSlashes.tsx @@ -0,0 +1,163 @@ +// Copyright 2017-2019 @polkadot/react-components authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { Balance, BlockNumber, EventRecord, Hash, Header } from '@polkadot/types/interfaces'; +import { Slash, SessionRewards } from './types'; + +import BN from 'bn.js'; +import { useContext, useEffect, useState } from 'react'; +import store from 'store'; +import { ApiPromise } from '@polkadot/api'; +import { ApiContext } from '@polkadot/react-api'; +import { createType } from '@polkadot/types'; +import { bnMax, u8aToU8a } from '@polkadot/util'; + +interface SlashSer { + accountId: string; + amount: string; +} + +interface SessionResultSer { + blockHash: string; + blockNumber: string; + isEventsEmpty: boolean; + reward: string; + sessionIndex: string; + slashes: SlashSer[]; +} + +// assuming 4 hrs sessions, we grab results for 10 days (+2 for at-start throw-away) +const MAX_SESSIONS = 10 * (24 / 4) + 2; + +function getStorage (storageKey: string): SessionRewards[] { + const sessions: SessionResultSer[] = store.get(storageKey, []); + + return sessions.map(({ blockHash, blockNumber, isEventsEmpty, reward, sessionIndex, slashes }): SessionRewards => ({ + blockHash: createType('Hash', blockHash), + blockNumber: createType('BlockNumber', blockNumber), + isEventsEmpty, + reward: createType('Balance', reward), + sessionIndex: createType('SessionIndex', sessionIndex), + slashes: slashes.map(({ accountId, amount }): Slash => ({ + accountId: createType('AccountId', accountId), + amount: createType('Balance', amount) + })) + })); +} + +function setStorage (storageKey: string, sessions: SessionRewards[]): SessionResultSer[] { + return store.set( + storageKey, + sessions + .map(({ blockHash, blockNumber, isEventsEmpty, reward, sessionIndex, slashes }): SessionResultSer => ({ + blockHash: blockHash.toHex(), + blockNumber: blockNumber.toHex(), + isEventsEmpty, + reward: reward.toHex(), + sessionIndex: sessionIndex.toHex(), + slashes: slashes.map(({ accountId, amount }): SlashSer => ({ + accountId: accountId.toString(), + amount: amount.toHex() + })) + })) + .slice(0, MAX_SESSIONS + 1) + ); +} + +function mergeResults (sessions: SessionRewards[], newSessions: SessionRewards[]): SessionRewards[] { + const tmp = sessions + .concat(newSessions) + .sort((a, b): number => a.blockNumber.cmp(b.blockNumber)); + + return tmp.filter(({ sessionIndex }, index): boolean => + index === 0 + // for the first, always use it + ? true + // if the prev has the same sessionIndex, ignore this one + : !tmp[index - 1].sessionIndex.eq(sessionIndex) + ); +} + +async function loadSome (api: ApiPromise, fromHash: Hash, toHash: Hash): Promise { + const results = await api.rpc.state.queryStorage([api.query.session.currentIndex.key()], fromHash, toHash); + const headers = await Promise.all( + results.map(({ block }): Promise
=> api.rpc.chain.getHeader(block)) + ); + const events: EventRecord[][] = await Promise.all( + results.map(({ block }): Promise => + (api.query.system.events.at(block) as unknown as Promise) + .then((records): EventRecord[] => + records.filter(({ event: { section } }): boolean => section === 'staking') + ) + .catch((): EventRecord[] => []) // undecodable may throw + ) + ); + const slashes: Slash[][] = events.map((info): Slash[] => + info + .filter(({ event: { method } }): boolean => method === 'Slash') + .map(({ event: { data: [accountId, amount] } }): Slash => ({ + accountId: accountId as any, + amount: amount as any + })) + ); + const rewards: (Balance | undefined)[] = events.map((info): Balance | undefined => { + const rewards = info.filter(({ event: { method } }): boolean => method === 'Reward'); + + return rewards[0]?.event?.data[0] as Balance; + }); + + return results.map(({ changes: [[, value]] }, index): SessionRewards => ({ + blockHash: headers[index].hash, + blockNumber: headers[index].number.unwrap(), + isEventsEmpty: events[index].length === 0, + reward: rewards[index] || createType('Balance'), + sessionIndex: createType('SessionIndex', u8aToU8a(value.unwrap())), + slashes: slashes[index] + })); +} + +export default function useSessionSlashes (maxSessions = MAX_SESSIONS): SessionRewards[] { + const { api } = useContext(ApiContext); + const STORAGE_KEY = `hooks:sessionSlashes:${api.genesisHash}`; + const [results, setResults] = useState(getStorage(STORAGE_KEY)); + const [filtered, setFiltered] = useState([]); + + useEffect((): void => { + let workQueue = results; + + api.isReady.then(async (): Promise => { + const sessionLength = (api.consts.babe?.epochDuration as BlockNumber || new BN(500)); + const count = sessionLength.muln(maxSessions).divn(10).toNumber(); + const bestHeader = await api.rpc.chain.getHeader(); + let toHash = bestHeader.hash; + let toNumber = bestHeader.number.unwrap().toBn(); + let fromHash = api.genesisHash; + let fromNumber = bnMax(toNumber.subn(count), new BN(1)); + + while (true) { + fromHash = await api.rpc.chain.getBlockHash(fromNumber as any); + + const newQueue = await loadSome(api, fromHash, toHash); + + workQueue = mergeResults(workQueue, newQueue); + toHash = fromHash; + toNumber = fromNumber; + fromNumber = bnMax(toNumber.subn(count), new BN(1)); + + setStorage(STORAGE_KEY, workQueue); + setResults(workQueue); + + if (fromNumber.eqn(1) || (workQueue.length > maxSessions)) { + break; + } + } + }); + }, []); + + useEffect((): void => { + setFiltered(results.filter(({ isEventsEmpty }): boolean => !isEventsEmpty)); + }, [results]); + + return filtered; +} diff --git a/packages/react-hooks/src/types.ts b/packages/react-hooks/src/types.ts new file mode 100644 index 0000000000..d210e98fca --- /dev/null +++ b/packages/react-hooks/src/types.ts @@ -0,0 +1,19 @@ +// Copyright 2017-2019 @polkadot/react-components authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { AccountId, Balance, BlockNumber, Hash, SessionIndex } from '@polkadot/types/interfaces'; + +export interface Slash { + accountId: AccountId; + amount: Balance; +} + +export interface SessionRewards { + blockHash: Hash; + blockNumber: BlockNumber; + isEventsEmpty: boolean; + reward: Balance; + sessionIndex: SessionIndex; + slashes: Slash[]; +}