From d0d0f6d5020e2bab6efeb9dc3827eb0d54d67157 Mon Sep 17 00:00:00 2001 From: evavirseda Date: Wed, 26 Feb 2025 18:20:36 +0100 Subject: [PATCH] feat(explorer): add validators joining next epoch (#5269) * first steps * feat: add hook * feat: see logs * cleanup * fix import * fix format * minor fixes * fix foramt * fix types * resolve comments * fix import * fix: show correct information * cleanup * concat pending validators * fix lint * feat: reorder two lines * feat: move to types folder * fix lint * fix build --------- Co-authored-by: Marc Espin --- apps/core/src/hooks/useMultiGetObjects.ts | 1 - apps/explorer/src/lib/index.ts | 1 + apps/explorer/src/lib/types/index.ts | 1 + .../explorer/src/lib/types/validator.types.ts | 3 + .../utils/generateValidatorsTableColumns.tsx | 72 ++++++++++----- apps/explorer/src/lib/utils/index.ts | 1 + .../lib/utils/sanitizePendingValidators.ts | 88 +++++++++++++++++++ .../src/pages/validators/Validators.tsx | 40 ++++++++- 8 files changed, 181 insertions(+), 26 deletions(-) create mode 100644 apps/explorer/src/lib/types/index.ts create mode 100644 apps/explorer/src/lib/types/validator.types.ts create mode 100644 apps/explorer/src/lib/utils/sanitizePendingValidators.ts diff --git a/apps/core/src/hooks/useMultiGetObjects.ts b/apps/core/src/hooks/useMultiGetObjects.ts index 25e5d63f9e6..61bb7248933 100644 --- a/apps/core/src/hooks/useMultiGetObjects.ts +++ b/apps/core/src/hooks/useMultiGetObjects.ts @@ -5,7 +5,6 @@ import { useIotaClient } from '@iota/dapp-kit'; import { IotaObjectDataOptions, IotaObjectResponse } from '@iota/iota-sdk/client'; import { useQuery, UseQueryOptions } from '@tanstack/react-query'; - import { chunkArray } from '../utils/chunkArray'; export function useMultiGetObjects( diff --git a/apps/explorer/src/lib/index.ts b/apps/explorer/src/lib/index.ts index 85dd3303d09..eeb42c0bb33 100644 --- a/apps/explorer/src/lib/index.ts +++ b/apps/explorer/src/lib/index.ts @@ -4,3 +4,4 @@ export * from './constants'; export * from './enums'; export * from './utils'; +export * from './types'; diff --git a/apps/explorer/src/lib/types/index.ts b/apps/explorer/src/lib/types/index.ts new file mode 100644 index 00000000000..7e515719ed7 --- /dev/null +++ b/apps/explorer/src/lib/types/index.ts @@ -0,0 +1 @@ +export * from './validator.types'; diff --git a/apps/explorer/src/lib/types/validator.types.ts b/apps/explorer/src/lib/types/validator.types.ts new file mode 100644 index 00000000000..ae467301fc9 --- /dev/null +++ b/apps/explorer/src/lib/types/validator.types.ts @@ -0,0 +1,3 @@ +import { type IotaValidatorSummary } from '@iota/iota-sdk/client'; + +export type IotaValidatorSummaryExtended = IotaValidatorSummary & { isPending?: boolean }; diff --git a/apps/explorer/src/lib/ui/utils/generateValidatorsTableColumns.tsx b/apps/explorer/src/lib/ui/utils/generateValidatorsTableColumns.tsx index 36c7178d872..5271f4c856a 100644 --- a/apps/explorer/src/lib/ui/utils/generateValidatorsTableColumns.tsx +++ b/apps/explorer/src/lib/ui/utils/generateValidatorsTableColumns.tsx @@ -4,7 +4,12 @@ import { Badge, BadgeType, TableCellBase, TableCellText } from '@iota/apps-ui-kit'; import type { ColumnDef, Row } from '@tanstack/react-table'; import { type ApyByValidator, formatPercentageDisplay, ImageIcon, ImageIconSize } from '@iota/core'; -import { ampli, getValidatorMoveEvent, VALIDATOR_LOW_STAKE_GRACE_PERIOD } from '~/lib'; +import { + ampli, + getValidatorMoveEvent, + type IotaValidatorSummaryExtended, + VALIDATOR_LOW_STAKE_GRACE_PERIOD, +} from '~/lib'; import { StakeColumn } from '~/components'; import type { IotaEvent, IotaValidatorSummary } from '@iota/iota-sdk/client'; import clsx from 'clsx'; @@ -24,10 +29,29 @@ function ValidatorWithImage({ validator, highlightValidatorName, }: { - validator: IotaValidatorSummary; + validator: IotaValidatorSummaryExtended; highlightValidatorName?: boolean; }) { - return ( + return validator.isPending ? ( +
+
+ +
+ + {validator.name} + +
+ ) : ( @@ -68,8 +92,8 @@ export function generateValidatorsTableColumns({ showValidatorIcon = true, includeColumns, highlightValidatorName, -}: generateValidatorsTableColumnsArgs): ColumnDef[] { - let columns: ColumnDef[] = [ +}: generateValidatorsTableColumnsArgs): ColumnDef[] { + let columns: ColumnDef[] = [ { header: '#', id: 'number', @@ -238,8 +262,15 @@ export function generateValidatorsTableColumns({ return sortByString(labelA, labelB); }, cell({ row }) { - const { atRisk, label } = determineRisk(atRiskValidators, row); + const { atRisk, label, isPending } = determineRisk(atRiskValidators, row); + if (isPending) { + return ( + + + + ); + } return ( , rowB: Row, @@ -272,34 +301,35 @@ function sortByNumber( ) { return Number(rowA.getValue(columnId)) - Number(rowB.getValue(columnId)) > 0 ? 1 : -1; } - function getLastReward( validatorEvents: IotaEvent[], - row: Row, + row: Row, ): number | null { const { original: validator } = row; const event = getValidatorMoveEvent(validatorEvents, validator.iotaAddress) as { pool_staking_reward?: string; }; - return event?.pool_staking_reward ? Number(event.pool_staking_reward) : null; } - -function determineRisk(atRiskValidators: [string, string][], row: Row) { +function determineRisk( + atRiskValidators: [string, string][], + row: Row, +) { const { original: validator } = row; const atRiskValidator = atRiskValidators.find(([address]) => address === validator.iotaAddress); const isAtRisk = !!atRiskValidator; const atRisk = isAtRisk ? VALIDATOR_LOW_STAKE_GRACE_PERIOD - Number(atRiskValidator[1]) : null; - - const label = - atRisk === null - ? 'Active' - : atRisk > 1 - ? `At Risk in ${atRisk} epochs` - : 'At Risk next epoch'; - + const isPending = validator.isPending; + const label = isPending + ? 'Pending' + : atRisk === null + ? 'Active' + : atRisk > 1 + ? `At Risk in ${atRisk} epochs` + : 'At Risk next epoch'; return { label, atRisk, + isPending, }; } diff --git a/apps/explorer/src/lib/utils/index.ts b/apps/explorer/src/lib/utils/index.ts index 74620bc993b..1dcfe6cc718 100644 --- a/apps/explorer/src/lib/utils/index.ts +++ b/apps/explorer/src/lib/utils/index.ts @@ -14,3 +14,4 @@ export * from './sentry'; export * from './stringUtils'; export * from './iotaMoveTypeConverters'; export * from './getSupplyChangeAfterEpochEnd'; +export * from './sanitizePendingValidators'; diff --git a/apps/explorer/src/lib/utils/sanitizePendingValidators.ts b/apps/explorer/src/lib/utils/sanitizePendingValidators.ts new file mode 100644 index 00000000000..0bded56d548 --- /dev/null +++ b/apps/explorer/src/lib/utils/sanitizePendingValidators.ts @@ -0,0 +1,88 @@ +// Copyright (c) 2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +import type { IotaObjectResponse, MoveStruct, MoveValue } from '@iota/iota-sdk/client'; +import type { IotaValidatorSummaryExtended } from '../types'; + +function isMoveStructWithFields( + data: MoveStruct, +): data is { fields: { [key: string]: MoveValue }; type: string } { + return ( + typeof data === 'object' && + data !== null && + 'fields' in data && + typeof data.fields === 'object' && + data.fields !== null + ); +} + +function getMoveFields(object: MoveStruct): { [key: string]: MoveValue } { + if (isMoveStructWithFields(object)) { + return object.fields as { [key: string]: MoveValue }; + } + return {}; +} + +interface MoveStructFields { + fields: { [key: string]: MoveValue }; +} + +export function sanitizePendingValidators( + allPendings: IotaObjectResponse[] | undefined, +): IotaValidatorSummaryExtended[] { + return ( + allPendings?.map(({ data }) => { + const fields = + (data && + data.content && + data.content.dataType === 'moveObject' && + getMoveFields(data.content)) || + {} || + {}; + const value = fields.value as MoveStructFields; + const metadata = (value?.fields?.metadata as MoveStructFields)?.fields || {}; + const stakingPool = (value?.fields?.staking_pool as MoveStructFields)?.fields || {}; + const exchangeRates = (stakingPool.exchange_rates as MoveStructFields)?.fields || {}; + + return { + isPending: true, + authorityPubkeyBytes: '', + commissionRate: String(value?.fields.commission_rate), + description: String(metadata.description), + exchangeRatesId: ( + exchangeRates.id as { + id: string; + } + )?.id, + exchangeRatesSize: String(exchangeRates.size), + gasPrice: String(value?.fields.gas_price), + imageUrl: String(metadata.image_url), + iotaAddress: String(metadata.iota_address), + name: String(metadata.name), + netAddress: String(metadata.net_address), + networkPubkeyBytes: '', + nextEpochCommissionRate: String(value?.fields.next_epoch_commission_rate), + nextEpochGasPrice: String(value?.fields.next_epoch_gas_price), + nextEpochStake: String(value?.fields.next_epoch_stake), + operationCapId: String(value?.fields.operation_cap_id), + p2pAddress: String(metadata.p2p_address), + pendingPoolTokenWithdraw: String(stakingPool.pending_pool_token_withdraw), + pendingStake: String(stakingPool.pending_stake), + pendingTotalIotaWithdraw: String(stakingPool.pending_total_iota_withdraw), + poolTokenBalance: String(stakingPool.pool_token_balance), + primaryAddress: String(metadata.primary_address), + projectUrl: String(metadata.project_url), + proofOfPossessionBytes: '', + protocolPubkeyBytes: '', + rewardsPool: String(stakingPool.rewards_pool), + stakingPoolId: ( + stakingPool.id as { + id: string; + } + )?.id, + stakingPoolIotaBalance: String(stakingPool.iota_balance), + votingPower: String(value?.fields.voting_power), + }; + }) || [] + ); +} diff --git a/apps/explorer/src/pages/validators/Validators.tsx b/apps/explorer/src/pages/validators/Validators.tsx index e4c08ab9e52..13112c5dfd0 100644 --- a/apps/explorer/src/pages/validators/Validators.tsx +++ b/apps/explorer/src/pages/validators/Validators.tsx @@ -3,7 +3,14 @@ // SPDX-License-Identifier: Apache-2.0 import { type JSX, useMemo } from 'react'; -import { roundFloat, useFormatCoin, useGetValidatorsApy, useGetValidatorsEvents } from '@iota/core'; +import { + roundFloat, + useFormatCoin, + useGetDynamicFields, + useGetValidatorsApy, + useGetValidatorsEvents, + useMultiGetObjects, +} from '@iota/core'; import { DisplayStats, DisplayStatsSize, @@ -21,10 +28,13 @@ import { generateValidatorsTableColumns } from '~/lib/ui'; import { Warning } from '@iota/apps-ui-icons'; import { useQuery } from '@tanstack/react-query'; import { useEnhancedRpcClient } from '~/hooks'; +import { sanitizePendingValidators } from '~/lib'; +import { normalizeIotaAddress } from '@iota/iota-sdk/utils'; function ValidatorPageResult(): JSX.Element { const { data, isPending, isSuccess, isError } = useIotaClientQuery('getLatestIotaSystemState'); - const numberOfValidators = data?.activeValidators.length || 0; + const activeValidatorsData = data?.activeValidators; + const numberOfValidators = activeValidatorsData?.length || 0; const { data: validatorEvents, @@ -35,6 +45,20 @@ function ValidatorPageResult(): JSX.Element { order: 'descending', }); + const { data: pendingActiveValidatorsId } = useGetDynamicFields( + data?.pendingActiveValidatorsId || '', + ); + const pendingValidatorsObjectIdsData = pendingActiveValidatorsId?.pages[0]?.data || []; + const pendingValidatorsObjectIds = pendingValidatorsObjectIdsData.map((item) => item.objectId); + const normalizedIds = pendingValidatorsObjectIds.map((id) => normalizeIotaAddress(id)); + + const { data: pendingValidatorsData } = useMultiGetObjects(normalizedIds, { + showDisplay: true, + showContent: true, + }); + + const sanitizePendingValidatorsData = sanitizePendingValidators(pendingValidatorsData); + const { data: validatorsApy } = useGetValidatorsApy(); const totalStaked = useMemo(() => { @@ -81,6 +105,14 @@ function ValidatorPageResult(): JSX.Element { const lastEpochRewardOnAllValidators = epochData?.data[0].endOfEpochInfo?.totalStakeRewardsDistributed; + const sortedValidators = activeValidatorsData?.sort(() => 0.5 - Math.random()); + + const tableData = data + ? Number(data.pendingActiveValidatorsSize) > 0 + ? sortedValidators?.concat(sanitizePendingValidatorsData) + : sortedValidators + : []; + const tableColumns = useMemo(() => { if (!data || !validatorEvents) return null; return generateValidatorsTableColumns({ @@ -174,13 +206,13 @@ function ValidatorPageResult(): JSX.Element { colHeadings={['Name', 'Address', 'Stake']} /> )} - {isSuccess && data.activeValidators && tableColumns && ( + {isSuccess && tableData && tableColumns && (