ghost-portal/packages/app-staking/src/Query/Validator.tsx
2019-11-30 13:11:37 +01:00

264 lines
9.4 KiB
TypeScript

// Copyright 2017-2019 @polkadot/app-staking 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 { I18nProps } from '@polkadot/react-components/types';
import { Balance, Hash, Exposure } from '@polkadot/types/interfaces';
import { SessionRewards, Slash } from '../types';
import BN from 'bn.js';
import React, { useEffect, useState } from 'react';
import { Chart, Columar, Column } from '@polkadot/react-components';
import { toShortAddress } from '@polkadot/react-components/util';
import { getHistoric } from '@polkadot/react-api/util';
import { useApi } from '@polkadot/react-hooks';
import { formatBalance, formatNumber } from '@polkadot/util';
import translate from '../translate';
import useBlockCounts from '../useBlockCounts';
interface Props extends I18nProps {
className?: string;
sessionRewards: SessionRewards[];
validatorId: string;
}
type LineDataEntry = (BN | number)[];
type LineData = LineDataEntry[];
interface SplitEntry {
colors: string[];
label: string;
value: number;
}
type SplitData = SplitEntry[];
const COLORS_MINE = ['#ff8c00'];
const COLORS_OTHER = ['#acacac'];
const COLORS_REWARD = ['#8c2200', '#008c22', '#acacac'];
const COLORS_BLOCKS = [undefined, '#acacac'];
function balanceToNumber (amount: BN, divisor: BN): number {
return amount.muln(1000).div(divisor).toNumber() / 1000;
}
function extractStake (values: [Hash, Exposure][], divisor: BN): LineData {
return [
values.map(([, { total }]): number =>
balanceToNumber(total.unwrap(), divisor))
// exposures.map(({ own }): number =>
// balanceToNumber(own.unwrap(), divisor)),
// exposures.map(({ others }): number =>
// balanceToNumber(
// others.reduce((total, { value }): number =>
// total.add(value.unwrap()), new BN(0)
// ), divisor))
];
}
function extractSplit (values: [Hash, Exposure][], validatorId: string): SplitData | null {
if (!values.length) {
return null;
}
const last = values[values.length - 1][1];
const total = last.total.unwrap();
if (total.eqn(0)) {
return null;
}
return [{ accountId: validatorId, isOwn: true, value: last.own.unwrap() }]
.concat(last.others.map(({ who, value }): { accountId: string; isOwn: boolean; value: Balance } => ({
accountId: who.toString(), isOwn: false, value: value.unwrap()
})))
.sort((a, b): number => b.value.cmp(a.value))
.map(({ accountId, isOwn, value }): SplitEntry => ({
colors: isOwn ? COLORS_MINE : COLORS_OTHER,
label: toShortAddress(accountId),
value: value.muln(10000).div(total).toNumber() / 100
}));
}
function extractEraSlash (validatorId: string, slashes: Slash[]): BN {
return slashes.reduce((total: BN, { accountId, amount }): BN => {
return accountId.eq(validatorId)
? total.sub(amount)
: total;
}, new BN(0));
}
function Validator ({ className, sessionRewards, t, validatorId }: Props): React.ReactElement<Props> {
const { api } = useApi();
const blockCounts = useBlockCounts(validatorId, sessionRewards);
const [blocksLabels, setBlocksLabels] = useState<string[]>([]);
const [blocksChart, setBlocksChart] = useState<LineData | null>(null);
const [{ currency, divisor }] = useState<{ currency: string; divisor: BN }>({
currency: formatBalance.getDefaults().unit,
divisor: new BN('1'.padEnd(formatBalance.getDefaults().decimals + 1, '0'))
});
const [{ rewardsChart, rewardsLabels }, setRewardsInfo] = useState<{ rewardsChart: LineData | null; rewardsLabels: string[] }>({ rewardsChart: null, rewardsLabels: [] });
const [{ splitChart, splitMax }, setSplitInfo] = useState<{ splitChart: SplitData | null; splitMax: number }>({ splitChart: null, splitMax: 100 });
const [{ stakeChart, stakeLabels }, setStakeInfo] = useState<{ stakeChart: LineData | null; stakeLabels: string[]}>({ stakeChart: null, stakeLabels: [] });
useEffect((): void => {
if (!splitChart) {
const hashes = sessionRewards.map(({ blockHash }): Hash => blockHash);
const stakeLabels = sessionRewards.map(({ sessionIndex }): string => formatNumber(sessionIndex));
api.isReady.then(async (): Promise<void> => {
const values = await getHistoric<Exposure>(api.query.staking.stakers.at, [validatorId], hashes);
const stakeChart = extractStake(values, divisor);
const splitChart = extractSplit(values, validatorId);
const splitMax = splitChart ? Math.min(Math.ceil(splitChart[0].value), 100) : 100;
setStakeInfo({ stakeChart, stakeLabels });
setSplitInfo({ splitChart, splitMax });
});
}
}, [sessionRewards, splitChart]);
useEffect((): void => {
if (blockCounts) {
const rewardsLabels: string[] = [];
const rewardsChart: LineData = [[], [], []];
let total = new BN(0);
let lastRewardIndex = 0;
let rewardCount = 0;
// we only work from the second position, the first deemed incomplete
sessionRewards.forEach(({ blockNumber, reward, sessionIndex, slashes }, index): void => {
// we are trying to find the first index where rewards are allocated, this is our start
// since we only want complete eras, this means we drop some at the start
if (lastRewardIndex === 0) {
if (index && reward.gtn(0)) {
lastRewardIndex = index;
}
return;
}
// slash is extracted from the available slashes
const neg = extractEraSlash(validatorId, slashes);
// start of a new session, use the counts for the previous
const totalBlocks = blockCounts
.filter((count, countIndex): boolean =>
!!count && countIndex >= lastRewardIndex && countIndex < index)
.reduce((total, count): BN => total.add(count), new BN(0));
// calculate the rewards based on our total share
const pos = reward
.mul(totalBlocks)
.div(blockNumber.sub(sessionRewards[lastRewardIndex].blockNumber));
// add this to the total
total = total.add(neg).add(pos);
rewardCount++;
// if we have a reward here, set the reward index for the next iteration
if (reward.gtn(0)) {
lastRewardIndex = index;
}
// this shows the start of the new era, however rewards are for previous
rewardsLabels.push(formatNumber(sessionIndex.subn(1)));
// calculate and format to 3 decimals
rewardsChart[0].push(balanceToNumber(neg, divisor));
rewardsChart[1].push(balanceToNumber(pos, divisor));
rewardsChart[2].push(balanceToNumber(total.divn(rewardCount), divisor));
});
setRewardsInfo({ rewardsChart, rewardsLabels });
}
}, [blockCounts, sessionRewards, validatorId]);
useEffect((): void => {
setBlocksLabels(
sessionRewards.map(({ sessionIndex }): string => formatNumber(sessionIndex))
);
}, [sessionRewards]);
useEffect((): void => {
if (blockCounts) {
const avgSet: number[] = [];
const idxSet: BN[] = [];
blockCounts.reduce((total: BN, value, index): BN => {
total = total.add(value);
avgSet.push(total.muln(100).divn(index + 1).toNumber() / 100);
idxSet.push(value);
return total;
}, new BN(0));
setBlocksChart([idxSet, avgSet]);
}
}, [blockCounts]);
return (
<Columar className={className}>
<Column emptyText={t('Loading block data')}>
{(rewardsChart || blocksChart) && (
<>
{blocksChart && (
<div className='staking--Chart'>
<h1>{t('blocks produced')}</h1>
<Chart.Line
colors={COLORS_BLOCKS}
labels={blocksLabels}
legends={[t('blocks'), t('average')]}
values={blocksChart}
/>
</div>
)}
{rewardsChart && (
<div className='staking--Chart'>
<h1>{t('rewards & slashes')}</h1>
<Chart.Line
colors={COLORS_REWARD}
labels={rewardsLabels}
legends={[t('{{currency}} slashed', { replace: { currency } }), t('{{currency}} rewards (est.)', { replace: { currency } }), t('{{currency}} average', { replace: { currency } })]}
values={rewardsChart}
/>
</div>
)}
</>
)}
</Column>
<Column emptyText={t('Loading staker data')}>
{(stakeChart || splitChart) && (
<>
{stakeChart && stakeChart[0].length !== 0 && (
<div className='staking--Chart'>
<h1>{t('elected stake')}</h1>
<Chart.Line
labels={stakeLabels}
legends={[t('{{currency}} total', { replace: { currency } }), t('{{currency}} own', { replace: { currency } }), t('{{currency}} other', { replace: { currency } })]}
values={stakeChart}
/>
</div>
)}
{splitChart && (
<div className='staking--Chart'>
<h1>{t('staker percentages')}</h1>
<Chart.HorizBar
aspectRatio={2}
max={splitMax}
values={splitChart}
/>
</div>
)}
</>
)}
</Column>
</Columar>
);
}
export default translate(Validator);