Skip to content

Commit

Permalink
feat(explorer): add validators joining next epoch (#5269)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
  • Loading branch information
evavirseda and marc2332 authored Feb 26, 2025
1 parent 0072f5b commit d0d0f6d
Show file tree
Hide file tree
Showing 8 changed files with 181 additions and 26 deletions.
1 change: 0 additions & 1 deletion apps/core/src/hooks/useMultiGetObjects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions apps/explorer/src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@
export * from './constants';
export * from './enums';
export * from './utils';
export * from './types';
1 change: 1 addition & 0 deletions apps/explorer/src/lib/types/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './validator.types';
3 changes: 3 additions & 0 deletions apps/explorer/src/lib/types/validator.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { type IotaValidatorSummary } from '@iota/iota-sdk/client';

export type IotaValidatorSummaryExtended = IotaValidatorSummary & { isPending?: boolean };
72 changes: 51 additions & 21 deletions apps/explorer/src/lib/ui/utils/generateValidatorsTableColumns.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -24,10 +29,29 @@ function ValidatorWithImage({
validator,
highlightValidatorName,
}: {
validator: IotaValidatorSummary;
validator: IotaValidatorSummaryExtended;
highlightValidatorName?: boolean;
}) {
return (
return validator.isPending ? (
<div className="flex items-center gap-x-2.5 text-neutral-40 dark:text-neutral-60">
<div className="h-8 w-8 shrink-0">
<ImageIcon
src={validator.imageUrl}
label={validator.name}
fallback={validator.name}
size={ImageIconSize.Medium}
rounded
/>
</div>
<span
className={clsx('text-label-lg', {
'text-neutral-10 dark:text-neutral-92': highlightValidatorName,
})}
>
{validator.name}
</span>
</div>
) : (
<ValidatorLink
address={validator.iotaAddress}
onClick={() =>
Expand Down Expand Up @@ -68,8 +92,8 @@ export function generateValidatorsTableColumns({
showValidatorIcon = true,
includeColumns,
highlightValidatorName,
}: generateValidatorsTableColumnsArgs): ColumnDef<IotaValidatorSummary>[] {
let columns: ColumnDef<IotaValidatorSummary>[] = [
}: generateValidatorsTableColumnsArgs): ColumnDef<IotaValidatorSummaryExtended>[] {
let columns: ColumnDef<IotaValidatorSummaryExtended>[] = [
{
header: '#',
id: 'number',
Expand Down Expand Up @@ -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 (
<TableCellBase>
<Badge type={BadgeType.Neutral} label={label} />
</TableCellBase>
);
}
return (
<TableCellBase>
<Badge
Expand All @@ -260,46 +291,45 @@ export function generateValidatorsTableColumns({

return columns;
}

function sortByString(value1: string, value2: string) {
return value1.localeCompare(value2, undefined, { sensitivity: 'base' });
}

function sortByNumber(
rowA: Row<IotaValidatorSummary>,
rowB: Row<IotaValidatorSummary>,
columnId: string,
) {
return Number(rowA.getValue(columnId)) - Number(rowB.getValue(columnId)) > 0 ? 1 : -1;
}

function getLastReward(
validatorEvents: IotaEvent[],
row: Row<IotaValidatorSummary>,
row: Row<IotaValidatorSummaryExtended>,
): 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<IotaValidatorSummary>) {
function determineRisk(
atRiskValidators: [string, string][],
row: Row<IotaValidatorSummaryExtended>,
) {
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,
};
}
1 change: 1 addition & 0 deletions apps/explorer/src/lib/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@ export * from './sentry';
export * from './stringUtils';
export * from './iotaMoveTypeConverters';
export * from './getSupplyChangeAfterEpochEnd';
export * from './sanitizePendingValidators';
88 changes: 88 additions & 0 deletions apps/explorer/src/lib/utils/sanitizePendingValidators.ts
Original file line number Diff line number Diff line change
@@ -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),
};
}) || []
);
}
40 changes: 36 additions & 4 deletions apps/explorer/src/pages/validators/Validators.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -174,13 +206,13 @@ function ValidatorPageResult(): JSX.Element {
colHeadings={['Name', 'Address', 'Stake']}
/>
)}
{isSuccess && data.activeValidators && tableColumns && (
{isSuccess && tableData && tableColumns && (
<TableCard
sortTable
defaultSorting={[
{ id: 'stakingPoolIotaBalance', desc: true },
]}
data={data.activeValidators}
data={tableData}
columns={tableColumns}
areHeadersCentered={false}
/>
Expand Down

0 comments on commit d0d0f6d

Please sign in to comment.