diff --git a/packages/ui/src/app/pages/Validators/ValidatorList.stories.tsx b/packages/ui/src/app/pages/Validators/ValidatorList.stories.tsx new file mode 100644 index 0000000000..a12c0be257 --- /dev/null +++ b/packages/ui/src/app/pages/Validators/ValidatorList.stories.tsx @@ -0,0 +1,116 @@ +import { Meta, StoryObj } from '@storybook/react' + +import { joy } from '@/mocks/helpers' +import { MocksParameters } from '@/mocks/providers' + +import { ValidatorList } from './ValidatorList' + +type Args = object + +export default { + title: 'Pages/Validators/ValidatorList', + component: ValidatorList, + + parameters: { + mocks: (): MocksParameters => { + return { + chain: { + derive: { + staking: { + erasRewards: [ + { era: 688, eraReward: joy(0.123456) }, + { era: 689, eraReward: joy(0.123456) }, + { era: 690, eraReward: joy(0.123456) }, + { era: 691, eraReward: joy(0.123456) }, + { era: 692, eraReward: joy(0.123456) }, + { era: 693, eraReward: joy(0.123456) }, + { era: 694, eraReward: joy(0.123456) }, + { era: 695, eraReward: joy(0.123456) }, + { era: 696, eraReward: joy(0.123456) }, + { era: 697, eraReward: joy(0.123456) }, + { era: 698, eraReward: joy(0.123456) }, + { era: 699, eraReward: joy(0.123456) }, + { era: 700, eraReward: joy(0.123456) }, + ], + }, + }, + query: { + balances: { + totalIssuance: joy(1000000), + }, + timestamp: { now: Date.now() }, + session: { + validators: [ + 'j4RLnWh3DWgc9u4CMprqxfBhq3kthXhvZDmnpjEtETFVm446D', + 'j4RbTjvPyaufVVoxVGk5vEKHma1k7j5ZAQCaAL9qMKQWKAswW', + 'j4Rc8VUXGYAx7FNbVZBFU72rQw3GaCuG2AkrUQWnWTh5SpemP', + 'j4Rh1cHtZFAQYGh7Y8RZwoXbkAPtZN46FmuYpKNiR3P2Dc2oz', + 'j4RjraznxDKae1aGL2L2xzXPSf8qCjFbjuw9sPWkoiy1UqWCa', + 'j4RuqkJ2Xqf3NTVRYBUqgbatKVZ31mbK59fWnq4ZzfZvhbhbN', + 'j4RxTMa1QVucodYPfQGA2JrHxZP944dfJ8qdDDYKU4QbJCWNP', + 'j4Rxkb1w9yB6WXroB2npKjRJJxwxbD8JjSQwMZFB31cf5aZAJ', + 'j4RyLBbSUBvipuQLkjLyUGeFWEzmrnfYdpteDa2gYNoM13qEg', + 'j4S998Thq5kQHyurofh8QfHrcFN2c1T19gTdMGUVVx5EHKgky', + ], + }, + staking: { + activeEra: { + index: 700, + start: Date.now() - 5400000, + }, + counterForValidators: 12, + counterForNominators: 20, + erasRewardPoints: { + total: 18000, + individuals: { + j4RLnWh3DWgc9u4CMprqxfBhq3kthXhvZDmnpjEtETFVm446D: 180, + j4RbTjvPyaufVVoxVGk5vEKHma1k7j5ZAQCaAL9qMKQWKAswW: 200, + j4Rc8VUXGYAx7FNbVZBFU72rQw3GaCuG2AkrUQWnWTh5SpemP: 280, + j4Rh1cHtZFAQYGh7Y8RZwoXbkAPtZN46FmuYpKNiR3P2Dc2oz: 200, + j4RjraznxDKae1aGL2L2xzXPSf8qCjFbjuw9sPWkoiy1UqWCa: 160, + j4RuqkJ2Xqf3NTVRYBUqgbatKVZ31mbK59fWnq4ZzfZvhbhbN: 180, + j4RxTMa1QVucodYPfQGA2JrHxZP944dfJ8qdDDYKU4QbJCWNP: 140, + j4Rxkb1w9yB6WXroB2npKjRJJxwxbD8JjSQwMZFB31cf5aZAJ: 160, + j4RyLBbSUBvipuQLkjLyUGeFWEzmrnfYdpteDa2gYNoM13qEg: 160, + j4S998Thq5kQHyurofh8QfHrcFN2c1T19gTdMGUVVx5EHKgky: 220, + }, + }, + erasValidatorReward: joy(0.123456), + erasStakers: { + total: joy(0.1), + own: joy(0.0001), + others: [ + { who: 'j4WGdFxqTkyAgzJiTbEBeRseP12dPEvJgf2Wy9qkPa68XSP55', value: joy(0.2) }, + { who: 'j4UQEfPFnKwGuHytxs9YEouLnhnSNkPDgNm9tKeB7an3dRaiy', value: joy(0.2) }, + { who: 'j4WwTZ3fnkoXJw3D1vGVyymjaiLxM78TGyAAX41JRH8Kx6T2u', value: joy(0.2) }, + { who: 'j4WqZwj6KjB4DbxknxyJB1ZkeVrPRGmg6DUGw2YkuAy7jUERg', value: joy(0.2) }, + { who: 'j4WwTZ3fnkoXJw3D1vGVyymjaiLxM78TGyAAX41JRH8Kx6T2u', value: joy(0.2) }, + { who: 'j4Wo9377XBAvhmB35J4TkpJUHnUKmyccXhGtHCVvi6pPr9so8', value: joy(0.2) }, + { who: 'j4WwTZ3fnkoXJw3D1vGVyymjaiLxM78TGyAAX41JRH8Kx6T2u', value: joy(0.2) }, + { who: 'j4WfB3TD4tFgrJpCmUi8P3wPp3EocyC5At9ZM2YUpmKGJ1FWM', value: joy(0.2) }, + { who: 'j4WwTZ3fnkoXJw3D1vGVyymjaiLxM78TGyAAX41JRH8Kx6T2u', value: joy(0.2) }, + { who: 'j4T3XgRMUaZZL6GsMk6RXfBcjuMWxfSLnoATYkBTHh7xyjmoH', value: joy(0.2) }, + { who: 'j4W2bw7ggG69e9TZ77RP9mjem1GrbPwpbKYK7WdZiym77yzMJ', value: joy(0.2) }, + { who: 'j4UzoJUhDGpnsCWrmx9ojofwaT8KHz3azp8C1S49MSN6rYjim', value: joy(0.2) }, + { who: 'j4ShWRXxTG4K5Q5H7KXmdWN8HnaaLwppqM7GdiSwAy3eTLsJt', value: joy(0.2) }, + { who: 'j4SgrgDrzzGyfrxPe4ZgaKfByKyLo5SdsUXNfHzZJPh5R6f8q', value: joy(0.2) }, + { who: 'j4RLnWh3DWgc9u4CMprqxfBhq3kthXhvZDmnpjEtETFVm446D', value: joy(0.2) }, + { who: 'j4SgrgDrzzGyfrxPe4ZgaKfByKyLo5SdsUXNfHzZJPh5R6f8q', value: joy(0.2) }, + { who: 'j4RxTMa1QVucodYPfQGA2JrHxZP944dfJ8qdDDYKU4QbJCWNP', value: joy(0.2) }, + { who: 'j4Rxkb1w9yB6WXroB2npKjRJJxwxbD8JjSQwMZFB31cf5aZAJ', value: joy(0.2) }, + { who: 'j4RyLBbSUBvipuQLkjLyUGeFWEzmrnfYdpteDa2gYNoM13qEg', value: joy(0.2) }, + { who: 'j4S998Thq5kQHyurofh8QfHrcFN2c1T19gTdMGUVVx5EHKgky', value: joy(0.2) }, + ], + }, + erasTotalStake: joy(130_000), + }, + }, + }, + } + }, + }, +} satisfies Meta + +type Story = StoryObj + +export const Statistics: Story = {} diff --git a/packages/ui/src/app/pages/Validators/ValidatorList.tsx b/packages/ui/src/app/pages/Validators/ValidatorList.tsx new file mode 100644 index 0000000000..00d68f470e --- /dev/null +++ b/packages/ui/src/app/pages/Validators/ValidatorList.tsx @@ -0,0 +1,53 @@ +import React from 'react' + +import { PageLayout } from '@/app/components/PageLayout' +import { RowGapBlock } from '@/common/components/page/PageContent' +import { Statistics } from '@/common/components/statistics' +import { Era } from '@/validators/components/statistics/Era' +import { Rewards } from '@/validators/components/statistics/Rewards' +import { Staking } from '@/validators/components/statistics/Staking' +import { ValidatorsState } from '@/validators/components/statistics/ValidatorsState' +import { useStakingStatistics } from '@/validators/hooks/useStakingStatistics' + +export const ValidatorList = () => { + const { + eraStartedOn, + eraDuration, + now, + eraRewardPoints, + totalRewards, + lastRewards, + idealStaking, + currentStaking, + stakingPercentage, + activeValidatorsCount, + allValidatorsCount, + acitveNominatorsCount, + allNominatorsCount, + } = useStakingStatistics() + + return ( + + + + + + + + + } + main={<>} + /> + ) +} diff --git a/packages/ui/src/common/components/charts/PercentageChart.tsx b/packages/ui/src/common/components/charts/PercentageChart.tsx index 4a7bbdca05..805f6856c1 100644 --- a/packages/ui/src/common/components/charts/PercentageChart.tsx +++ b/packages/ui/src/common/components/charts/PercentageChart.tsx @@ -8,14 +8,15 @@ export interface PercentageChartProps { percentage: number isOnBlack?: boolean className?: string + small?: boolean } -export const PercentageChart = ({ percentage, className, isOnBlack }: PercentageChartProps) => { +export const PercentageChart = ({ percentage, className, isOnBlack, small }: PercentageChartProps) => { const innerPercentage = percentage <= 0 ? 0 : percentage return ( - + - {Math.min(innerPercentage, 100)}% + {Math.min(innerPercentage, 100)}% @@ -24,13 +25,13 @@ export const PercentageChart = ({ percentage, className, isOnBlack }: Percentage ) } -const PercentageChartContainer = styled.div<{ isOnBlack?: boolean }>` +const PercentageChartContainer = styled.div<{ isOnBlack?: boolean; small?: boolean }>` display: flex; position: relative; justify-content: center; align-items: center; - width: 44px; - height: 44px; + width: ${({ small }) => (small ? '24px' : '44px')}; + height: ${({ small }) => (small ? '24px' : '44px')}; padding: 1px; color: ${({ isOnBlack }) => (isOnBlack ? Colors.White : Colors.Black[900])}; overflow: hidden; @@ -46,7 +47,8 @@ const PercentageChartBorder = styled.div` border-radius: ${BorderRad.round}; ` -const PercentageChartText = styled(TextInlineSmall)` +const PercentageChartText = styled(TextInlineSmall)<{ small?: boolean }>` + ${({ small }) => (small ? 'font-size:9px;' : '')} color: inherit; font-weight: 700; text-align: center; diff --git a/packages/ui/src/common/components/statistics/StatisticHeader.tsx b/packages/ui/src/common/components/statistics/StatisticHeader.tsx index c213d459b0..3e8e74bc92 100644 --- a/packages/ui/src/common/components/statistics/StatisticHeader.tsx +++ b/packages/ui/src/common/components/statistics/StatisticHeader.tsx @@ -14,6 +14,7 @@ export interface StatisticHeaderProps { TooltipIcon?: React.ElementType counter?: number dotElement?: React.ReactNode + actionElement?: React.ReactNode } export const StatisticHeader = ({ @@ -24,6 +25,7 @@ export const StatisticHeader = ({ tooltipLinkURL, counter, dotElement, + actionElement, TooltipIcon = TooltipDefault, }: StatisticHeaderProps) => ( @@ -43,6 +45,7 @@ export const StatisticHeader = ({ )} {counter && {counter}} + {actionElement ?? null} ) diff --git a/packages/ui/src/common/components/statistics/Statistics.tsx b/packages/ui/src/common/components/statistics/Statistics.tsx index 704320bb82..5e09fe4b53 100644 --- a/packages/ui/src/common/components/statistics/Statistics.tsx +++ b/packages/ui/src/common/components/statistics/Statistics.tsx @@ -7,7 +7,7 @@ interface StatisticsLayoutProps { export const Statistics = styled.div` display: grid; - grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: ${({ gapSize }) => (gapSize === 's' ? '16px' : '24px')}; width: 100%; max-width: 100%; diff --git a/packages/ui/src/common/components/typography/TokenValue.tsx b/packages/ui/src/common/components/typography/TokenValue.tsx index 145ba7880e..e8a80509c9 100644 --- a/packages/ui/src/common/components/typography/TokenValue.tsx +++ b/packages/ui/src/common/components/typography/TokenValue.tsx @@ -17,9 +17,10 @@ interface ValueProps extends ValueSizingProps { value?: BN | null className?: string isLoading?: boolean + mjoy?: boolean } -export const TokenValue = React.memo(({ className, value, size, isLoading }: ValueProps) => { +export const TokenValue = React.memo(({ className, value, size, isLoading, mjoy }: ValueProps) => { if (isLoading) { return } @@ -29,9 +30,15 @@ export const TokenValue = React.memo(({ className, value, size, isLoading }: Val } return ( {formatJoyValue(value)}}> - - {formatJoyValue(value, { precision: 2 })} - + {mjoy ? ( + + {formatJoyValue(value.divn(Math.pow(10, 6)), { precision: 2 })} + + ) : ( + + {formatJoyValue(value, { precision: 2 })} + + )} ) }) @@ -89,3 +96,8 @@ export const ValueInJoys = styled(JOYSuffix)` } }} ` +export const ValueInMJoys = styled(ValueInJoys)` + &:after { + content: 'M${CurrencyName.integerValue}'; + } +` diff --git a/packages/ui/src/common/constants/numbers.ts b/packages/ui/src/common/constants/numbers.ts index 676770c221..d1976feedd 100644 --- a/packages/ui/src/common/constants/numbers.ts +++ b/packages/ui/src/common/constants/numbers.ts @@ -5,3 +5,4 @@ export const JOY_DECIMAL_PLACES = 10 export const ED = new BN(10) export const BN_ZERO = new BN(0) export const SECONDS_PER_BLOCK = 6 +export const ERA_DURATION = 21600000 diff --git a/packages/ui/src/mocks/helpers/asChainData.ts b/packages/ui/src/mocks/helpers/asChainData.ts index 590ac4c9e9..a5bcc2780b 100644 --- a/packages/ui/src/mocks/helpers/asChainData.ts +++ b/packages/ui/src/mocks/helpers/asChainData.ts @@ -1,13 +1,10 @@ import { createType } from '@joystream/types' import { mapValues } from 'lodash' -import { isDefined } from '@/common/utils' - export const asChainData = (data: any): any => { - const type = isDefined(data) ? Object.getPrototypeOf(data).constructor.name : typeof data - switch (type) { + switch (Object.getPrototypeOf(data).constructor.name) { case 'Object': - return mapValues(data, asChainData) + return withUnwrap(mapValues(data, asChainData)) case 'Array': return data.map(asChainData) @@ -22,3 +19,5 @@ export const asChainData = (data: any): any => { return data } } + +const withUnwrap = (data: Record) => Object.defineProperty(data, 'unwrap', { value: () => data }) diff --git a/packages/ui/src/validators/components/statistics/Era.tsx b/packages/ui/src/validators/components/statistics/Era.tsx new file mode 100644 index 0000000000..f27841d7a3 --- /dev/null +++ b/packages/ui/src/validators/components/statistics/Era.tsx @@ -0,0 +1,62 @@ +import { Option, u64 } from '@polkadot/types' +import { PalletStakingEraRewardPoints } from '@polkadot/types/lookup' +import React, { useMemo } from 'react' + +import { PercentageChart } from '@/common/components/charts/PercentageChart' +import { BlockIcon } from '@/common/components/icons' +import { + NumericValue, + StatisticItem, + StatisticItemSpacedContent, + StatisticLabel, + formatDurationDate, +} from '@/common/components/statistics' +import { DurationValue } from '@/common/components/typography/DurationValue' + +interface EraProps { + eraStartedOn: Option | undefined + eraDuration: number + now: u64 | undefined + eraRewardPoints: PalletStakingEraRewardPoints | undefined +} + +export const Era = ({ eraStartedOn, eraDuration, now, eraRewardPoints }: EraProps) => { + const { nextReward, percentage } = useMemo(() => { + const nextReward = now && eraStartedOn && eraDuration - (Number(now) - Number(eraStartedOn)) + const totalDuration = Number(eraDuration) + const percentage = nextReward ? Math.ceil(100 - (nextReward / totalDuration) * 100) : 0 + return { + nextReward: formatDurationDate(nextReward ?? 0), + totalDuration: formatDurationDate(totalDuration ?? 0), + percentage, + } + }, [eraStartedOn, eraDuration, now]) + return ( + } + > + + Next Reward +
+ +
+
+ + Blocks / Points +
+ {eraRewardPoints && ( + + + {eraRewardPoints.total.toNumber() / 20} / {eraRewardPoints?.total.toNumber()} + + )} +
+
+
+ ) +} diff --git a/packages/ui/src/validators/components/statistics/Rewards.tsx b/packages/ui/src/validators/components/statistics/Rewards.tsx new file mode 100644 index 0000000000..7e66d5b6dc --- /dev/null +++ b/packages/ui/src/validators/components/statistics/Rewards.tsx @@ -0,0 +1,31 @@ +import BN from 'bn.js' +import React from 'react' + +import { StatisticItem, StatisticItemSpacedContent, StatisticLabel } from '@/common/components/statistics' +import { TokenValue } from '@/common/components/typography' + +interface RewardsProps { + totalRewards: BN | undefined + lastRewards: BN | undefined +} + +export const Rewards = ({ totalRewards, lastRewards }: RewardsProps) => { + return ( + + + Month + + + + Last + + + + ) +} diff --git a/packages/ui/src/validators/components/statistics/Staking.tsx b/packages/ui/src/validators/components/statistics/Staking.tsx new file mode 100644 index 0000000000..319a042c3b --- /dev/null +++ b/packages/ui/src/validators/components/statistics/Staking.tsx @@ -0,0 +1,39 @@ +import { BN } from '@polkadot/util' +import React from 'react' + +import { StatisticItem, StatisticItemSpacedContent, StatisticLabel } from '@/common/components/statistics' +import { TokenValue } from '@/common/components/typography' +import { Colors } from '@/common/constants' + +interface StakingProps { + idealStaking: BN + currentStaking: BN + stakingPercentage: number +} + +export const Staking = ({ idealStaking, currentStaking, stakingPercentage }: StakingProps) => { + const Percentage = ( + + Percentage {stakingPercentage} % + + ) + return ( + + + Ideal + + + + Current + + + + ) +} diff --git a/packages/ui/src/validators/components/statistics/ValidatorsState.tsx b/packages/ui/src/validators/components/statistics/ValidatorsState.tsx new file mode 100644 index 0000000000..6bf3c35a26 --- /dev/null +++ b/packages/ui/src/validators/components/statistics/ValidatorsState.tsx @@ -0,0 +1,40 @@ +import React from 'react' + +import { NumericValue, StatisticItem, StatisticItemSpacedContent, StatisticLabel } from '@/common/components/statistics' + +interface ValidatorsStateProps { + activeValidatorsCount: number + allValidatorsCount: number + acitveNominatorsCount: number + allNominatorsCount: number +} + +export const ValidatorsState = ({ + activeValidatorsCount, + allValidatorsCount, + acitveNominatorsCount, + allNominatorsCount, +}: ValidatorsStateProps) => { + return ( + + + Validator (Active / Waiting) + + {activeValidatorsCount} / {allValidatorsCount - activeValidatorsCount} + + + + Nominator (Active / Total) + + {acitveNominatorsCount} / {allNominatorsCount} + + + + ) +} diff --git a/packages/ui/src/validators/hooks/useStakingStatistics.tsx b/packages/ui/src/validators/hooks/useStakingStatistics.tsx new file mode 100644 index 0000000000..c10e0b45aa --- /dev/null +++ b/packages/ui/src/validators/hooks/useStakingStatistics.tsx @@ -0,0 +1,72 @@ +import { BN } from '@polkadot/util' +import { useMemo } from 'react' +import { combineLatest, map } from 'rxjs' + +import { useApi } from '@/api/hooks/useApi' +import { ERA_DURATION } from '@/common/constants' +import { useObservable } from '@/common/hooks/useObservable' + +export const useStakingStatistics = () => { + const { api } = useApi() + const activeEra = useObservable( + () => + api?.query.staking.activeEra().pipe( + map((activeEra) => ({ + eraIndex: activeEra?.unwrap().index, + eraStartedOn: activeEra?.unwrap().start, + })) + ), + [api?.isConnected] + ) + + const now = useObservable(() => api?.query.timestamp.now(), [api?.isConnected]) + const totalIssuance = useObservable(() => api?.query.balances.totalIssuance(), [api?.isConnected]) + const currentStaking = useObservable( + () => activeEra && api && api.query.staking.erasTotalStake(activeEra.eraIndex), + [activeEra, api?.isConnected] + ) + const activeValidators = useObservable(() => api?.query.session.validators(), [api?.isConnected]) + const stakers = useObservable( + () => + activeValidators && + api && + activeEra && + combineLatest(activeValidators.map((address) => api.query.staking.erasStakers(activeEra.eraIndex, address))), + [api?.isConnected, activeValidators, activeEra] + ) + const acitveNominators = useMemo(() => { + const nominators = stakers?.map((validator) => validator.others.map((nominator) => nominator.who.toString())) + const uniqueNominators = [...new Set(nominators?.flat())] + return uniqueNominators + }, [stakers]) + const allValidatorsCount = useObservable(() => api?.query.staking.counterForValidators(), [api?.isConnected]) + const allNominatorsCount = useObservable(() => api?.query.staking.counterForNominators(), [api?.isConnected]) + const lastValidatorRewards = useObservable( + () => activeEra && api && api.query.staking.erasValidatorReward(activeEra.eraIndex.subn(1)), + [activeEra, api?.isConnected] + ) + const totalRewards = useObservable(() => api?.derive.staking.erasRewards(), [api?.isConnected]) + const stakingPercentage = useMemo( + () => (totalIssuance && currentStaking ? currentStaking.muln(1000).div(totalIssuance).toNumber() / 10 : 0), + [currentStaking, totalIssuance] + ) + const eraRewardPoints = useObservable( + () => activeEra && api && api.query.staking.erasRewardPoints(activeEra.eraIndex), + [activeEra, api?.isConnected] + ) + return { + eraStartedOn: activeEra?.eraStartedOn, + eraDuration: ERA_DURATION, + eraRewardPoints, + now, + idealStaking: new BN(totalIssuance ?? 0).divn(2), + currentStaking: new BN(currentStaking ?? 0), + stakingPercentage, + activeValidatorsCount: activeValidators?.length ?? 0, + acitveNominatorsCount: acitveNominators.length, + allValidatorsCount: allValidatorsCount?.toNumber() ?? 0, + allNominatorsCount: allNominatorsCount?.toNumber() ?? 0, + totalRewards: totalRewards?.reduce((total: BN, reward) => total.add(reward.eraReward), new BN(0)), + lastRewards: new BN(lastValidatorRewards?.toString() ?? 0), + } +}