diff --git a/apps/core/src/interfaces/stakeEvent.interfaces.ts b/apps/core/src/interfaces/stakeEvent.interfaces.ts index 1b7514f0a6..9710d0c51c 100644 --- a/apps/core/src/interfaces/stakeEvent.interfaces.ts +++ b/apps/core/src/interfaces/stakeEvent.interfaces.ts @@ -4,6 +4,8 @@ export interface StakeEventJson { amount: string; validator_address: string; + staker_address?: string; + pool_id?: string; epoch: string; } @@ -11,4 +13,8 @@ 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; } diff --git a/apps/explorer/src/components/validator/ValidatorStakingHistory.tsx b/apps/explorer/src/components/validator/ValidatorStakingHistory.tsx new file mode 100644 index 0000000000..df400b6d33 --- /dev/null +++ b/apps/explorer/src/components/validator/ValidatorStakingHistory.tsx @@ -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 ( + + + + {isError ? ( + } + type={InfoBoxType.Error} + style={InfoBoxStyle.Default} + /> + ) : isPending || isFetching || !data?.data ? ( + + ) : ( + + )} + + + ); +} diff --git a/apps/explorer/src/components/validator/index.ts b/apps/explorer/src/components/validator/index.ts index 81af02db29..4ef5112537 100644 --- a/apps/explorer/src/components/validator/index.ts +++ b/apps/explorer/src/components/validator/index.ts @@ -6,3 +6,4 @@ export * from './ValidatorStats'; export * from './ValidatorFilters'; export * from './ValidatorSearch'; export * from './ValidatorStatusLegend'; +export * from './ValidatorStakingHistory'; diff --git a/apps/explorer/src/hooks/index.ts b/apps/explorer/src/hooks/index.ts index 5bd53941f0..bad8deaa50 100644 --- a/apps/explorer/src/hooks/index.ts +++ b/apps/explorer/src/hooks/index.ts @@ -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'; diff --git a/apps/explorer/src/hooks/useGetValidatorStakingEvents.ts b/apps/explorer/src/hooks/useGetValidatorStakingEvents.ts new file mode 100644 index 0000000000..367a872ab0 --- /dev/null +++ b/apps/explorer/src/hooks/useGetValidatorStakingEvents.ts @@ -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({ + 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, + }); +} diff --git a/apps/explorer/src/lib/ui/utils/generateStakingHistoryTableColumns.tsx b/apps/explorer/src/lib/ui/utils/generateStakingHistoryTableColumns.tsx new file mode 100644 index 0000000000..b87e05c7ef --- /dev/null +++ b/apps/explorer/src/lib/ui/utils/generateStakingHistoryTableColumns.tsx @@ -0,0 +1,212 @@ +// Copyright (c) 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +import { + CoinFiatValue, + ImageIcon, + ImageIconSize, + STAKING_REQUEST_EVENT, + TransactionAction, + TransactionIcon, + TransactionIconSize, + useAddressAliasLookup, +} from '@iota/core'; +import type { IotaEvent } from '@iota/iota-sdk/client'; +import { CoinFormat, formatBalance, formatDigest, IOTA_DECIMALS } from '@iota/iota-sdk/utils'; +import { TableCellBase, TableCellText, Tooltip } from '@iota/apps-ui-kit'; +import type { ColumnDef } from '@tanstack/react-table'; +import type { StakeEventJson, UnstakeEventJson } from '@iota/core'; +import { DateDisplay } from '~/components'; +import { AddressLink, EpochLink, TransactionLink } from '~/components/ui'; + +function StakerAddressLink({ address }: { address: string }) { + const getAddressAlias = useAddressAliasLookup(); + const addressAlias = getAddressAlias(address); + + if (!addressAlias) { + return ( + + ); + } + + return ( + + + + + + {addressAlias.alias} + + } + /> + + ); +} + +function formatIota(amount: string | number | undefined): string { + return formatBalance(amount ?? 0, IOTA_DECIMALS, CoinFormat.Full); +} + +function AmountCell({ + amount, + negative, +}: { + amount: string | number | undefined; + negative?: boolean; +}) { + const formatted = formatIota(amount); + return ( + + + {negative ? `-${formatted}` : formatted} + + + + ); +} + +/** + * Generate table columns renderers for a validator's staking history (stake/withdraw events). + */ +export function generateStakingHistoryTableColumns(): ColumnDef[] { + return [ + { + header: 'Type', + id: 'type', + cell: ({ row: { original: event } }) => { + const isStake = event.type === STAKING_REQUEST_EVENT; + const digest = event.id.txDigest; + return ( + + + + + + {isStake ? 'Stake' : 'Withdraw'} + + + {formatDigest(digest)} + + + + } + /> + + ); + }, + }, + { + header: 'Address', + id: 'address', + cell: ({ row: { original: event } }) => { + const parsedJson = event.parsedJson as StakeEventJson | UnstakeEventJson; + const address = parsedJson?.staker_address; + return ( + + {address ? ( + + ) : ( + -- + )} + + ); + }, + }, + { + header: 'Amount', + id: 'amount', + cell: ({ row: { original: event } }) => { + const isStake = event.type === STAKING_REQUEST_EVENT; + const parsedJson = event.parsedJson as StakeEventJson | UnstakeEventJson; + const amount = isStake + ? (parsedJson as StakeEventJson).amount + : (parsedJson as UnstakeEventJson).principal_amount; + return ( + + + + ); + }, + }, + { + header: 'Reward', + id: 'reward', + cell: ({ row: { original: event } }) => { + const isStake = event.type === STAKING_REQUEST_EVENT; + const parsedJson = event.parsedJson as UnstakeEventJson; + const reward = isStake ? '0' : parsedJson.reward_amount; + return ( + + + + ); + }, + }, + { + header: 'Active Epoch', + id: 'activeEpoch', + cell: ({ row: { original: event } }) => { + const isStake = event.type === STAKING_REQUEST_EVENT; + const parsedJson = event.parsedJson as StakeEventJson | UnstakeEventJson; + const epoch = isStake + ? String(Number((parsedJson as StakeEventJson).epoch) + 1) + : (parsedJson as UnstakeEventJson).stake_activation_epoch; + return ( + + + {epoch !== undefined ? ( + {epoch} + ) : ( + '--' + )} + + + ); + }, + }, + { + header: 'Age', + id: 'age', + cell: ({ row: { original: event } }) => ( + + + {event.timestampMs ? ( + + ) : ( + '--' + )} + + + ), + }, + ]; +} diff --git a/apps/explorer/src/lib/ui/utils/index.ts b/apps/explorer/src/lib/ui/utils/index.ts index 85b027bc27..96395410fc 100644 --- a/apps/explorer/src/lib/ui/utils/index.ts +++ b/apps/explorer/src/lib/ui/utils/index.ts @@ -5,6 +5,7 @@ export * from './generateCheckpointsTableColumns'; export * from './generateEpochsTableColumns'; export * from './generateValidatorsTableColumns'; export * from './generateTransactionsTableColumns'; +export * from './generateStakingHistoryTableColumns'; export * from './generateBalanceChangesTableColumns'; export * from './generateObjectChangesTableColumns'; export * from './generateActivityTableColumns'; diff --git a/apps/explorer/src/pages/validator/ValidatorDetails.tsx b/apps/explorer/src/pages/validator/ValidatorDetails.tsx index d16c7e4837..6132633793 100644 --- a/apps/explorer/src/pages/validator/ValidatorDetails.tsx +++ b/apps/explorer/src/pages/validator/ValidatorDetails.tsx @@ -12,7 +12,13 @@ import { useGetPendingValidator, } from '@iota/core'; import { useParams } from 'react-router-dom'; -import { PageLayout, ValidatorMeta, ValidatorStats, ValidatorStatusLegend } from '~/components'; +import { + PageLayout, + ValidatorMeta, + ValidatorStakingHistory, + ValidatorStats, + ValidatorStatusLegend, +} from '~/components'; import { VALIDATOR_LOW_STAKE_GRACE_PERIOD } from '~/lib/constants'; import { getValidatorMoveEvent } from '~/lib/utils'; import { @@ -270,6 +276,7 @@ function ValidatorDetails(): JSX.Element { /> )} + } />