Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
7993def
feat(explorer): add staking history table to validator detail page
Juligs Jul 27, 2026
e19f583
Merge branch 'develop' into feat/add-staking-history-to-validators-page
Juligs Jul 27, 2026
78c512e
Merge remote-tracking branch 'origin/develop' into feat/add-staking-h…
Juligs Jul 27, 2026
4d4cb7c
Merge remote-tracking branch 'origin/feat/add-staking-history-to-vali…
Juligs Jul 27, 2026
663fa00
chore(explorer): apply suggestions
Juligs Jul 28, 2026
00a3072
Merge branch 'develop' into feat/add-staking-history-to-validators-page
VmMad Jul 28, 2026
616b76d
chore(explorer): improve icon validator to address column
Juligs Jul 28, 2026
cfc2d84
Merge branch 'develop' into feat/add-staking-history-to-validators-page
Juligs Jul 28, 2026
60da458
Merge branch 'develop' into feat/add-staking-history-to-validators-page
evavirseda Jul 29, 2026
29d929f
Merge branch 'develop' into feat/add-staking-history-to-validators-page
Juligs Jul 30, 2026
e71d9dc
Merge branch 'develop' into feat/add-staking-history-to-validators-page
Juligs Jul 30, 2026
fb5e302
chore(explorer): apply suggestions
Juligs Jul 30, 2026
d50f9f4
refactor(explorer): split staking history type/address columns and al…
Juligs Jul 30, 2026
bf60dab
Merge remote-tracking branch 'origin/develop' into feat/add-staking-h…
Juligs Jul 30, 2026
c31b5e7
Merge branch 'develop' into feat/add-staking-history-to-validators-page
Juligs Jul 30, 2026
a06d230
Merge branch 'develop' into feat/add-staking-history-to-validators-page
Juligs Jul 30, 2026
552f411
Merge branch 'develop' into feat/add-staking-history-to-validators-page
VmMad Jul 30, 2026
8af275c
Merge branch 'develop' into feat/add-staking-history-to-validators-page
evavirseda Jul 31, 2026
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
6 changes: 6 additions & 0 deletions apps/core/src/interfaces/stakeEvent.interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,17 @@
export interface StakeEventJson {
amount: string;
validator_address: string;
staker_address?: string;
pool_id?: string;
epoch: string;
}

export interface UnstakeEventJson {
principal_amount?: string;
reward_amount?: string;
validator_address?: string;
staker_address?: string;
pool_id?: string;
stake_activation_epoch?: string;
unstaking_epoch?: string;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// Copyright (c) 2026 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0

import { InfoBox, InfoBoxStyle, InfoBoxType, Panel, Title } from '@iota/apps-ui-kit';
import { Warning } from '@iota/apps-ui-icons';
import { useCursorPagination } from '@iota/core';
import { PlaceholderTable, TableCard } from '~/components/ui';
import { useGetValidatorStakingEvents } from '~/hooks';
import { generateStakingHistoryTableColumns } from '~/lib/ui';

const STAKING_HISTORY_PAGE_SIZE = 10;

const STAKING_HISTORY_COLUMN_HEADINGS = [
'Address',
'Amount',
'Reward',
'Active Epoch',
'Digest',
'Age',
];

interface ValidatorStakingHistoryProps {
validatorAddress: string;
}

export function ValidatorStakingHistory({
validatorAddress,
}: ValidatorStakingHistoryProps): JSX.Element {
const stakingEventsQuery = useGetValidatorStakingEvents({
validatorAddress,
limit: STAKING_HISTORY_PAGE_SIZE,
order: 'descending',
});
const { data, isFetching, pagination, isPending, isError } =
useCursorPagination(stakingEventsQuery);

const tableColumns = generateStakingHistoryTableColumns();

return (
<Panel>
<Title title="Staking History" />
<div className="p-md--rs">
{isError ? (
<InfoBox
title="Error"
supportingText="Failed to load staking history"
icon={<Warning />}
type={InfoBoxType.Error}
style={InfoBoxStyle.Default}
/>
) : isPending || isFetching || !data?.data ? (
<PlaceholderTable
rowCount={STAKING_HISTORY_PAGE_SIZE}
rowHeight="16px"
colHeadings={STAKING_HISTORY_COLUMN_HEADINGS}
/>
) : (
<TableCard
data={data.data}
columns={tableColumns}
paginationOptions={pagination}
/>
)}
</div>
</Panel>
);
}
1 change: 1 addition & 0 deletions apps/explorer/src/components/validator/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ export * from './ValidatorStats';
export * from './ValidatorFilters';
export * from './ValidatorSearch';
export * from './ValidatorStatusLegend';
export * from './ValidatorStakingHistory';
1 change: 1 addition & 0 deletions apps/explorer/src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export * from './useVerifiedSourceCode';
export * from './useEndOfEpochTransactionFromCheckpoint';
export * from './useFormattedDate';
export * from './useAbstractAccountData';
export * from './useGetValidatorStakingEvents';
export * from './useLocalTablePagination';
export * from './useDeserializedSignatures';
export * from './useAddressBalanceSummary';
Expand Down
146 changes: 146 additions & 0 deletions apps/explorer/src/hooks/useGetValidatorStakingEvents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
// Copyright (c) 2026 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0

import { useIotaClient } from '@iota/dapp-kit';
import type { EventId, IotaEvent } from '@iota/iota-sdk/client';
import { useInfiniteQuery } from '@tanstack/react-query';
import {
STAKING_REQUEST_EVENT,
UNSTAKING_REQUEST_EVENT,
type StakeEventJson,
type UnstakeEventJson,
} from '@iota/core';

// NOTE: This copies the query limit from our Rust JSON RPC backend, this needs to be kept in sync!
const RAW_QUERY_LIMIT = 50;

// Full nodes only retain a bounded window of past events, and `validator_address` can only be
// matched client-side (there is no server-side filter for it). This caps how many raw pages we
// scan per fetch so a sparsely-staked validator can't trigger an unbounded chain of RPC calls.
const MAX_RAW_FETCHES_PER_PAGE = 20;

interface UseGetValidatorStakingEventsOptions {
validatorAddress?: string;
limit: number;
order?: 'ascending' | 'descending';
}

interface EventStreamState {
cursor: EventId | null;
hasNextPage: boolean;
// Matched events already fetched but not yet consumed by the merge step, kept in fetch order.
buffer: IotaEvent[];
}

interface StakingEventsPageParam {
stake: EventStreamState;
unstake: EventStreamState;
}

interface StakingEventsPage {
data: IotaEvent[];
nextCursor: StakingEventsPageParam;
hasNextPage: boolean;
}

const INITIAL_PAGE_PARAM: StakingEventsPageParam = {
stake: { cursor: null, hasNextPage: true, buffer: [] },
unstake: { cursor: null, hasNextPage: true, buffer: [] },
};

function matchesValidator(event: IotaEvent, validatorAddress: string): boolean {
const parsedJson = event.parsedJson as StakeEventJson | UnstakeEventJson;
return parsedJson?.validator_address === validatorAddress;
}

function eventTimestamp(event: IotaEvent | undefined): number {
return Number(event?.timestampMs ?? 0);
}

/** Returns true when `a`'s event should be emitted before `b`'s, given the sort order. */
function comesFirst(a: IotaEvent, b: IotaEvent, order: 'ascending' | 'descending'): boolean {
return order === 'descending'
? eventTimestamp(a) >= eventTimestamp(b)
: eventTimestamp(a) <= eventTimestamp(b);
}

export function useGetValidatorStakingEvents({
validatorAddress,
limit,
order = 'descending',
}: UseGetValidatorStakingEventsOptions) {
const client = useIotaClient();

return useInfiniteQuery<StakingEventsPage>({
queryKey: ['validator-staking-events', validatorAddress, limit, order],
queryFn: async ({ pageParam }) => {
const currentPageParam = pageParam as StakingEventsPageParam;
const stakeState: EventStreamState = {
...currentPageParam.stake,
buffer: [...currentPageParam.stake.buffer],
};
const unstakeState: EventStreamState = {
...currentPageParam.unstake,
buffer: [...currentPageParam.unstake.buffer],
};

let remainingFetchBudget = MAX_RAW_FETCHES_PER_PAGE;

async function fillBuffer(eventType: string, state: EventStreamState) {
while (state.buffer.length === 0 && state.hasNextPage && remainingFetchBudget > 0) {
remainingFetchBudget -= 1;
const response = await client.queryEvents({
query: { MoveEventType: eventType },
cursor: state.cursor,
limit: RAW_QUERY_LIMIT,
order,
});

for (const event of response.data as IotaEvent[]) {
if (matchesValidator(event, validatorAddress!)) {
state.buffer.push(event);
}
}
state.cursor = response.nextCursor ?? null;
state.hasNextPage = response.hasNextPage;
}
}

const merged: IotaEvent[] = [];
while (merged.length < limit) {
await Promise.all([
fillBuffer(STAKING_REQUEST_EVENT, stakeState),
fillBuffer(UNSTAKING_REQUEST_EVENT, unstakeState),
]);

const stakeHead = stakeState.buffer[0];
const unstakeHead = unstakeState.buffer[0];

if (!stakeHead && !unstakeHead) {
break;
}

const takeStake =
!!stakeHead && (!unstakeHead || comesFirst(stakeHead, unstakeHead, order));
merged.push(takeStake ? stakeState.buffer.shift()! : unstakeState.buffer.shift()!);

if (remainingFetchBudget <= 0) {
break;
}
}

return {
data: merged,
nextCursor: { stake: stakeState, unstake: unstakeState },
hasNextPage:
stakeState.hasNextPage ||
unstakeState.hasNextPage ||
stakeState.buffer.length > 0 ||
unstakeState.buffer.length > 0,
};
},
initialPageParam: INITIAL_PAGE_PARAM,
getNextPageParam: (lastPage) => (lastPage.hasNextPage ? lastPage.nextCursor : undefined),
enabled: !!validatorAddress && !!limit,
});
}
Loading
Loading