Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 48 additions & 12 deletions packages/app-staking/src/Query/Validator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -19,6 +20,7 @@ interface Props extends I18nProps {
blockCounts?: BN[];
className?: string;
currentIndex: SessionIndex;
stakingRewards: SessionRewards[];
startNumber: BlockNumber;
validatorId: string;
}
Expand Down Expand Up @@ -87,16 +89,17 @@ function extractSplit (values: [BN, Hash, Exposure][], validatorId: string): Spl
}));
}

function Validator ({ blockCounts, className, currentIndex, startNumber, t, validatorId }: Props): React.ReactElement<Props> {
function Validator ({ blockCounts, className, currentIndex, stakingRewards, startNumber, t, validatorId }: Props): React.ReactElement<Props> {
const { api } = useContext(ApiContext);
const [blocksLabels, setBlocksLabels] = useState<string[]>([]);
const [blocksChart, setBlocksChart] = useState<LineData | null>(null);
const [{ rewardsChart, rewardsLabels }, setRewardsInfo] = useState<{ rewardsChart: LineData | null; rewardsLabels: string[] }>({ rewardsChart: null, rewardsLabels: [] });
const [splitChart, setSplitInfo] = useState<SplitData | null>(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<void> => {
const divisor = new BN('1'.padEnd(formatBalance.getDefaults().decimals + 1, '0'));
const values = await getHistoric<Exposure>(api, 'staking.stakers', [validatorId], {
interval: (api.consts.babe?.epochDuration as BlockNumber || new BN(500)).muln(2).divn(3),
max: SESSIONS,
Expand All @@ -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))
Expand Down Expand Up @@ -137,16 +158,31 @@ function Validator ({ blockCounts, className, currentIndex, startNumber, t, vali
return (
<Columar className={className}>
<Column emptyText={t('Loading block data')}>
{blocksChart && (
<div className='staking--Chart'>
<h1>{t('blocks per session')}</h1>
<Chart.Line
colors={COLORS_BLOCKS}
labels={blocksLabels}
legends={[t('blocks'), t('average')]}
values={blocksChart}
/>
</div>
{(rewardsChart || blocksChart) && (
<>
{blocksChart && (
<div className='staking--Chart'>
<h1>{t('blocks per session')}</h1>
<Chart.Line
colors={COLORS_BLOCKS}
labels={blocksLabels}
legends={[t('blocks'), t('average')]}
values={blocksChart}
/>
</div>
)}
{rewardsChart && (
<div className='staking--Chart'>
<h1>{t('slashed per session')}</h1>
<Chart.Line
colors={COLORS_BLOCKS}
labels={rewardsLabels}
legends={[t('slashed'), t('rewarded')]}
values={rewardsChart}
/>
</div>
)}
</>
)}
</Column>
<Column emptyText={t('Loading staker data')}>
Expand Down
3 changes: 2 additions & 1 deletion packages/app-staking/src/Query/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ interface Props extends I18nProps, ComponentProps, RouteComponentProps<{}> {
// />
// </Input>

function Query ({ bestNumber, className, stakingOverview, match: { params: { value } }, t }: Props): React.ReactElement<Props> {
function Query ({ bestNumber, className, stakingRewards, stakingOverview, match: { params: { value } }, t }: Props): React.ReactElement<Props> {
const [startNumber, setStartNumber] = useState<BlockNumber | undefined>();
const [validatorId, setValidatorId] = useState<string | null>(value || null);

Expand Down Expand Up @@ -82,6 +82,7 @@ function Query ({ bestNumber, className, stakingOverview, match: { params: { val
{value && startNumber && stakingOverview && (
<Validator
currentIndex={stakingOverview.currentIndex}
stakingRewards={stakingRewards}
startNumber={startNumber}
validatorId={value}
/>
Expand Down
3 changes: 3 additions & 0 deletions packages/app-staking/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<Props> {
const { api } = useContext(ApiContext);
const stakingRewards = useSessionSlashes();
const routeMatch = useRouteMatch({ path: basePath, strict: true });
const _renderComponent = (Component: React.ComponentType<ComponentProps>, className?: string): () => React.ReactNode => {
// eslint-disable-next-line react/display-name
Expand All @@ -54,6 +56,7 @@ function App ({ allAccounts, allStashesAndControllers: [allStashes, allControlle
bestNumber={bestNumber}
className={className}
recentlyOnline={recentlyOnline}
stakingRewards={stakingRewards}
stakingOverview={stakingOverview}
/>
);
Expand Down
2 changes: 2 additions & 0 deletions packages/app-staking/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string[]>;
Expand All @@ -15,6 +16,7 @@ export interface ComponentProps {
bestNumber?: BlockNumber;
className?: string;
recentlyOnline?: DerivedHeartbeats;
stakingRewards: SessionRewards[];
stakingOverview?: DerivedStakingOverview;
}

Expand Down
1 change: 1 addition & 0 deletions packages/react-hooks/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@

export { default as useDebounce } from './debounce';
export { default as useFavorites } from './favorites';
export { default as useSessionSlashes } from './sessionSlashes';
163 changes: 163 additions & 0 deletions packages/react-hooks/src/sessionSlashes.tsx
Original file line number Diff line number Diff line change
@@ -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<SessionRewards[]> {
const results = await api.rpc.state.queryStorage([api.query.session.currentIndex.key()], fromHash, toHash);
const headers = await Promise.all(
results.map(({ block }): Promise<Header> => api.rpc.chain.getHeader(block))
);
const events: EventRecord[][] = await Promise.all(
results.map(({ block }): Promise<EventRecord[]> =>
(api.query.system.events.at(block) as unknown as Promise<EventRecord[]>)
.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<SessionRewards[]>(getStorage(STORAGE_KEY));
const [filtered, setFiltered] = useState<SessionRewards[]>([]);

useEffect((): void => {
let workQueue = results;

api.isReady.then(async (): Promise<void> => {
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;
}
19 changes: 19 additions & 0 deletions packages/react-hooks/src/types.ts
Original file line number Diff line number Diff line change
@@ -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[];
}