Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: show validators APY #1633

Merged
merged 10 commits into from
Nov 10, 2024
7 changes: 4 additions & 3 deletions packages/extension-polkagate/src/components/ShortAddress.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
// Copyright 2019-2024 @polkadot/extension-polkagate authors & contributors
// SPDX-License-Identifier: Apache-2.0

// @ts-nocheck

/* eslint-disable react/jsx-max-props-per-line */

import type { AccountId } from '@polkadot/types/interfaces/runtime';

import { Grid, type SxProps, type Theme } from '@mui/material';
import React, { useCallback, useEffect, useRef, useState } from 'react';

import type { AccountId } from '@polkadot/types/interfaces/runtime';

import { SHORT_ADDRESS_CHARACTERS } from '../util/constants';
import ObserveResize from '../util/ObserveResize';
import CopyAddressButton from './CopyAddressButton';
Expand All @@ -22,7 +23,7 @@ interface Props {
clipped?: boolean;
}

function ShortAddress({ address, clipped = false, charsCount = SHORT_ADDRESS_CHARACTERS, style, showCopy = false, inParentheses = false }: Props): React.ReactElement {
function ShortAddress ({ address, charsCount = SHORT_ADDRESS_CHARACTERS, clipped = false, inParentheses = false, showCopy = false, style }: Props): React.ReactElement {
const [charactersCount, setCharactersCount] = useState<number>(1);
const pRef = useRef(null);
const cRef = useRef(null);
Expand Down
5 changes: 2 additions & 3 deletions packages/extension-polkagate/src/components/ShowValue.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
// Copyright 2019-2024 @polkadot/extension-polkagate authors & contributors
// SPDX-License-Identifier: Apache-2.0
// @ts-nocheck

/**
/**
* @description this component is used to show an account balance in some pages like contributeToCrowdloan
* */
import type { BN } from '@polkadot/util';
Expand All @@ -17,7 +16,7 @@ export interface Props {
width?: string;
}

export default function ShowValue({ height = 20, unit, value, width = '90px' }: Props): React.ReactElement<Props> {
export default function ShowValue ({ height = 20, unit, value, width = '90px' }: Props): React.ReactElement<Props> {
return (
<>
{value !== undefined
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ import React from 'react';

import { BN_ZERO } from '@polkadot/util';

import { Checkbox2, Identity, Infotip, ShowBalance } from '../../../../components';
import { useTranslation } from '../../../../hooks';
import { Checkbox2, Identity, Infotip, ShowBalance, ShowValue } from '../../../../components';
import { useTranslation, useValidatorApy } from '../../../../hooks';
import { isHexToBn } from '../../../../util/utils';

interface Props {
Expand All @@ -37,20 +37,23 @@ interface Props {
allInOneRow?: boolean
}

const Div = () => (
<Grid alignItems='center' item justifyContent='center'>
<Divider orientation='vertical' sx={{ bgcolor: 'divider', height: '15px', m: '3px 10px', width: '1px' }} />
</Grid>
);

function ShowValidator ({ accountInfo, allInOneRow = true, api, chain, check, decimal, handleCheck, isActive, isOversubscribed, showCheckbox, stakingConsts, token, v }: Props): React.ReactElement {
const { t } = useTranslation();

const isElected = isHexToBn(v.exposure.total.toString()).gt(BN_ZERO);
const apy = useValidatorApy(api, String(v.accountId), isElected);

const overSubscriptionAlert1 = t('This validator is oversubscribed but you are within the top {{max}}.', { replace: { max: stakingConsts?.maxNominatorRewardedPerValidator } });
const overSubscriptionAlert2 = t('This validator is oversubscribed and you are not within the top {{max}} and won\'t get rewards.', { replace: { max: stakingConsts?.maxNominatorRewardedPerValidator } });

const ifOverSubscribed = isOversubscribed?.safe || isOversubscribed?.notSafe;

const Div = () => (
<Grid alignItems='center' item justifyContent='center'>
<Divider orientation='vertical' sx={{ bgcolor: 'secondary.light', height: '15px', m: '3px 10px', width: '1px' }} />
</Grid>
);

return (
<Grid alignItems='center' container item p='3px 5px' rowGap={!allInOneRow ? '5px' : undefined} sx={{ borderRight: allInOneRow ? '1px solid' : undefined, borderRightColor: allInOneRow ? 'secondary.main' : undefined }} width={allInOneRow ? '94%' : '100%'}>
{showCheckbox &&
Expand Down Expand Up @@ -79,7 +82,7 @@ function ShowValidator ({ accountInfo, allInOneRow = true, api, chain, check, de
{t('Staked')}:
</Grid>
<Grid fontSize='14px' fontWeight={400} item pl='3px'>
{isHexToBn(v.exposure.total.toString()).gt(BN_ZERO)
{isElected
? <ShowBalance
api={api}
balance={v.exposure.total}
Expand Down Expand Up @@ -111,6 +114,17 @@ function ShowValidator ({ accountInfo, allInOneRow = true, api, chain, check, de
{v.exposure.others?.length || t('N/A')}
</Grid>
</Grid>
{allInOneRow && <Div />}
<Grid alignItems='end' container item justifyContent={allInOneRow ? 'center' : 'space-between'} sx={{ fontSize: '14px', fontWeight: 300, lineHeight: '23px' }} width={allInOneRow ? 'fit-content' : ifOverSubscribed ? '93%' : '100%'}>
<Grid item>
{t('APY')}:
</Grid>
<Grid fontSize='14px' fontWeight={400} item lineHeight='22px' pl='3px'>
{isElected
? <ShowValue height={14} value={apy ? `${apy}%` : undefined} width='50px' />
: 'N/A'}
</Grid>
</Grid>
<Grid alignItems='center' container item justifyContent='flex-end' sx={{ lineHeight: '23px', pl: '2px' }} width='fit-content'>
{isActive && allInOneRow &&
<Infotip text={t('Active')}>
Expand Down
1 change: 1 addition & 0 deletions packages/extension-polkagate/src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ export { default as useTransactionState } from './useTransactionState';
export { default as useTranslation } from './useTranslation';
export { default as useUnstakingAmount } from './useUnstakingAmount';
export { default as useUnSupportedNetwork } from './useUnSupportedNetwork';
export { default as useValidatorApy } from './useValidatorApy';
export { default as useValidators } from './useValidators';
export { default as useValidatorsIdentities } from './useValidatorsIdentities';
export { default as useValidatorSuggestion } from './useValidatorSuggestion';
Expand Down
9 changes: 4 additions & 5 deletions packages/extension-polkagate/src/hooks/useCurrentEraIndex.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,19 @@
// Copyright 2019-2024 @polkadot/extension-polkagate authors & contributors
// SPDX-License-Identifier: Apache-2.0
// @ts-nocheck

import { useEffect, useState } from 'react';

import type { AccountId } from '@polkadot/types/interfaces/runtime';

import { useEffect, useState } from 'react';

import { useApi } from '.';

/** This hook is going to be used for users account existing in the extension */
export default function useCurrentEraIndex(address: AccountId | string | undefined): number | undefined {
export default function useCurrentEraIndex (address: AccountId | string | undefined): number | undefined {
const [index, setIndex] = useState<number>();
const api = useApi(address);

useEffect(() => {
api && api.query.staking && api.query.staking.currentEra().then((i) => {
api?.query['staking']?.['currentEra']().then((i) => {
setIndex(Number(i?.toString() || '0'));
}).catch(console.error);
}, [api]);
Expand Down
128 changes: 128 additions & 0 deletions packages/extension-polkagate/src/hooks/useValidatorApy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
// Copyright 2019-2024 @polkadot/extension-polkagate authors & contributors
// SPDX-License-Identifier: Apache-2.0

import type { ApiPromise } from '@polkadot/api';
import type { Option, u128 } from '@polkadot/types';
//@ts-ignore
import type { PalletStakingActiveEraInfo, PalletStakingEraRewardPoints, PalletStakingValidatorPrefs, SpStakingPagedExposureMetadata } from '@polkadot/types/lookup';

import { useCallback, useEffect, useState } from 'react';

import { BN, BN_HUNDRED, BN_ZERO } from '@polkadot/util';

interface ValidatorEraInfo {
netReward: BN;
total: BN;
}

export default function useValidatorApy (api: ApiPromise | undefined, validatorAddress: string, isElected?: boolean): string | undefined | null {
const [apy, setApy] = useState<string | null>();

const calculateValidatorAPY = useCallback(async (validatorAddress: string) => {
if (!api) {
return;
}

// Define the number of past eras you want to check (e.g., last 10 eras)
const eraDepth = 10;
const decimal = new BN(10 ** api.registry.chainDecimals[0]);
let totalRewards = BN_ZERO;
let totalPoints = BN_ZERO;
let validatorPoints = BN_ZERO;
let totalStaked = BN_ZERO;
const validatorEraInfo: ValidatorEraInfo[] = [];

const currentEra = ((await api.query['staking']['activeEra']()) as Option<PalletStakingActiveEraInfo>).unwrap().index.toNumber();
const { commission } = await api.query['staking']['validators'](validatorAddress) as PalletStakingValidatorPrefs;

// Loop over the past eras to calculate rewards for the validator
for (let eraIndex = currentEra - eraDepth; eraIndex <= currentEra; eraIndex++) {
let netReward;
const eraReward = await api.query['staking']['erasValidatorReward'](eraIndex) as Option<u128>;

if (eraReward.isNone) {
continue;
}

const eraPoints = await api.query['staking']['erasRewardPoints'](eraIndex) as PalletStakingEraRewardPoints;

let validatorEraPoints;

for (const [address, points] of eraPoints.individual.entries()) {
if (address.toString() === validatorAddress) {
validatorEraPoints = points;
break;
}
}

if (validatorEraPoints) {
// Accumulate the validator's points and total points
validatorPoints = validatorPoints.add(validatorEraPoints);
totalPoints = totalPoints.add(eraPoints.total);
const _eraReward = eraReward.unwrap();

netReward = _eraReward.mul(validatorPoints).div(totalPoints).muln(100 - (commission.toNumber() / 1e7)).div(BN_HUNDRED);
} else {
continue;
}

// Fetch the validator's stake in this era
const validatorExposure = await api.query['staking']['erasStakersOverview'](eraIndex, validatorAddress) as Option<SpStakingPagedExposureMetadata>;

if (validatorExposure.isSome) {
const { total } = validatorExposure.unwrap();
const totalAsBN = new BN(total.toString());

if (totalAsBN.isZero()) {
continue;
}

validatorEraInfo.push(
{
netReward,
total: totalAsBN
});
}
}

if (!validatorEraInfo.length) {
setApy(null);
}

validatorEraInfo.forEach(({ netReward, total }) => {
totalRewards = totalRewards.add(netReward);
totalStaked = totalStaked.add(total);
});

const actualDepth = validatorEraInfo.length;

totalStaked = totalStaked.div(decimal).divn(actualDepth);

const dailyReward = totalRewards.div(decimal).divn(actualDepth);

// Calculate daily return as a fraction of the staked amount
const dailyReturn = dailyReward.toNumber() / totalStaked.toNumber();

const APY = (dailyReturn * 365 * 100).toFixed(2);

if (!isFinite(+APY) || isNaN(+APY)) {
setApy(null);
}

setApy(APY);
}, [api]);

useEffect(() => {
if (!api || isElected === undefined) {
return;
}

if (isElected === false) {
return setApy(null);
}

calculateValidatorAPY(validatorAddress).catch(console.error);
}, [api, calculateValidatorAPY, isElected, validatorAddress]);

return apy;
}
22 changes: 14 additions & 8 deletions packages/extension-polkagate/src/hooks/useValidators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

import type { Option } from '@polkadot/types';
import type { AccountId } from '@polkadot/types/interfaces';
//@ts-ignore
// @ts-ignore
import type { PalletStakingValidatorPrefs } from '@polkadot/types/lookup';
import type { AnyJson } from '@polkadot/types/types';
import type { BN } from '@polkadot/util';
Expand Down Expand Up @@ -105,7 +105,7 @@ export default function useValidators (address: string | undefined, validators?:
}

const getValidatorsPaged = async (eraIndex: number) => {
if (!api) {
if (!api || !currentEraIndex) {
return; // never happens since we check api before, but to suppress linting
}

Expand Down Expand Up @@ -152,20 +152,24 @@ export default function useValidators (address: string | undefined, validators?:
const current: ValidatorInfo[] = [];
const waiting: ValidatorInfo[] = [];

Object.keys(validatorPrefs).forEach((v) => {
Object.keys(currentEraValidatorsOverview).includes(v)
? current.push(
for (const v of Object.keys(validatorPrefs)) {
if (Object.keys(currentEraValidatorsOverview).includes(v)) {
// const apy = await getValidatorApy(api, v, currentEraValidatorsOverview[v].total, validatorPrefs[v].commission, currentEraIndex);

current.push(
{
accountId: v as unknown as AccountId,
// apy,
exposure: {
...currentEraValidatorsOverview[v],
others: currentNominators[v]
},
stashId: v as unknown as AccountId,
validatorPrefs: validatorPrefs[v]
} as unknown as ValidatorInfo // types need to be revised!
)
: waiting.push(
);
} else {
waiting.push(
{
accountId: v as unknown as AccountId,
exposure: {
Expand All @@ -177,7 +181,9 @@ export default function useValidators (address: string | undefined, validators?:
validatorPrefs: validatorPrefs[v]
} as unknown as ValidatorInfo
);
});
}
}

const inf = {
current,
eraIndex,
Expand Down
3 changes: 1 addition & 2 deletions packages/extension-polkagate/src/util/ObserveResize.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
// Copyright 2019-2024 @polkadot/extension-polkagate authors & contributors
// SPDX-License-Identifier: Apache-2.0
// @ts-nocheck

import { useEffect } from 'react';

export default function ObserveResize(element: Element, maxSize: number, onResize: () => void): void {
export default function ObserveResize (element: Element, maxSize: number, onResize: () => void): void {
useEffect(() => {
if (!element) {
return;
Expand Down
1 change: 1 addition & 0 deletions packages/extension-polkagate/src/util/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export interface ValidatorInfo extends DeriveStakingQuery {
others: Other[]
};
accountInfo?: DeriveAccountInfo;
apy?: string | null;
isOversubscribed?: {
notSafe: boolean;
safe: boolean;
Expand Down
Loading