Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/core/src/hooks/stake/useGetInactiveValidator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import { useQuery } from '@tanstack/react-query';
import { normalizeIotaAddress } from '@iota/iota-sdk/utils';
import { useIotaClient, useIotaClientQuery } from '@iota/dapp-kit';
import { getInactiveValidatorsMetadata } from '../../utils';
import { getValidatorsMetadata } from '../../utils';

export function useGetInactiveValidator(validatorAddress: string) {
const iotaClient = useIotaClient();
Expand All @@ -21,7 +21,7 @@ export function useGetInactiveValidator(validatorAddress: string) {
});
return Promise.all(
inactiveValidators.data.map((validator) =>
getInactiveValidatorsMetadata(iotaClient, validator.objectId),
getValidatorsMetadata(iotaClient, validator.objectId),
),
);
},
Expand Down
2 changes: 1 addition & 1 deletion apps/core/src/types/validators.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright (c) 2025 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0

export type InactiveValidatorData = {
export type ValidatorOverviewData = {
imageUrl: string;
name: string;
description: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@

import type { IotaClient } from '@iota/iota-sdk/client';
import { normalizeIotaAddress, toBase64 } from '@iota/iota-sdk/utils';
import { InactiveValidatorData, ValidatorSchema, DynamicFieldObjectSchema } from '../../types';
import { ValidatorOverviewData, ValidatorSchema, DynamicFieldObjectSchema } from '../../types';

export async function getInactiveValidatorsMetadata(
export async function getValidatorsMetadata(
client: IotaClient,
validatorObjectId: string,
): Promise<InactiveValidatorData | null> {
): Promise<ValidatorOverviewData | null> {
const validatorObject = await client.getObject({
id: normalizeIotaAddress(validatorObjectId),
options: {
Expand Down
2 changes: 1 addition & 1 deletion apps/core/src/utils/stake/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,5 @@ export * from './checkIfIsTimelockedStaking';
export * from './getUnstakeDetailsFromEvents';
export * from './getTransactionAmountForTimelocked';
export * from './getValidatorEffectiveCommission';
export * from './getInactiveValidatorsMetadata';
export * from './getValidatorsMetadata';
export * from './types';
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

import { ButtonSegment, SegmentedButton } from '@iota/apps-ui-kit';

export type ValidatorStatus = 'All' | 'Committee' | 'Active' | 'Pending' | 'At Risk';
export type ValidatorStatus = 'All' | 'Committee' | 'Active' | 'Pending' | 'Candidate' | 'At Risk';

interface ValidatorFiltersProps {
selectedStatus: ValidatorStatus;
Expand All @@ -13,6 +13,7 @@ interface ValidatorFiltersProps {
committee: number;
active: number;
pending: number;
candidate: number;
atRisk: number;
};
}
Expand All @@ -27,6 +28,7 @@ export function ValidatorFilters({
{ status: 'Committee', count: validatorCounts.committee },
{ status: 'Active', count: validatorCounts.active },
{ status: 'Pending', count: validatorCounts.pending },
{ status: 'Candidate', count: validatorCounts.candidate },
{ status: 'At Risk', count: validatorCounts.atRisk },
];

Expand Down
6 changes: 3 additions & 3 deletions apps/explorer/src/components/validator/ValidatorMeta.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,16 @@ import { Badge, BadgeType, KeyValueInfo, Panel } from '@iota/apps-ui-kit';
import { type IotaValidatorSummary } from '@iota/iota-sdk/client';
import { ArrowTopRight, IotaLogoMark } from '@iota/apps-ui-icons';
import { AddressLink } from '~/components/ui';
import type { ValidatorOverviewData } from '@iota/core/src/types';
import { ImageIcon, ImageIconSize, useIsValidatorCommitteeMember } from '@iota/core';
import type { InactiveValidatorData } from '@iota/core/src/types';
import { onCopySuccess } from '~/lib/utils';

type ValidatorMetaProps = {
validatorData: IotaValidatorSummary;
atRiskRemainingEpochs?: number | null;
};

export function InactiveValidators({
export function ValidatorOverview({
validatorData: {
imageUrl,
name,
Expand All @@ -25,7 +25,7 @@ export function InactiveValidators({
validatorStakingPoolId,
},
}: {
validatorData: InactiveValidatorData;
validatorData: ValidatorOverviewData;
}): JSX.Element {
return (
<div className="flex flex-col gap-y-md">
Expand Down
1 change: 1 addition & 0 deletions apps/explorer/src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,4 @@ export * from './useNormalizedMoveModule';
export * from './useSearch';
export * from './useVerifiedSourceCode';
export * from './useEndOfEpochTransactionFromCheckpoint';
export * from './useGetValidatorCandidates';
52 changes: 52 additions & 0 deletions apps/explorer/src/hooks/useGetValidatorCandidates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// Copyright (c) 2026 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0

import { useIotaClient, useIotaClientQuery } from '@iota/dapp-kit';
import { useQuery } from '@tanstack/react-query';
import { getValidatorCandidateObjects, sanitizeValidatorObjects } from '~/lib';
import type { IotaValidatorSummaryExtended } from '~/lib/types';

interface ValidatorCandidatesResult<T> {
data: T;
isPending: boolean;
isLoading: boolean;
isError: boolean;
}

export function useGetValidatorCandidates(
validatorAddress: string,
): ValidatorCandidatesResult<IotaValidatorSummaryExtended | null>;
export function useGetValidatorCandidates(): ValidatorCandidatesResult<
IotaValidatorSummaryExtended[]
>;
export function useGetValidatorCandidates(validatorAddress?: string) {
const iotaClient = useIotaClient();
const { data: systemState } = useIotaClientQuery('getLatestIotaSystemState');
const validatorCandidatesId = systemState?.validatorCandidatesId;

const { data, isPending, isLoading, isError } = useQuery({
queryKey: ['validator-candidate-objects', validatorCandidatesId, iotaClient],
async queryFn() {
if (!validatorCandidatesId) {
throw Error('Missing validatorCandidatesId');
}
return getValidatorCandidateObjects(iotaClient, validatorCandidatesId);
},
enabled: !!validatorCandidatesId,
select(candidateObjects) {
const allCandidates = sanitizeValidatorObjects(candidateObjects, {
isCandidate: true,
});
return validatorAddress
? (allCandidates.find((v) => v.iotaAddress === validatorAddress) ?? null)
: allCandidates;
},
});

return {
data,
isPending,
isLoading,
isError,
};
}
5 changes: 4 additions & 1 deletion apps/explorer/src/lib/types/validator.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,7 @@

import { type IotaValidatorSummary } from '@iota/iota-sdk/client';

export type IotaValidatorSummaryExtended = IotaValidatorSummary & { isPending?: boolean };
export type IotaValidatorSummaryExtended = IotaValidatorSummary & {
isPending?: boolean;
isCandidate?: boolean;
};
177 changes: 174 additions & 3 deletions apps/explorer/src/lib/ui/utils/generateValidatorsTableColumns.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,15 @@ export function generateValidatorsTableColumns({

return apyA - apyB;
},
cell({ getValue }) {
cell({ getValue, row }) {
const validator = row.original as IotaValidatorSummaryExtended;
if (validator.isCandidate || validator.isPending) {
return (
<TableCellBase>
<TableCellText>--</TableCellText>
</TableCellBase>
);
}
const iotaAddress = getValue<string>();
const { apy, isApyApproxZero } = rollingAverageApys?.[iotaAddress] ?? {
apy: null,
Expand Down Expand Up @@ -256,17 +264,42 @@ export function generateValidatorsTableColumns({
accessorKey: 'votingPower',
enableSorting: true,
sortingFn: sortByNumber,
cell({ getValue }) {
cell({ getValue, row }) {
const validator = row.original as IotaValidatorSummaryExtended;
const votingPower = getValue<string>();
const commission = Number(votingPower);
return (
<TableCellBase>
<TableCellText>
{votingPower ? Number(votingPower) / 100 + '%' : '--'}
{validator.isCandidate || validator.isPending || isNaN(commission)
? '--'
: `${commission / 100}%`}
</TableCellText>
</TableCellBase>
);
},
},
{
header: 'Next Epoch Stake',
accessorKey: 'nextEpochStake',
id: 'nextEpochStake',
enableSorting: true,
sortingFn: (rowA, rowB, columnId) =>
BigInt(rowA.getValue(columnId)) - BigInt(rowB.getValue(columnId)) > 0 ? 1 : -1,
cell({ getValue }) {
const nextEpochStake = getValue<string>();
const isValid = nextEpochStake && !isNaN(Number(nextEpochStake));
return (
<TableCellBase>
{isValid ? (
<StakeColumn stake={nextEpochStake} />
) : (
<TableCellText>--</TableCellText>
)}
</TableCellBase>
);
},
},
{
header: 'Last Epoch Rewards',
meta: {
Expand Down Expand Up @@ -297,6 +330,144 @@ export function generateValidatorsTableColumns({
);
},
},
{
header: 'Status',
accessorKey: 'status',
id: 'status',
enableSorting: true,
sortingFn: (rowA, rowB) => {
const { label: labelA } = determineRisk(committeeMembers, atRiskValidators, rowA);
const { label: labelB } = determineRisk(committeeMembers, atRiskValidators, rowB);
return sortByString(labelA, labelB);
},
cell({ row }) {
const { atRisk, label, isPending, isCandidate } = determineRisk(
committeeMembers,
atRiskValidators,
row,
);

if (isPending || isCandidate) {
return (
<TableCellBase>
<Badge type={BadgeType.Neutral} label={label} />
</TableCellBase>
);
}

return (
<TableCellBase>
<Badge
type={
atRisk === null
? BadgeType.Success
: atRisk > 1
? BadgeType.Warning
: BadgeType.Error
}
label={label}
/>
</TableCellBase>
);
},
},
{
header: 'Current Epoch Rewards',
accessorKey: 'isEarningCurrent',
id: 'isEarningCurrent',
enableSorting: true,
sortingFn: (rowA, rowB) => {
const isCommitteeMemberA = committeeMembers.some(
(address) => address === rowA.original.iotaAddress,
);
const isCommitteeMemberB = committeeMembers.some(
(address) => address === rowB.original.iotaAddress,
);
return sortByBoolean(isCommitteeMemberA, isCommitteeMemberB);
},
cell({ row }) {
const isCommitteeMember = committeeMembers.find(
(committeeMemberAddress) => committeeMemberAddress === row.original.iotaAddress,
);
const label = isCommitteeMember ? 'Earning' : 'Not Earning';
return (
<TableCellBase>
<Badge
type={isCommitteeMember ? BadgeType.PrimarySoft : BadgeType.Neutral}
label={label}
/>
</TableCellBase>
);
},
},
{
header: 'Next Epoch Rewards',
accessorKey: 'isEarningNext',
id: 'isEarningNext',
enableSorting: true,
sortingFn: (rowA, rowB) => {
const valA = rowA.original as IotaValidatorSummaryExtended;
const valB = rowB.original as IotaValidatorSummaryExtended;

// Candidates and pending validators never earn
if (valA.isCandidate || valA.isPending) {
return valB.isCandidate || valB.isPending ? 0 : -1;
}
if (valB.isCandidate || valB.isPending) {
return 1;
}

const { atRisk: atRiskA } = determineRisk(committeeMembers, atRiskValidators, rowA);
const { atRisk: atRiskB } = determineRisk(committeeMembers, atRiskValidators, rowB);

const isInTopStakersA = topValidators.some(
(v) => v.iotaAddress === rowA.original.iotaAddress,
);
const isInTopStakersB = topValidators.some(
(v) => v.iotaAddress === rowB.original.iotaAddress,
);

const isEarningNextA = (atRiskA === null || atRiskA > 1) && isInTopStakersA;
const isEarningNextB = (atRiskB === null || atRiskB > 1) && isInTopStakersB;

return sortByBoolean(isEarningNextA, isEarningNextB);
},
cell({ row }) {
const validator = row.original as IotaValidatorSummaryExtended;

// Candidates and pending validators are not part of the active set
// and cannot earn rewards in the next epoch.
if (validator.isCandidate || validator.isPending) {
return (
<TableCellBase>
<Badge type={BadgeType.Neutral} label="Not Earning" />
</TableCellBase>
);
}

const { atRisk } = determineRisk(committeeMembers, atRiskValidators, row);

const isInTopStakers = !!topValidators.find(
(v) => v.iotaAddress === row.original.iotaAddress,
);

// if its active validator, not at high risk,
// and is part of the top X stakers,
// it will generate rewards in the next epoch, otherwise not.
const isEarningNext = (atRisk === null || atRisk > 1) && isInTopStakers;

const label = isEarningNext ? 'Earning' : 'Not Earning';

return (
<TableCellBase>
<Badge
type={isEarningNext ? BadgeType.PrimarySoft : BadgeType.Neutral}
label={label}
/>
</TableCellBase>
);
},
},
];

if (includeColumns) {
Expand Down
Loading
Loading