From 86e42806805633e3c40bb8edf4f502240d81c934 Mon Sep 17 00:00:00 2001 From: Sam Keen Date: Thu, 8 Dec 2022 14:57:11 +0000 Subject: [PATCH] feat(2089): second level validator table mouseovers (#2354) * feat(1913): validator table column heading mouseovers * feat(2089): stake needed for promotion tooltip * feat(2089): normalised voting power tooltip * feat(2089): total stake tooltip * feat(2089): total stake tooltip for standby-pending-validators-table * feat(2089): total penalties tooltip * feat(2089): tooltip colour tweakage * feat(2089): unit tests for the shared validator data functions * feat(2089): removed unused import from tooltip.tsx * Update apps/token/src/routes/staking/home/validator-tables/standby-pending-validators-table.tsx Co-authored-by: Dexter Edwards * feat(2089): tweaks from PR comments Co-authored-by: Dexter Edwards --- apps/token/src/i18n/translations/dev.json | 12 +- .../src/routes/staking/home/Nodes.graphql | 2 + .../staking/home/__generated___/Nodes.ts | 6 +- .../consensus-validators-table.spec.tsx | 2 + .../consensus-validators-table.tsx | 52 +++++- .../home/validator-tables/shared.spec.tsx | 20 +-- .../staking/home/validator-tables/shared.tsx | 170 +++++++++++++++--- .../standby-pending-validators-table.tsx | 77 ++++++-- .../validator-tables/validator-tables.tsx | 8 +- .../routes/staking/node/validator-table.tsx | 67 ++++--- apps/token/src/routes/staking/shared.spec.ts | 122 +++++++++++++ apps/token/src/routes/staking/shared.ts | 75 +++++++- .../src/components/tooltip/tooltip.tsx | 10 +- 13 files changed, 517 insertions(+), 106 deletions(-) create mode 100644 apps/token/src/routes/staking/shared.spec.ts diff --git a/apps/token/src/i18n/translations/dev.json b/apps/token/src/i18n/translations/dev.json index bba3a170d..3af1c0d58 100644 --- a/apps/token/src/i18n/translations/dev.json +++ b/apps/token/src/i18n/translations/dev.json @@ -577,10 +577,14 @@ "finalOutcomeMayDiffer": "Final outcome may differ", "votingPower": "Voting power", "normalisedVotingPower": "Normalised voting power", + "unnormalisedVotingPower": "Unnormalised voting power", "noValidators": "No validators", "validator": "Validator", "stake": "Stake", "stakeShare": "Stake share", + "stakedByOperator": "Staked by operator", + "stakedByDelegates": "Staked by delegates", + "totalStake": "Total stake", "pendingStake": "Pending stake", "totalPenalties": "Total penalties", "noPenaltyDataFromLastEpoch": "No penalty data from last epoch", @@ -724,6 +728,10 @@ "StakeShareDescription": "The stake a validator represents as a share of total stake across the network.", "TotalPenaltiesDescription": "Total of penalties taking into account performance (considering proportion of blocks proposed against the number of blocks the validator was expected to propose) and any overstaking.", "PendingStakeDescription": "The amount of stake that will be added or removed from the validator from the next epoch.", - "StakeNeededForPromotionStandbyDescription": "The amount of stake needed for promotion to consensus, assuming constant performance in line with previous epoch.", - "StakeNeededForPromotionCandidateDescription": "The amount of stake needed for promotion to standby, assuming constant performance in line with previous epoch." + "StakeNeededForPromotionStandbyDescription": "{{prefix}} additional stake needed for promotion to consensus, assuming constant performance in line with previous epoch.", + "StakeNeededForPromotionCandidateDescription": "{{prefix}} additional stake needed for promotion to standby, assuming constant performance in line with previous epoch.", + "Score": "Score", + "performancePenalty": "Performance penalty", + "overstaked": "Overstaked", + "overstakedPenalty": "Overstaked penalty" } diff --git a/apps/token/src/routes/staking/home/Nodes.graphql b/apps/token/src/routes/staking/home/Nodes.graphql index d387cc0a9..46b80bf9a 100644 --- a/apps/token/src/routes/staking/home/Nodes.graphql +++ b/apps/token/src/routes/staking/home/Nodes.graphql @@ -3,6 +3,8 @@ fragment NodesFragment on Node { id name pubkey + stakedByOperator + stakedByDelegates stakedTotal pendingStake rankingScore { diff --git a/apps/token/src/routes/staking/home/__generated___/Nodes.ts b/apps/token/src/routes/staking/home/__generated___/Nodes.ts index 9e26f7fdb..537544df7 100644 --- a/apps/token/src/routes/staking/home/__generated___/Nodes.ts +++ b/apps/token/src/routes/staking/home/__generated___/Nodes.ts @@ -3,12 +3,12 @@ import { Schema as Types } from '@vegaprotocol/types'; import { gql } from '@apollo/client'; import * as Apollo from '@apollo/client'; const defaultOptions = {} as const; -export type NodesFragmentFragment = { __typename?: 'Node', avatarUrl?: string | null, id: string, name: string, pubkey: string, stakedTotal: string, pendingStake: string, rankingScore: { __typename?: 'RankingScore', rankingScore: string, stakeScore: string, performanceScore: string, votingPower: string, status: Types.ValidatorStatus } }; +export type NodesFragmentFragment = { __typename?: 'Node', avatarUrl?: string | null, id: string, name: string, pubkey: string, stakedByOperator: string, stakedByDelegates: string, stakedTotal: string, pendingStake: string, rankingScore: { __typename?: 'RankingScore', rankingScore: string, stakeScore: string, performanceScore: string, votingPower: string, status: Types.ValidatorStatus } }; export type NodesQueryVariables = Types.Exact<{ [key: string]: never; }>; -export type NodesQuery = { __typename?: 'Query', epoch: { __typename?: 'Epoch', id: string, timestamps: { __typename?: 'EpochTimestamps', start?: any | null, end?: any | null, expiry?: any | null } }, nodesConnection: { __typename?: 'NodesConnection', edges?: Array<{ __typename?: 'NodeEdge', node: { __typename?: 'Node', avatarUrl?: string | null, id: string, name: string, pubkey: string, stakedTotal: string, pendingStake: string, rankingScore: { __typename?: 'RankingScore', rankingScore: string, stakeScore: string, performanceScore: string, votingPower: string, status: Types.ValidatorStatus } } } | null> | null }, nodeData?: { __typename?: 'NodeData', stakedTotal: string } | null }; +export type NodesQuery = { __typename?: 'Query', epoch: { __typename?: 'Epoch', id: string, timestamps: { __typename?: 'EpochTimestamps', start?: any | null, end?: any | null, expiry?: any | null } }, nodesConnection: { __typename?: 'NodesConnection', edges?: Array<{ __typename?: 'NodeEdge', node: { __typename?: 'Node', avatarUrl?: string | null, id: string, name: string, pubkey: string, stakedByOperator: string, stakedByDelegates: string, stakedTotal: string, pendingStake: string, rankingScore: { __typename?: 'RankingScore', rankingScore: string, stakeScore: string, performanceScore: string, votingPower: string, status: Types.ValidatorStatus } } } | null> | null }, nodeData?: { __typename?: 'NodeData', stakedTotal: string } | null }; export const NodesFragmentFragmentDoc = gql` fragment NodesFragment on Node { @@ -16,6 +16,8 @@ export const NodesFragmentFragmentDoc = gql` id name pubkey + stakedByOperator + stakedByDelegates stakedTotal pendingStake rankingScore { diff --git a/apps/token/src/routes/staking/home/validator-tables/consensus-validators-table.spec.tsx b/apps/token/src/routes/staking/home/validator-tables/consensus-validators-table.spec.tsx index c255054af..bbb30043c 100644 --- a/apps/token/src/routes/staking/home/validator-tables/consensus-validators-table.spec.tsx +++ b/apps/token/src/routes/staking/home/validator-tables/consensus-validators-table.spec.tsx @@ -21,6 +21,8 @@ const nodeFactory = ( avatarUrl: 'https://upload.wikimedia.org/wikipedia/en/2/25/Marvin-TV-3.jpg', pubkey: '6abc23391a9f888ab240415bf63d6844b03fc360be822f4a1d2cd832d87b2917', stakedTotal: '14182454495731682635157', + stakedByOperator: '1000000000000000000000', + stakedByDelegates: '13182454495731682635157', pendingStake: '0', rankingScore: { rankingScore: '0.67845061012234727427532760837568', diff --git a/apps/token/src/routes/staking/home/validator-tables/consensus-validators-table.tsx b/apps/token/src/routes/staking/home/validator-tables/consensus-validators-table.tsx index 65b4581e2..8b6afd8dd 100644 --- a/apps/token/src/routes/staking/home/validator-tables/consensus-validators-table.tsx +++ b/apps/token/src/routes/staking/home/validator-tables/consensus-validators-table.tsx @@ -4,14 +4,25 @@ import { useNavigate } from 'react-router-dom'; import { AgGridDynamic as AgGrid, Button } from '@vegaprotocol/ui-toolkit'; import { useAppState } from '../../../../contexts/app-state/app-state-context'; import { BigNumber } from '../../../../lib/bignumber'; -import { normalisedVotingPower, rawValidatorScore } from '../../shared'; +import { + getFormattedPerformanceScore, + getNormalisedVotingPower, + getOverstakedAmount, + getOverstakingPenalty, + getPerformancePenalty, + getRawValidatorScore, + getTotalPenalties, + getUnnormalisedVotingPower, +} from '../../shared'; import { defaultColDef, NODE_LIST_GRID_STYLES, stakedTotalPercentage, - totalPenalties, + TotalPenaltiesRenderer, + TotalStakeRenderer, ValidatorFields, ValidatorRenderer, + VotingPowerRenderer, } from './shared'; import type { AgGridReact } from 'ag-grid-react'; import type { ColDef } from 'ag-grid-community'; @@ -66,11 +77,20 @@ export const ConsensusValidatorsTable = ({ id, name, avatarUrl, + stakedByDelegates, + stakedByOperator, stakedTotal, rankingScore: { stakeScore, votingPower, performanceScore }, pendingStake, votingPowerRanking, }) => { + const validatorScore = getRawValidatorScore(previousEpochData, id); + const overstakedAmount = getOverstakedAmount( + validatorScore, + stakedTotal, + totalStake + ); + return { id, [ValidatorFields.RANKING_INDEX]: votingPowerRanking, @@ -83,10 +103,29 @@ export const ConsensusValidatorsTable = ({ 2 ), [ValidatorFields.NORMALISED_VOTING_POWER]: - normalisedVotingPower(votingPower), + getNormalisedVotingPower(votingPower), + [ValidatorFields.UNNORMALISED_VOTING_POWER]: + getUnnormalisedVotingPower(validatorScore), [ValidatorFields.STAKE_SHARE]: stakedTotalPercentage(stakeScore), - [ValidatorFields.TOTAL_PENALTIES]: totalPenalties( - rawValidatorScore(previousEpochData, id), + [ValidatorFields.STAKED_BY_DELEGATES]: formatNumber( + toBigNum(stakedByDelegates, decimals), + 2 + ), + [ValidatorFields.STAKED_BY_OPERATOR]: formatNumber( + toBigNum(stakedByOperator, decimals), + 2 + ), + [ValidatorFields.PERFORMANCE_SCORE]: + getFormattedPerformanceScore(performanceScore).toString(), + [ValidatorFields.PERFORMANCE_PENALTY]: + getPerformancePenalty(performanceScore), + [ValidatorFields.OVERSTAKED_AMOUNT]: overstakedAmount.toString(), + [ValidatorFields.OVERSTAKING_PENALTY]: getOverstakingPenalty( + overstakedAmount, + totalStake + ), + [ValidatorFields.TOTAL_PENALTIES]: getTotalPenalties( + validatorScore, performanceScore, stakedTotal, totalStake @@ -152,12 +191,14 @@ export const ConsensusValidatorsTable = ({ field: ValidatorFields.STAKE, headerName: t(ValidatorFields.STAKE).toString(), headerTooltip: t('StakeDescription').toString(), + cellRenderer: TotalStakeRenderer, width: 120, }, { field: ValidatorFields.NORMALISED_VOTING_POWER, headerName: t(ValidatorFields.NORMALISED_VOTING_POWER).toString(), headerTooltip: t('NormalisedVotingPowerDescription').toString(), + cellRenderer: VotingPowerRenderer, width: 200, sort: 'desc', }, @@ -171,6 +212,7 @@ export const ConsensusValidatorsTable = ({ field: ValidatorFields.TOTAL_PENALTIES, headerName: t(ValidatorFields.TOTAL_PENALTIES).toString(), headerTooltip: t('TotalPenaltiesDescription').toString(), + cellRenderer: TotalPenaltiesRenderer, width: 120, }, { diff --git a/apps/token/src/routes/staking/home/validator-tables/shared.spec.tsx b/apps/token/src/routes/staking/home/validator-tables/shared.spec.tsx index d4685a391..8913beff9 100644 --- a/apps/token/src/routes/staking/home/validator-tables/shared.spec.tsx +++ b/apps/token/src/routes/staking/home/validator-tables/shared.spec.tsx @@ -1,5 +1,5 @@ -import { rawValidatorScore } from '../../shared'; -import { stakedTotalPercentage, totalPenalties } from './shared'; +import { getRawValidatorScore, getTotalPenalties } from '../../shared'; +import { stakedTotalPercentage } from './shared'; const mockPreviousEpochData = { epoch: { @@ -28,30 +28,30 @@ describe('stakedTotalPercentage', () => { describe('totalPenalties', () => { it('should return the correct penalty based on arbitrary values, test 1', () => { expect( - totalPenalties( - rawValidatorScore(mockPreviousEpochData, '0x123'), + getTotalPenalties( + getRawValidatorScore(mockPreviousEpochData, '0x123'), '0.1', '5000', '100000' ) - ).toBe('50%'); + ).toBe('50.00%'); }); it('should return the correct penalty based on lower performance score than first test', () => { expect( - totalPenalties( - rawValidatorScore(mockPreviousEpochData, '0x123'), + getTotalPenalties( + getRawValidatorScore(mockPreviousEpochData, '0x123'), '0.05', '5000', '100000' ) - ).toBe('75%'); + ).toBe('75.00%'); }); it('should return the correct penalty based on higher amount of stake than other tests (great penalty due to anti-whaling)', () => { expect( - totalPenalties( - rawValidatorScore(mockPreviousEpochData, '0x123'), + getTotalPenalties( + getRawValidatorScore(mockPreviousEpochData, '0x123'), '0.1', '5000', '5500' diff --git a/apps/token/src/routes/staking/home/validator-tables/shared.tsx b/apps/token/src/routes/staking/home/validator-tables/shared.tsx index 5cbf1a8cb..7fc3298ca 100644 --- a/apps/token/src/routes/staking/home/validator-tables/shared.tsx +++ b/apps/token/src/routes/staking/home/validator-tables/shared.tsx @@ -1,7 +1,15 @@ import { Link } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { toBigNum } from '@vegaprotocol/react-helpers'; -import { Button, TooltipCellComponent } from '@vegaprotocol/ui-toolkit'; +import { + formatNumber, + formatNumberPercentage, + toBigNum, +} from '@vegaprotocol/react-helpers'; +import { + Button, + Tooltip, + TooltipCellComponent, +} from '@vegaprotocol/ui-toolkit'; import type { NodesFragmentFragment } from '../__generated___/Nodes'; import type { PreviousEpochQuery } from '../../__generated___/PreviousEpoch'; @@ -9,11 +17,19 @@ export enum ValidatorFields { RANKING_INDEX = 'rankingIndex', VALIDATOR = 'validator', STAKE = 'stake', + STAKED_BY_DELEGATES = 'stakedByDelegates', + STAKED_BY_OPERATOR = 'stakedByOperator', PENDING_STAKE = 'pendingStake', STAKE_SHARE = 'stakeShare', TOTAL_PENALTIES = 'totalPenalties', NORMALISED_VOTING_POWER = 'normalisedVotingPower', + UNNORMALISED_VOTING_POWER = 'unnormalisedVotingPower', STAKE_NEEDED_FOR_PROMOTION = 'stakeNeededForPromotion', + STAKE_NEEDED_FOR_PROMOTION_DESCRIPTION = 'stakeNeededForPromotionDescription', + PERFORMANCE_SCORE = 'performanceScore', + PERFORMANCE_PENALTY = 'performancePenalty', + OVERSTAKED_AMOUNT = 'overstakedAmount', + OVERSTAKING_PENALTY = 'overstakingPenalty', } export interface ValidatorsTableProps { @@ -34,27 +50,7 @@ export const NODE_LIST_GRID_STYLES = ` `; export const stakedTotalPercentage = (stakeScore: string) => - toBigNum(stakeScore, 0).times(100).dp(2).toString() + '%'; - -export const totalPenalties = ( - rawValidatorScore: string | null | undefined, - performanceScore: string, - stakedTotal: string, - totalStake: string -) => { - const totalPenaltiesCalc = - rawValidatorScore !== null - ? 100 * - Math.max( - 0, - 1 - - (Number(performanceScore) * Number(rawValidatorScore)) / - (Number(stakedTotal) / Number(totalStake || 0)) - ) - : 0; - - return toBigNum(totalPenaltiesCalc, 0).dp(2).toString() + '%'; -}; + formatNumberPercentage(toBigNum(stakeScore, 0).times(100), 2); export const defaultColDef = { sortable: true, @@ -64,6 +60,7 @@ export const defaultColDef = { cellStyle: { margin: '10px 0' }, tooltipComponent: TooltipCellComponent, tooltipShowDelay: 0, + tooltipHideDelay: 0, }; interface ValidatorRendererProps { @@ -94,3 +91,130 @@ export const ValidatorRenderer = ({ data }: ValidatorRendererProps) => { ); }; + +interface StakeNeededForPromotionRendererProps { + data: { + stakeNeededForPromotion: string | undefined; + stakeNeededForPromotionDescription: string; + }; +} + +export const StakeNeededForPromotionRenderer = ({ + data, +}: StakeNeededForPromotionRendererProps) => { + return ( + + + {data.stakeNeededForPromotion && + formatNumber(data.stakeNeededForPromotion, 2)} + + + ); +}; + +interface VotingPowerRendererProps { + data: { + normalisedVotingPower: string | undefined | null; + unnormalisedVotingPower: string | undefined | null; + }; +} + +export const VotingPowerRenderer = ({ data }: VotingPowerRendererProps) => { + const { t } = useTranslation(); + + return ( + +
+ {t('unnormalisedVotingPower')}: {data.unnormalisedVotingPower} +
+
+ {t('normalisedVotingPower')}: {data.normalisedVotingPower} +
+ + } + > + {data.normalisedVotingPower} +
+ ); +}; + +interface TotalStakeRendererProps { + data: { + stake: string; + stakedByDelegates: string; + stakedByOperator: string; + }; +} + +export const TotalStakeRenderer = ({ data }: TotalStakeRendererProps) => { + const { t } = useTranslation(); + + return ( + +
+ {t('stakedByOperator')}: {data.stakedByOperator.toString()} +
+
+ {t('stakedByDelegates')}: {data.stakedByDelegates.toString()} +
+
+ {t('totalStake')}: {data.stake} +
+ + } + > + {data.stake} +
+ ); +}; + +interface TotalPenaltiesRendererProps { + data: { + performanceScore: string; + performancePenalty: string; + overstakedAmount: string; + overstakedPenalty: string; + totalPenalties: string; + }; +} + +export const TotalPenaltiesRenderer = ({ + data, +}: TotalPenaltiesRendererProps) => { + const { t } = useTranslation(); + + return ( + +
+ + {t('performancePenalty')}: {data.performancePenalty} + + + ({t('score')} {data.performanceScore}) + +
+
+ + {t('overstakedPenalty')}: {data.overstakedPenalty} + + + ({t('overstaked')} {data.overstakedAmount}) + +
+
+ {t('totalPenalties')}:{' '} + {data.totalPenalties} +
+ + } + > + {data.totalPenalties} +
+ ); +}; diff --git a/apps/token/src/routes/staking/home/validator-tables/standby-pending-validators-table.tsx b/apps/token/src/routes/staking/home/validator-tables/standby-pending-validators-table.tsx index 16bea58a1..3ce9254db 100644 --- a/apps/token/src/routes/staking/home/validator-tables/standby-pending-validators-table.tsx +++ b/apps/token/src/routes/staking/home/validator-tables/standby-pending-validators-table.tsx @@ -4,14 +4,23 @@ import { useNavigate } from 'react-router-dom'; import { AgGridDynamic as AgGrid } from '@vegaprotocol/ui-toolkit'; import { useAppState } from '../../../../contexts/app-state/app-state-context'; import { BigNumber } from '../../../../lib/bignumber'; -import { rawValidatorScore } from '../../shared'; +import { + getFormattedPerformanceScore, + getOverstakedAmount, + getOverstakingPenalty, + getPerformancePenalty, + getRawValidatorScore, + getTotalPenalties, +} from '../../shared'; import { defaultColDef, NODE_LIST_GRID_STYLES, + StakeNeededForPromotionRenderer, stakedTotalPercentage, - totalPenalties, ValidatorFields, ValidatorRenderer, + TotalPenaltiesRenderer, + TotalStakeRenderer, } from './shared'; import type { AgGridReact } from 'ag-grid-react'; import type { ColDef } from 'ag-grid-community'; @@ -46,11 +55,20 @@ export const StandbyPendingValidatorsTable = ({ id, name, avatarUrl, + stakedByDelegates, + stakedByOperator, stakedTotal, rankingScore: { stakeScore, performanceScore }, pendingStake, }) => { - let individualStakeNeededForPromotion = undefined; + const validatorScore = getRawValidatorScore(previousEpochData, id); + const overstakedAmount = getOverstakedAmount( + validatorScore, + stakedTotal, + totalStake + ); + let individualStakeNeededForPromotion, + individualStakeNeededForPromotionDescription; if (stakeNeededForPromotion) { const stakedTotalBigNum = new BigNumber(stakedTotal); @@ -61,9 +79,23 @@ export const StandbyPendingValidatorsTable = ({ .dividedBy(performanceScoreBigNum) .minus(stakedTotalBigNum); - individualStakeNeededForPromotion = calc.isGreaterThan(0) - ? calc.toString() - : '0'; + if (calc.isGreaterThan(0)) { + individualStakeNeededForPromotion = calc.toString(); + individualStakeNeededForPromotionDescription = t( + stakeNeededForPromotionDescription, + { + prefix: formatNumber(calc, 2).toString(), + } + ); + } else { + individualStakeNeededForPromotion = '0'; + individualStakeNeededForPromotionDescription = t( + stakeNeededForPromotionDescription, + { + prefix: formatNumber(0, 2).toString(), + } + ); + } } return { @@ -77,10 +109,29 @@ export const StandbyPendingValidatorsTable = ({ 2 ), [ValidatorFields.STAKE_NEEDED_FOR_PROMOTION]: - individualStakeNeededForPromotion || t('n/a'), + individualStakeNeededForPromotion || null, + [ValidatorFields.STAKE_NEEDED_FOR_PROMOTION_DESCRIPTION]: + individualStakeNeededForPromotionDescription || t('n/a'), [ValidatorFields.STAKE_SHARE]: stakedTotalPercentage(stakeScore), - [ValidatorFields.TOTAL_PENALTIES]: totalPenalties( - rawValidatorScore(previousEpochData, id), + [ValidatorFields.STAKED_BY_DELEGATES]: formatNumber( + toBigNum(stakedByDelegates, decimals), + 2 + ), + [ValidatorFields.STAKED_BY_OPERATOR]: formatNumber( + toBigNum(stakedByOperator, decimals), + 2 + ), + [ValidatorFields.PERFORMANCE_SCORE]: + getFormattedPerformanceScore(performanceScore).toString(), + [ValidatorFields.PERFORMANCE_PENALTY]: + getPerformancePenalty(performanceScore), + [ValidatorFields.OVERSTAKED_AMOUNT]: overstakedAmount.toString(), + [ValidatorFields.OVERSTAKING_PENALTY]: getOverstakingPenalty( + overstakedAmount, + totalStake + ), + [ValidatorFields.TOTAL_PENALTIES]: getTotalPenalties( + getRawValidatorScore(previousEpochData, id), performanceScore, stakedTotal, totalStake @@ -97,6 +148,7 @@ export const StandbyPendingValidatorsTable = ({ decimals, previousEpochData, stakeNeededForPromotion, + stakeNeededForPromotionDescription, t, totalStake, ]); @@ -116,12 +168,16 @@ export const StandbyPendingValidatorsTable = ({ field: ValidatorFields.STAKE, headerName: t(ValidatorFields.STAKE).toString(), headerTooltip: t('StakeDescription').toString(), + cellRenderer: TotalStakeRenderer, width: 120, }, { field: ValidatorFields.STAKE_NEEDED_FOR_PROMOTION, headerName: t(ValidatorFields.STAKE_NEEDED_FOR_PROMOTION).toString(), - headerTooltip: stakeNeededForPromotionDescription, + headerTooltip: t(stakeNeededForPromotionDescription, { + prefix: t('The'), + }), + cellRenderer: StakeNeededForPromotionRenderer, width: 210, sort: 'asc', }, @@ -135,6 +191,7 @@ export const StandbyPendingValidatorsTable = ({ field: ValidatorFields.TOTAL_PENALTIES, headerName: t(ValidatorFields.TOTAL_PENALTIES).toString(), headerTooltip: t('TotalPenaltiesDescription').toString(), + cellRenderer: TotalPenaltiesRenderer, width: 120, }, { diff --git a/apps/token/src/routes/staking/home/validator-tables/validator-tables.tsx b/apps/token/src/routes/staking/home/validator-tables/validator-tables.tsx index 98424b8c5..9b2c21ce3 100644 --- a/apps/token/src/routes/staking/home/validator-tables/validator-tables.tsx +++ b/apps/token/src/routes/staking/home/validator-tables/validator-tables.tsx @@ -123,9 +123,7 @@ export const ValidatorTables = ({ previousEpochData={previousEpochData} totalStake={totalStake} stakeNeededForPromotion={stakeNeededForPromotion} - stakeNeededForPromotionDescription={t( - 'StakeNeededForPromotionStandbyDescription' - )} + stakeNeededForPromotionDescription="StakeNeededForPromotionStandbyDescription" /> )} @@ -154,9 +152,7 @@ export const ValidatorTables = ({ previousEpochData={previousEpochData} totalStake={totalStake} stakeNeededForPromotion={stakeNeededForPromotion} - stakeNeededForPromotionDescription={t( - 'StakeNeededForPromotionCandidateDescription' - )} + stakeNeededForPromotionDescription="StakeNeededForPromotionCandidateDescription" /> )} diff --git a/apps/token/src/routes/staking/node/validator-table.tsx b/apps/token/src/routes/staking/node/validator-table.tsx index 2072b7a9d..22f95f7f3 100644 --- a/apps/token/src/routes/staking/node/validator-table.tsx +++ b/apps/token/src/routes/staking/node/validator-table.tsx @@ -10,8 +10,16 @@ import { formatNumber } from '../../../lib/format-number'; import { ExternalLinks, toBigNum } from '@vegaprotocol/react-helpers'; import { useAppState } from '../../../contexts/app-state/app-state-context'; import { Schema } from '@vegaprotocol/types'; -import { totalPenalties } from '../home/validator-tables/shared'; -import { normalisedVotingPower, rawValidatorScore } from '../shared'; +import { + getFormattedPerformanceScore, + getNormalisedVotingPower, + getOverstakedAmount, + getOverstakingPenalty, + getPerformancePenalty, + getRawValidatorScore, + getTotalPenalties, + getUnnormalisedVotingPower, +} from '../shared'; import type { ReactNode } from 'react'; import type { StakingNodeFieldsFragment } from './__generated___/Staking'; import type { PreviousEpochQuery } from '../__generated___/PreviousEpoch'; @@ -60,48 +68,26 @@ export const ValidatorTable = ({ const stakedOnNode = toBigNum(node.stakedTotal, decimals); - const location = countryData.find((c) => c.code === node.location)?.name; + const validatorScore = getRawValidatorScore(previousEpochData, node.id); - const performanceScore = new BigNumber(node.rankingScore.performanceScore).dp( - 2 + const overstakedAmount = getOverstakedAmount( + validatorScore, + stakedTotal, + node.stakedTotal ); - const validatorScore = rawValidatorScore(previousEpochData, node.id); - - const overstakedAmount = useMemo(() => { - const amount = validatorScore - ? new BigNumber(validatorScore).times(total).minus(stakedOnNode).dp(2) - : new BigNumber(0); - - return amount.isNegative() ? new BigNumber(0) : amount; - }, [stakedOnNode, total, validatorScore]); - const stakePercentage = total.isEqualTo(0) || stakedOnNode.isEqualTo(0) ? '-' : stakedOnNode.dividedBy(total).times(100).dp(2).toString() + '%'; - const performancePenalty = - new BigNumber(1).minus(performanceScore).times(100).toString() + '%'; - - const totalPenaltiesAmount = totalPenalties( + const totalPenaltiesAmount = getTotalPenalties( validatorScore, node.rankingScore.performanceScore, stakedOnNode.toString(), total.toString() ); - const overstakingPenalty = useMemo( - () => - overstakedAmount.dividedBy(stakedOnNode).times(100).dp(2).toString() + - '%', - [overstakedAmount, stakedOnNode] - ); - - const unnormalisedVotingPower = validatorScore - ? new BigNumber(validatorScore).times(100).dp(2).toString() + '%' - : null; - return (
@@ -148,7 +134,8 @@ export const ValidatorTable = ({ {t('SERVER LOCATION')} - {location || t('not available')} + {countryData.find((c) => c.code === node.location)?.name || + t('not available')} @@ -210,15 +197,23 @@ export const ValidatorTable = ({ {t('OVERSTAKED PENALTY')} - {overstakingPenalty} + + {getOverstakingPenalty(overstakedAmount, node.stakedTotal)} + {t('PERFORMANCE SCORE')} - {performanceScore.toString()} + + {getFormattedPerformanceScore( + node.rankingScore.performanceScore + ).toString()} + {t('PERFORMANCE PENALITY')} - {performancePenalty} + + {getPerformancePenalty(node.rankingScore.performanceScore)} + @@ -236,7 +231,7 @@ export const ValidatorTable = ({ > {t('UNNORMALISED VOTING POWER')} - {unnormalisedVotingPower} + {getUnnormalisedVotingPower(validatorScore)} @@ -244,7 +239,7 @@ export const ValidatorTable = ({ - {normalisedVotingPower(node.rankingScore.votingPower)} + {getNormalisedVotingPower(node.rankingScore.votingPower)} diff --git a/apps/token/src/routes/staking/shared.spec.ts b/apps/token/src/routes/staking/shared.spec.ts new file mode 100644 index 000000000..7e62fde9e --- /dev/null +++ b/apps/token/src/routes/staking/shared.spec.ts @@ -0,0 +1,122 @@ +import { BigNumber } from '../../lib/bignumber'; +import { + getRawValidatorScore, + getNormalisedVotingPower, + getUnnormalisedVotingPower, + getOverstakingPenalty, + getOverstakedAmount, + getFormattedPerformanceScore, + getPerformancePenalty, + getTotalPenalties, +} from './shared'; + +describe('getRawValidatorScore', () => { + const mockPreviousEpochData = { + epoch: { + id: '123', + validatorsConnection: { + edges: [ + { + node: { + id: '0x123', + rewardScore: { + rawValidatorScore: '0.25', + }, + }, + }, + { + node: { + id: '0x234', + rewardScore: { + rawValidatorScore: '0.35', + }, + }, + }, + ], + }, + }, + }; + + it('should return the rawValidatorScore for the given validator id', () => { + expect(getRawValidatorScore(mockPreviousEpochData, '0x123')).toEqual( + '0.25' + ); + expect(getRawValidatorScore(mockPreviousEpochData, '0x234')).toEqual( + '0.35' + ); + }); +}); + +describe('getNormalisedVotingPower', () => { + it('should return the normalised voting power', () => { + expect(getNormalisedVotingPower('123')).toEqual('1.23%'); + expect(getNormalisedVotingPower('789')).toEqual('7.89%'); + }); +}); + +describe('getUnnormalisedVotingPower', () => { + it('should return the unnormalised voting power', () => { + expect(getUnnormalisedVotingPower('0.25')).toEqual('25.00%'); + expect(getUnnormalisedVotingPower('0.35')).toEqual('35.00%'); + }); + + it('should return null if the validator score is null', () => { + expect(getUnnormalisedVotingPower(null)).toEqual(null); + }); +}); + +describe('getOverstakingPenalty', () => { + it('should return the overstaking penalty', () => { + expect( + getOverstakingPenalty(new BigNumber(100), Number(1000).toString()) + ).toEqual('10.00%'); + expect( + getOverstakingPenalty(new BigNumber(500), Number(2000).toString()) + ).toEqual('25.00%'); + }); +}); + +describe('getOverstakedAmount', () => { + it('should return the overstaked amount', () => { + expect( + getOverstakedAmount('0.21', Number(100).toString(), Number(20).toString()) + ).toEqual(new BigNumber(1)); + expect( + getOverstakedAmount('0.22', Number(100).toString(), Number(20).toString()) + ).toEqual(new BigNumber(2)); + expect( + getOverstakedAmount('0.30', Number(100).toString(), Number(20).toString()) + ).toEqual(new BigNumber(10)); + }); + + it('should return 0 if the overstaked amount is negative', () => { + expect( + getOverstakedAmount('0.19', Number(100).toString(), Number(20).toString()) + ).toEqual(new BigNumber(0)); + }); +}); + +describe('getFormattedPerformanceScore', () => { + it('should return the formatted performance score', () => { + expect(getFormattedPerformanceScore('0.25')).toEqual(new BigNumber(0.25)); + expect(getFormattedPerformanceScore('0.35')).toEqual(new BigNumber(0.35)); + }); +}); + +describe('getPerformancePenalty', () => { + it('should return the performance penalty', () => { + expect(getPerformancePenalty('0.25')).toEqual('75.00%'); + expect(getPerformancePenalty('0.5')).toEqual('50.00%'); + }); +}); + +describe('getTotalPenalties', () => { + it('should return the total penalties', () => { + expect(getTotalPenalties('0.25', '1', '5000', '10000')).toEqual('50.00%'); + expect(getTotalPenalties('0.25', '0.5', '5000', '10000')).toEqual('75.00%'); + }); + + it('should return 0 if the total penalties is negative', () => { + expect(getTotalPenalties('0.25', '0.5', '1000', '10000')).toEqual('0.00%'); + }); +}); diff --git a/apps/token/src/routes/staking/shared.ts b/apps/token/src/routes/staking/shared.ts index 3794ce064..86f53f3f6 100644 --- a/apps/token/src/routes/staking/shared.ts +++ b/apps/token/src/routes/staking/shared.ts @@ -1,7 +1,11 @@ -import { removePaginationWrapper, toBigNum } from '@vegaprotocol/react-helpers'; +import { + formatNumberPercentage, + removePaginationWrapper, +} from '@vegaprotocol/react-helpers'; import type { PreviousEpochQuery } from './__generated___/PreviousEpoch'; +import { BigNumber } from '../../lib/bignumber'; -export const rawValidatorScore = ( +export const getRawValidatorScore = ( previousEpochData: PreviousEpochQuery | undefined, id: string ) => { @@ -13,6 +17,69 @@ export const rawValidatorScore = ( : null; }; -export const normalisedVotingPower = (votingPower: string) => { - return toBigNum(votingPower, 0).dividedBy(100).dp(2).toString() + '%'; +export const getNormalisedVotingPower = (votingPower: string) => + formatNumberPercentage(new BigNumber(votingPower).dividedBy(100), 2); + +export const getUnnormalisedVotingPower = ( + validatorScore: string | null | undefined +) => + validatorScore + ? formatNumberPercentage(new BigNumber(validatorScore).times(100), 2) + : null; + +export const getFormattedPerformanceScore = (performanceScore: string) => + new BigNumber(performanceScore).dp(2); + +export const getPerformancePenalty = (performanceScore: string) => + formatNumberPercentage( + new BigNumber(1) + .minus(getFormattedPerformanceScore(performanceScore)) + .times(100), + 2 + ); + +export const getOverstakedAmount = ( + validatorScore: string | null | undefined, + totalStake: string, + stakedOnNode: string +) => { + const amount = validatorScore + ? new BigNumber(validatorScore) + .times(new BigNumber(totalStake)) + .minus(new BigNumber(stakedOnNode)) + .dp(2) + : new BigNumber(0); + + return amount.isNegative() ? new BigNumber(0) : amount; +}; + +export const getOverstakingPenalty = ( + overstakedAmount: BigNumber, + stakedOnNode: string +) => + formatNumberPercentage( + overstakedAmount.dividedBy(new BigNumber(stakedOnNode)).times(100), + 2 + ); + +export const getTotalPenalties = ( + rawValidatorScore: string | null | undefined, + performanceScore: string, + stakedOnNode: string, + totalStake: string +) => { + const calc = rawValidatorScore + ? new BigNumber(1).minus( + new BigNumber(performanceScore) + .times(new BigNumber(rawValidatorScore)) + .dividedBy( + new BigNumber(stakedOnNode).dividedBy(new BigNumber(totalStake)) + ) + ) + : new BigNumber(0); + + return formatNumberPercentage( + calc.isPositive() ? calc.times(100) : new BigNumber(0), + 2 + ); }; diff --git a/libs/ui-toolkit/src/components/tooltip/tooltip.tsx b/libs/ui-toolkit/src/components/tooltip/tooltip.tsx index 2206be27b..990429210 100644 --- a/libs/ui-toolkit/src/components/tooltip/tooltip.tsx +++ b/libs/ui-toolkit/src/components/tooltip/tooltip.tsx @@ -5,7 +5,6 @@ import { Root, Trigger, Content, - Arrow, Portal, } from '@radix-ui/react-tooltip'; import type { ITooltipParams } from 'ag-grid-community'; @@ -41,14 +40,9 @@ export const Tooltip = ({ align={align} side={side} alignOffset={8} - className="max-w-sm bg-neutral-200 px-4 py-2 z-20 rounded text-sm break-word" + className="max-w-sm border border-neutral-600 bg-neutral-100 dark:bg-neutral-800 px-4 py-2 z-20 rounded text-sm text-black dark:text-white break-word" >
{description}
- )} @@ -60,7 +54,7 @@ export const Tooltip = ({ export const TooltipCellComponent = (props: ITooltipParams) => { return ( -

+

{props.value}

);