Skip to content

Commit 1ac779f

Browse files
feat(explorer): resolve candidate validators (#74)
# Description of change - Resolve candidate validators - Show candidates in all validators table - update legend status with 'inactive' type - update candidate view - update inactive view ## Links to any relevant issues fixes #59 ## How the change has been tested Describe the tests that you ran to verify your changes. Make sure to provide instructions for the maintainer as well as any relevant configurations. --------- Co-authored-by: Mario Sarcevic <[email protected]>
1 parent cc629e9 commit 1ac779f

26 files changed

Lines changed: 431 additions & 273 deletions

.changeset/orange-tools-matter.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@iota/apps-ui-kit': patch
3+
---
4+
5+
Add outlined type badge

apps/core/src/hooks/stake/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,5 @@ export * from './useStakeTxnInfo';
99
export * from './useNewStakeTransaction';
1010
export * from './useNewUnstakeTransaction';
1111
export * from './useGetInactiveValidator';
12+
export * from './useGetPendingValidator';
13+
export * from './useGetCandidateValidators';
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// Copyright (c) 2026 IOTA Stiftung
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { useIotaClient, useIotaClientQuery } from '@iota/dapp-kit';
5+
import { useQuery } from '@tanstack/react-query';
6+
import { normalizeIotaAddress } from '@iota/iota-sdk/utils';
7+
import { getValidatorsMetadata } from '../../utils';
8+
import type { IotaValidatorSummaryExtended } from '../../types';
9+
10+
interface CandidateValidatorsResult<T> {
11+
data: T;
12+
isPending: boolean;
13+
isLoading: boolean;
14+
isError: boolean;
15+
}
16+
17+
export function useGetCandidateValidators(
18+
validatorAddress: string,
19+
): CandidateValidatorsResult<(IotaValidatorSummaryExtended & { isCandidate: true }) | null>;
20+
export function useGetCandidateValidators(): CandidateValidatorsResult<
21+
(IotaValidatorSummaryExtended & { isCandidate: true })[]
22+
>;
23+
export function useGetCandidateValidators(validatorAddress?: string) {
24+
const iotaClient = useIotaClient();
25+
const { data: systemState } = useIotaClientQuery('getLatestIotaSystemState');
26+
const validatorCandidatesId = systemState?.validatorCandidatesId;
27+
const candidateValidatorsSize = Number(systemState?.validatorCandidatesSize ?? 0);
28+
const { data, isPending, isLoading, isError } = useQuery({
29+
queryKey: ['candidate-validators', validatorCandidatesId],
30+
async queryFn() {
31+
if (!validatorCandidatesId) {
32+
throw Error('Missing validatorCandidatesId');
33+
}
34+
const results = [];
35+
let cursor = null;
36+
do {
37+
const page = await iotaClient.getDynamicFields({
38+
parentId: normalizeIotaAddress(validatorCandidatesId),
39+
cursor,
40+
});
41+
results.push(...page.data);
42+
cursor = page.hasNextPage ? page.nextCursor : null;
43+
} while (cursor);
44+
45+
const allCandidates = await Promise.allSettled(
46+
results.map((entry) => getValidatorsMetadata(iotaClient, entry.objectId)),
47+
);
48+
const candidateValidators = allCandidates
49+
.filter(
50+
(r): r is PromiseFulfilledResult<IotaValidatorSummaryExtended | null> =>
51+
r.status === 'fulfilled',
52+
)
53+
.map((r) => r.value)
54+
.filter((v): v is IotaValidatorSummaryExtended => v !== null);
55+
return candidateValidators;
56+
},
57+
enabled: !!validatorCandidatesId && candidateValidatorsSize > 0,
58+
select(candidateValidators) {
59+
const candidates = candidateValidators.map((v) => ({
60+
...v,
61+
isCandidate: true as const,
62+
}));
63+
return validatorAddress
64+
? (candidates.find((v) => v.iotaAddress === validatorAddress) ?? null)
65+
: candidates;
66+
},
67+
});
68+
69+
return {
70+
data,
71+
isPending,
72+
isLoading,
73+
isError,
74+
};
75+
}

apps/core/src/hooks/stake/useGetInactiveValidator.ts

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,30 +4,40 @@
44
import { useQuery } from '@tanstack/react-query';
55
import { normalizeIotaAddress } from '@iota/iota-sdk/utils';
66
import { useIotaClient, useIotaClientQuery } from '@iota/dapp-kit';
7-
import { getInactiveValidatorsMetadata } from '../../utils';
7+
import { getValidatorsMetadata } from '../../utils';
8+
import type { IotaValidatorSummaryExtended } from '../../types';
89

910
export function useGetInactiveValidator(validatorAddress: string) {
1011
const iotaClient = useIotaClient();
11-
const { data } = useIotaClientQuery('getLatestIotaSystemState');
12-
const inactivePoolsId = data?.inactivePoolsId;
12+
const { data: systemState } = useIotaClientQuery('getLatestIotaSystemState');
13+
const inactivePoolsId = systemState?.inactivePoolsId;
14+
const inactivePoolsSize = Number(systemState?.inactivePoolsSize ?? 0);
1315
return useQuery({
1416
queryKey: ['inactive-validators', inactivePoolsId],
1517
async queryFn() {
1618
if (!inactivePoolsId) {
17-
throw Error('Missing params');
19+
throw Error('Missing inactivePoolsId');
1820
}
1921
const inactiveValidators = await iotaClient.getDynamicFields({
2022
parentId: normalizeIotaAddress(inactivePoolsId),
2123
});
22-
return Promise.all(
24+
25+
const allInactive = await Promise.allSettled(
2326
inactiveValidators.data.map((validator) =>
24-
getInactiveValidatorsMetadata(iotaClient, validator.objectId),
27+
getValidatorsMetadata(iotaClient, validator.objectId),
2528
),
2629
);
30+
return allInactive
31+
.filter(
32+
(r): r is PromiseFulfilledResult<IotaValidatorSummaryExtended | null> =>
33+
r.status === 'fulfilled',
34+
)
35+
.map((r) => r.value)
36+
.filter((v): v is IotaValidatorSummaryExtended => v !== null);
2737
},
2838
select(data) {
29-
return data.find((v) => v?.validatorAddress === validatorAddress) ?? null;
39+
return data.find((v) => v?.iotaAddress === validatorAddress) ?? null;
3040
},
31-
enabled: !!inactivePoolsId,
41+
enabled: !!inactivePoolsId && inactivePoolsSize > 0,
3242
});
3343
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// Copyright (c) 2026 IOTA Stiftung
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { useQuery } from '@tanstack/react-query';
5+
import { normalizeIotaAddress } from '@iota/iota-sdk/utils';
6+
import { useIotaClient, useIotaClientQuery } from '@iota/dapp-kit';
7+
import { getValidatorsMetadata } from '../../utils';
8+
import type { IotaValidatorSummaryExtended } from '../../types';
9+
10+
export function useGetPendingValidator(validatorAddress: string) {
11+
const iotaClient = useIotaClient();
12+
const { data: systemState } = useIotaClientQuery('getLatestIotaSystemState');
13+
const pendingActiveValidatorsId = systemState?.pendingActiveValidatorsId;
14+
const pendingActiveValidatorsSize = Number(systemState?.pendingActiveValidatorsSize ?? 0);
15+
16+
return useQuery({
17+
queryKey: ['pending-validators', pendingActiveValidatorsId],
18+
async queryFn() {
19+
if (!pendingActiveValidatorsId) {
20+
throw Error('Missing pendingActiveValidatorsId');
21+
}
22+
const pendingValidators = await iotaClient.getDynamicFields({
23+
parentId: normalizeIotaAddress(pendingActiveValidatorsId),
24+
});
25+
26+
const allPending = await Promise.allSettled(
27+
pendingValidators.data.map((validator) =>
28+
getValidatorsMetadata(iotaClient, validator.objectId),
29+
),
30+
);
31+
return allPending
32+
.filter(
33+
(r): r is PromiseFulfilledResult<IotaValidatorSummaryExtended | null> =>
34+
r.status === 'fulfilled',
35+
)
36+
.map((r) => r.value)
37+
.filter((v): v is IotaValidatorSummaryExtended => v !== null);
38+
},
39+
select(data) {
40+
return data.find((v) => v?.iotaAddress === validatorAddress) ?? null;
41+
},
42+
enabled: !!pendingActiveValidatorsId && pendingActiveValidatorsSize > 0,
43+
});
44+
}

apps/core/src/types/schema.ts

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -20,23 +20,3 @@ export const ValidatorSchema = z.object({
2020
}),
2121
}),
2222
});
23-
24-
// Schema for dynamic field object
25-
export const DynamicFieldObjectSchema = z.object({
26-
fields: z.object({
27-
value: z.object({
28-
fields: z.object({
29-
metadata: z.object({
30-
fields: z.object({
31-
image_url: z.string(),
32-
name: z.string(),
33-
description: z.string(),
34-
project_url: z.string(),
35-
protocol_pubkey_bytes: z.array(z.number()),
36-
iota_address: z.string(),
37-
}),
38-
}),
39-
}),
40-
}),
41-
}),
42-
});

apps/core/src/types/validators.ts

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,9 @@
11
// Copyright (c) 2025 IOTA Stiftung
22
// SPDX-License-Identifier: Apache-2.0
33

4-
export type InactiveValidatorData = {
5-
imageUrl: string;
6-
name: string;
7-
description: string;
8-
projectUrl: string;
9-
validatorPublicKey: string;
10-
validatorAddress: string;
11-
validatorStakingPoolId: string;
4+
import { type IotaValidatorSummary } from '@iota/iota-sdk/client';
5+
6+
export type IotaValidatorSummaryExtended = IotaValidatorSummary & {
7+
isPending?: boolean;
8+
isCandidate?: boolean;
129
};

apps/core/src/utils/stake/getInactiveValidatorsMetadata.ts

Lines changed: 0 additions & 50 deletions
This file was deleted.
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
// Copyright (c) 2026 IOTA Stiftung
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import type { IotaClient } from '@iota/iota-sdk/client';
5+
import { normalizeIotaAddress } from '@iota/iota-sdk/utils';
6+
import { ValidatorSchema } from '../../types';
7+
import type { IotaValidatorSummaryExtended } from '../../types';
8+
import { bytesToBase64, getMoveFields, MoveStructFields } from './helpers';
9+
10+
/**
11+
* Fetch the full validator metadata from a validator wrapper object.
12+
* Handles the Versioned unwrapping common to inactive, pending, and candidate validators.
13+
*/
14+
export async function getValidatorsMetadata(
15+
client: IotaClient,
16+
validatorObjectId: string,
17+
): Promise<IotaValidatorSummaryExtended | null> {
18+
const validatorObject = await client.getObject({
19+
id: normalizeIotaAddress(validatorObjectId),
20+
options: {
21+
showContent: true,
22+
},
23+
});
24+
const validator = ValidatorSchema.safeParse(validatorObject.data?.content);
25+
const validatorFieldId = validator.data?.fields.value.fields.inner.fields.id.id;
26+
if (!validatorFieldId) {
27+
return null;
28+
}
29+
const dynamicFields = await client.getDynamicFields({
30+
parentId: normalizeIotaAddress(validatorFieldId),
31+
limit: 1,
32+
});
33+
const dfObjectId = dynamicFields.data?.[0]?.objectId;
34+
if (!dfObjectId) {
35+
return null;
36+
}
37+
const dfObject = await client.getObject({
38+
id: normalizeIotaAddress(dfObjectId),
39+
options: {
40+
showContent: true,
41+
},
42+
});
43+
44+
const content = dfObject.data?.content;
45+
if (!content || content.dataType !== 'moveObject') {
46+
return null;
47+
}
48+
49+
const fields = getMoveFields(content);
50+
const value = fields.value as MoveStructFields;
51+
if (!value?.fields) {
52+
return null;
53+
}
54+
55+
const metadata = (value.fields.metadata as MoveStructFields)?.fields || {};
56+
const stakingPool = (value.fields.staking_pool as MoveStructFields)?.fields || {};
57+
const exchangeRates = (stakingPool.exchange_rates as MoveStructFields)?.fields || {};
58+
59+
return {
60+
authorityPubkeyBytes: bytesToBase64(metadata.authority_pubkey_bytes),
61+
commissionRate: String(value.fields.commission_rate),
62+
description: String(metadata.description),
63+
exchangeRatesId: (exchangeRates.id as { id: string })?.id,
64+
exchangeRatesSize: String(exchangeRates.size),
65+
gasPrice: String(value.fields.gas_price),
66+
imageUrl: String(metadata.image_url),
67+
iotaAddress: String(metadata.iota_address),
68+
name: String(metadata.name),
69+
netAddress: String(metadata.net_address),
70+
networkPubkeyBytes: bytesToBase64(metadata.network_pubkey_bytes),
71+
nextEpochCommissionRate: String(value.fields.next_epoch_commission_rate),
72+
nextEpochGasPrice: String(value.fields.next_epoch_gas_price),
73+
nextEpochStake: String(value.fields.next_epoch_stake),
74+
operationCapId: String(value.fields.operation_cap_id),
75+
p2pAddress: String(metadata.p2p_address),
76+
pendingPoolTokenWithdraw: String(stakingPool.pending_pool_token_withdraw),
77+
pendingStake: String(stakingPool.pending_stake),
78+
pendingTotalIotaWithdraw: String(stakingPool.pending_total_iota_withdraw),
79+
poolTokenBalance: String(stakingPool.pool_token_balance),
80+
primaryAddress: String(metadata.primary_address),
81+
projectUrl: String(metadata.project_url),
82+
proofOfPossessionBytes: bytesToBase64(metadata.proof_of_possession),
83+
protocolPubkeyBytes: bytesToBase64(metadata.protocol_pubkey_bytes),
84+
rewardsPool: String(stakingPool.rewards_pool),
85+
stakingPoolId: (stakingPool.id as { id: string })?.id,
86+
stakingPoolIotaBalance: String(stakingPool.iota_balance),
87+
votingPower: String(value.fields.voting_power),
88+
};
89+
}

0 commit comments

Comments
 (0)