Add Pezkuwi SDK UI - Polkadot.js Apps clone

- Clone Polkadot.js Apps repository
- Update package.json with Pezkuwi branding
- Add Pezkuwi endpoint to production chains (wss://pezkuwichain.app:9944)
- Create comprehensive README for SDK UI
- Set up project structure with all packages

Next steps:
- Apply Kurdistan colors (Kesk, Sor, Zer, Spi + Black) to UI theme
- Replace logos with Pezkuwi branding
- Test build and deployment
This commit is contained in:
Claude
2025-11-14 00:55:17 +00:00
parent 24be8d4411
commit 60a800b33e
5836 changed files with 324981 additions and 17 deletions
@@ -0,0 +1,21 @@
// Copyright 2017-2025 @polkadot/app-staking-async authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React from 'react';
import { MarkWarning } from '@polkadot/react-components';
import { useTranslation } from '../translate.js';
function ActionsBanner (): React.ReactElement<null> | null {
const { t } = useTranslation();
return (
<MarkWarning
className='warning centered'
content={t('Use the account actions to create a new validator/nominator stash and bond it to participate in staking. Do not send funds directly via a transfer to a validator.')}
/>
);
}
export default React.memo(ActionsBanner);
@@ -0,0 +1,88 @@
// Copyright 2017-2025 @polkadot/app-staking-async authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ApiPromise } from '@polkadot/api';
import type { NominatedBy as NominatedByType } from '@polkadot/app-staking/types';
import type { SlashingSpans } from '@polkadot/types/interfaces';
import React, { useMemo } from 'react';
import { AddressMini, ExpanderScroll } from '@polkadot/react-components';
import { useApi } from '@polkadot/react-hooks';
import { formatNumber } from '@polkadot/util';
import { useTranslation } from '../../translate.js';
interface Props {
nominators?: NominatedByType[];
slashingSpans?: SlashingSpans | null;
}
interface Chilled {
active: null | [number, () => React.ReactNode[]];
chilled: null | [number, () => React.ReactNode[]];
}
function extractFunction (all: string[]): null | [number, () => React.ReactNode[]] {
return all.length
? [
all.length,
() => all.map((value): React.ReactNode =>
<AddressMini
key={value}
value={value}
/>
)
]
: null;
}
function extractChilled (api: ApiPromise, nominators: NominatedByType[] = [], slashingSpans?: SlashingSpans | null): Chilled {
// NOTE With the introduction of the SlashReported event,
// nominators are not auto-chilled on validator slash
const chilled = slashingSpans && !api.events.staking.SlashReported
? nominators
.filter(({ submittedIn }) =>
slashingSpans.lastNonzeroSlash.gt(submittedIn)
)
.map(({ nominatorId }) => nominatorId)
: [];
return {
active: extractFunction(
nominators
.filter(({ nominatorId }) => !chilled.includes(nominatorId))
.map(({ nominatorId }) => nominatorId)
),
chilled: extractFunction(chilled)
};
}
function NominatedBy ({ nominators, slashingSpans }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api } = useApi();
const { active, chilled } = useMemo(
() => extractChilled(api, nominators, slashingSpans),
[api, nominators, slashingSpans]
);
return (
<td className='expand all'>
{active && (
<ExpanderScroll
renderChildren={active[1]}
summary={t('Nominations ({{count}})', { replace: { count: formatNumber(active[0]) } })}
/>
)}
{chilled && (
<ExpanderScroll
renderChildren={chilled[1]}
summary={t('Renomination required ({{count}})', { replace: { count: formatNumber(chilled[0]) } })}
/>
)}
</td>
);
}
export default React.memo(NominatedBy);
@@ -0,0 +1,101 @@
// Copyright 2017-2025 @polkadot/app-staking-async authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { u32 } from '@polkadot/types';
import type { NominatorValue } from './types.js';
import React, { useMemo } from 'react';
import { AddressMini, ExpanderScroll } from '@polkadot/react-components';
import { useApi } from '@polkadot/react-hooks';
import { FormatBalance } from '@polkadot/react-query';
import { BN, BN_ZERO } from '@polkadot/util';
interface Props {
stakeOther?: BN;
nominators?: NominatorValue[];
}
function extractFunction (all: NominatorValue[]): null | [number, () => React.ReactNode[]] {
return [
all.length,
() => all.map(({ nominatorId, value }): React.ReactNode =>
<AddressMini
bonded={value}
key={nominatorId}
value={nominatorId}
withBonded
/>
)
];
}
function sumValue (all: { value: BN }[]): BN {
const total = new BN(0);
for (let i = 0, count = all.length; i < count; i++) {
total.iadd(all[i].value);
}
return total;
}
function extractTotals (maxPaid: BN | undefined, nominators?: NominatorValue[], stakeOther?: BN): [null | [number, () => React.ReactNode[]], BN, null | [number, () => React.ReactNode[]], BN] {
if (!nominators) {
return [null, BN_ZERO, null, BN_ZERO];
}
const sorted = nominators.sort((a, b) => b.value.cmp(a.value));
if (!maxPaid || maxPaid.gtn(sorted.length)) {
return [extractFunction(sorted), stakeOther || BN_ZERO, null, BN_ZERO];
}
const max = maxPaid.toNumber();
const rewarded = sorted.slice(0, max);
const rewardedTotal = sumValue(rewarded);
const unrewarded = sorted.slice(max);
const unrewardedTotal = sumValue(unrewarded);
return [extractFunction(rewarded), rewardedTotal, extractFunction(unrewarded), unrewardedTotal];
}
function StakeOther ({ nominators, stakeOther }: Props): React.ReactElement<Props> {
const { api } = useApi();
const [rewarded, rewardedTotal, unrewarded, unrewardedTotal] = useMemo(
() => extractTotals(api.consts.staking?.maxNominatorRewardedPerValidator as u32, nominators, stakeOther),
[api, nominators, stakeOther]
);
return (
<td className='expand all'>
{(!rewarded || rewarded[0] !== 0) && (
<ExpanderScroll
className={rewarded ? '' : '--tmp'}
renderChildren={rewarded?.[1]}
summary={
<FormatBalance
labelPost={` (${rewarded ? rewarded[0] : '0'})`}
value={rewardedTotal}
/>
}
/>
)}
{unrewarded && (
<ExpanderScroll
className='stakeOver'
renderChildren={unrewarded[1]}
summary={
<FormatBalance
labelPost={` (${unrewarded[0]})`}
value={unrewardedTotal}
/>
}
/>
)}
</td>
);
}
export default React.memo(StakeOther);
@@ -0,0 +1,119 @@
// Copyright 2017-2025 @polkadot/app-staking-async authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { BN } from '@polkadot/util';
import React, { useMemo } from 'react';
import MaxBadge from '@polkadot/app-staking/MaxBadge';
import { Badge } from '@polkadot/react-components';
import { useAccounts } from '@polkadot/react-hooks';
interface Props {
isChilled?: boolean;
isElected: boolean;
isMain?: boolean;
isPara?: boolean;
isRelay?: boolean;
nominators?: { nominatorId: string }[];
onlineCount?: false | BN;
onlineMessage?: boolean;
}
const NO_NOMS: { nominatorId: string }[] = [];
function Status ({ isChilled, isElected, isMain, isPara, isRelay, nominators = NO_NOMS, onlineCount, onlineMessage }: Props): React.ReactElement<Props> {
const { allAccounts } = useAccounts();
const blockCount = onlineCount && onlineCount.toNumber();
const isNominating = useMemo(
() => nominators.some(({ nominatorId }) => allAccounts.includes(nominatorId)),
[allAccounts, nominators]
);
return (
<>
{isNominating
? (
<Badge
className='media--1100'
color='green'
icon='hand-paper'
/>
)
: (
<Badge
className='media--1100'
color='transparent'
/>
)
}
{isRelay && (
isPara
? (
<Badge
className='media--1100'
color='purple'
icon='vector-square'
/>
)
: (
<Badge
className='media--1100'
color='transparent'
/>
)
)}
{isChilled
? (
<Badge
className='media--1000'
color='red'
icon='cancel'
/>
)
: isElected
? (
<Badge
className='media--1000'
color='blue'
icon='chevron-right'
/>
)
: (
<Badge
className='media--1000'
color='transparent'
/>
)
}
{isMain && (
blockCount
? (
<Badge
className='media--900'
color='green'
info={blockCount}
/>
)
: onlineMessage
? (
<Badge
className='media--900'
color='green'
icon='envelope'
/>
)
: (
<Badge
className='media--900'
color='transparent'
/>
)
)}
<MaxBadge numNominators={nominators.length} />
</>
);
}
export default React.memo(Status);
@@ -0,0 +1,236 @@
// Copyright 2017-2025 @polkadot/app-staking-async authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ApiPromise } from '@polkadot/api';
import type { DeriveHeartbeatAuthor } from '@polkadot/api-derive/types';
import type { NominatedBy as NominatedByType, ValidatorInfo } from '@polkadot/app-staking/types';
import type { Option } from '@polkadot/types';
import type { SlashingSpans, ValidatorPrefs } from '@polkadot/types/interfaces';
import type { BN } from '@polkadot/util';
import type { NominatorValue } from './types.js';
import React, { useMemo } from 'react';
import { AddressSmall, Columar, Icon, LinkExternal, Table, Tag } from '@polkadot/react-components';
import { checkVisibility } from '@polkadot/react-components/util';
import { useApi, useCall, useDeriveAccountInfo, useToggle } from '@polkadot/react-hooks';
import { FormatBalance } from '@polkadot/react-query';
import { BN_ZERO } from '@polkadot/util';
import { useTranslation } from '../../translate.js';
import NominatedBy from './NominatedBy.js';
import StakeOther from './StakeOther.js';
import Status from './Status.js';
interface Props {
address: string;
className?: string;
filterName: string;
hasQueries: boolean;
isElected: boolean;
isFavorite: boolean;
isMain?: boolean;
isPara?: boolean;
lastBlock?: string;
minCommission?: BN;
nominatedBy?: NominatedByType[];
points?: string;
recentlyOnline?: DeriveHeartbeatAuthor;
toggleFavorite: (accountId: string) => void;
validatorInfo?: ValidatorInfo;
withIdentity?: boolean;
}
interface StakingState {
isChilled?: boolean;
commission?: string;
nominators?: NominatorValue[];
stakeTotal?: BN;
stakeOther?: BN;
stakeOwn?: BN;
}
function expandInfo ({ exposureMeta, exposurePaged, validatorPrefs }: ValidatorInfo, minCommission?: BN): StakingState {
let nominators: NominatorValue[] | undefined;
let stakeTotal: BN | undefined;
let stakeOther: BN | undefined;
let stakeOwn: BN | undefined;
if (exposureMeta?.total) {
nominators = exposurePaged.others.map(({ value, who }) => ({
nominatorId: who.toString(),
value: value.unwrap()
}));
stakeTotal = exposureMeta.total?.unwrap() || BN_ZERO;
stakeOwn = exposureMeta.own.unwrap();
stakeOther = stakeTotal.sub(stakeOwn);
}
const commission = (validatorPrefs as ValidatorPrefs)?.commission?.unwrap();
return {
commission: commission?.toHuman(),
isChilled: commission && minCommission && commission.isZero() && commission.lt(minCommission),
nominators,
stakeOther,
stakeOwn,
stakeTotal
};
}
const transformSlashes = {
transform: (opt: Option<SlashingSpans>) => opt.unwrapOr(null)
};
function useAddressCalls (api: ApiPromise, address: string, isMain?: boolean) {
const params = useMemo(() => [address], [address]);
const accountInfo = useDeriveAccountInfo(address);
const slashingSpans = useCall<SlashingSpans | null>(!isMain && api.query.staking.slashingSpans, params, transformSlashes);
return { accountInfo, slashingSpans };
}
function Address ({ address, className = '', filterName, hasQueries, isElected, isFavorite, isMain, isPara, lastBlock, minCommission, nominatedBy, points, recentlyOnline, toggleFavorite, validatorInfo, withIdentity }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
const { api, apiIdentity } = useApi();
const [isExpanded, toggleIsExpanded] = useToggle(false);
const { accountInfo, slashingSpans } = useAddressCalls(api, address, isMain);
const { commission, isChilled, nominators, stakeOther, stakeOwn } = useMemo(
() => validatorInfo
? expandInfo(validatorInfo, minCommission)
: {},
[minCommission, validatorInfo]
);
const isVisible = useMemo(
() => accountInfo ? checkVisibility(apiIdentity, address, accountInfo, filterName, withIdentity) : true,
[accountInfo, address, filterName, apiIdentity, withIdentity]
);
const statsLink = useMemo(
() => `#/staking/query/${address}`,
[address]
);
const pointsAnimClass = useMemo(
() => points && `greyAnim-${Date.now() % 25}`,
[points]
);
if (!isVisible) {
return null;
}
return (
<>
<tr className={`${className} isExpanded isFirst ${isExpanded ? 'packedBottom' : 'isLast'}`}>
<Table.Column.Favorite
address={address}
isFavorite={isFavorite}
toggle={toggleFavorite}
/>
<td className='badge together'>
<Status
isChilled={isChilled}
isElected={isElected}
isMain={isMain}
isPara={isPara}
isRelay={!!(api.query.parasShared || api.query.shared)?.activeValidatorIndices}
nominators={isMain ? nominators : nominatedBy}
onlineCount={recentlyOnline?.blockCount}
onlineMessage={recentlyOnline?.hasMessage}
/>
</td>
<td className='address all relative'>
<AddressSmall value={address} />
{isMain && pointsAnimClass && (
<Tag
className={`${pointsAnimClass} absolute`}
color='lightgrey'
label={points}
/>
)}
</td>
{isMain
? (
<StakeOther
nominators={nominators}
stakeOther={stakeOther}
/>
)
: (
<NominatedBy
nominators={nominatedBy}
slashingSpans={slashingSpans}
/>
)
}
<td className='number'>
{commission || <span className='--tmp'>50.00%</span>}
</td>
{isMain && (
<td className='number'>
{lastBlock}
</td>
)}
<Table.Column.Expand
isExpanded={isExpanded}
toggle={toggleIsExpanded}
/>
</tr>
{isExpanded && (
<tr className={`${className} ${isExpanded ? 'isExpanded isLast' : 'isCollapsed'} packedTop`}>
<td colSpan={2} />
<td
className='columar'
colSpan={
isMain
? 4
: 3
}
>
<Columar size='small'>
<Columar.Column>
{isMain && stakeOwn?.gtn(0) && (
<>
<h5>{t('own stake')}</h5>
<FormatBalance
value={stakeOwn}
/>
</>
)}
</Columar.Column>
<Columar.Column>
{hasQueries && (
<>
<h5>{t('graphs')}</h5>
<a href={statsLink}>
<Icon
className='highlight--color'
icon='chart-line'
/>
&nbsp;{t('historic results')}
</a>
</>
)}
</Columar.Column>
</Columar>
<Columar is100>
<Columar.Column>
<LinkExternal
data={address}
type='validator' // {isMain ? 'validator' : 'intention'}
withTitle
/>
</Columar.Column>
</Columar>
</td>
<td />
</tr>
)}
</>
);
}
export default React.memo(Address);
@@ -0,0 +1,9 @@
// Copyright 2017-2025 @polkadot/app-staking-async authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { Balance } from '@polkadot/types/interfaces';
export interface NominatorValue {
nominatorId: string;
value: Balance;
}
@@ -0,0 +1,210 @@
// Copyright 2017-2025 @polkadot/app-staking-async authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { DeriveHeartbeats, DeriveStakingOverview } from '@polkadot/api-derive/types';
import type { NominatedByMap, SortedTargets, ValidatorInfo } from '@polkadot/app-staking/types';
import type { AccountId } from '@polkadot/types/interfaces';
import type { BN } from '@polkadot/util';
import React, { useMemo, useRef, useState } from 'react';
import Filtering from '@polkadot/app-staking/Filtering';
import Legend from '@polkadot/app-staking2/Legend';
import { Table } from '@polkadot/react-components';
import { useApi, useBlockAuthors, useNextTick } from '@polkadot/react-hooks';
import { useTranslation } from '../translate.js';
import Address from './Address/index.js';
interface Props {
className?: string;
byAuthor: Record<string, string>;
eraPoints: Record<string, string>;
favorites: string[];
hasQueries: boolean;
isIntentions?: boolean;
isIntentionsTrigger?: boolean;
isOwn: boolean;
minCommission?: BN;
nominatedBy?: NominatedByMap;
ownStashIds?: string[];
paraValidators: Record<string, boolean>;
recentlyOnline?: DeriveHeartbeats;
setNominators?: (nominators: string[]) => void;
stakingOverview?: DeriveStakingOverview;
targets: SortedTargets;
toggleFavorite: (address: string) => void;
}
type AccountExtend = [string, boolean, boolean];
interface Filtered {
validators?: AccountExtend[];
waiting?: AccountExtend[];
}
function filterAccounts (isOwn: boolean, accounts: string[] = [], ownStashIds: string[] = [], elected: string[], favorites: string[], without: string[]): AccountExtend[] {
return accounts
.filter((accountId) =>
!without.includes(accountId) && (
!isOwn ||
ownStashIds.includes(accountId)
)
)
.map((accountId): AccountExtend => [
accountId,
elected.includes(accountId),
favorites.includes(accountId)
])
.sort(([accA,, isFavA]: AccountExtend, [accB,, isFavB]: AccountExtend): number => {
const isStashA = ownStashIds.includes(accA);
const isStashB = ownStashIds.includes(accB);
return isFavA === isFavB
? isStashA === isStashB
? 0
: (isStashA ? -1 : 1)
: (isFavA ? -1 : 1);
});
}
function accountsToString (accounts: AccountId[]): string[] {
const result = new Array<string>(accounts.length);
for (let i = 0; i < accounts.length; i++) {
result[i] = accounts[i].toString();
}
return result;
}
function getFiltered (isOwn: boolean, stakingOverview: DeriveStakingOverview | undefined, favorites: string[], next?: string[], ownStashIds?: string[]): Filtered {
if (!stakingOverview) {
return {};
}
const allElected = accountsToString(stakingOverview.nextElected);
const validatorIds = accountsToString(stakingOverview.validators);
return {
validators: filterAccounts(isOwn, validatorIds, ownStashIds, allElected, favorites, []),
waiting: filterAccounts(isOwn, allElected, ownStashIds, allElected, favorites, validatorIds).concat(
filterAccounts(isOwn, next, ownStashIds, [], favorites, allElected)
)
};
}
function mapValidators (infos: ValidatorInfo[]): Record<string, ValidatorInfo> {
const result: Record<string, ValidatorInfo> = {};
for (let i = 0, count = infos.length; i < count; i++) {
const info = infos[i];
result[info.key] = info;
}
return result;
}
const DEFAULT_PARAS = {};
function CurrentList ({ className, favorites, hasQueries, isIntentions, isOwn, minCommission, nominatedBy, ownStashIds, paraValidators = DEFAULT_PARAS, recentlyOnline, stakingOverview, targets, toggleFavorite }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
const { api } = useApi();
const { byAuthor, eraPoints } = useBlockAuthors();
const [nameFilter, setNameFilter] = useState<string>('');
const isNextTick = useNextTick();
const { validators, waiting } = useMemo(
() => getFiltered(isOwn, stakingOverview, favorites, targets.waitingIds, ownStashIds),
[favorites, isOwn, ownStashIds, stakingOverview, targets]
);
const list = useMemo(
() => isNextTick
? isIntentions
? nominatedBy && waiting
: validators
: undefined,
[isIntentions, isNextTick, nominatedBy, validators, waiting]
);
const infoMap = useMemo(
() => targets.validators && mapValidators(targets.validators),
[targets]
);
const headerRef = useRef<([React.ReactNode?, string?, number?] | false)[]>(
isIntentions
? [
[t('intentions'), 'start', 3],
[t('nominators'), 'expand'],
[t('commission'), 'number'],
[]
]
: [
[t('validators'), 'start', 3],
[t('other stake'), 'expand'],
[t('commission')],
[t('last #')],
[]
]
);
return (
<Table
className={className}
empty={
isIntentions
? list && t('No waiting validators found')
: list && recentlyOnline && infoMap && t('No active validators found')
}
emptySpinner={
<>
{!waiting && <div>{t('Retrieving validators')}</div>}
{!infoMap && <div>{t('Retrieving validator info')}</div>}
{isIntentions
? !nominatedBy && <div>{t('Retrieving nominators')}</div>
: !recentlyOnline && <div>{t('Retrieving online status')}</div>
}
{!list && <div>{t('Preparing validator list')}</div>}
</>
}
filter={
<Filtering
nameFilter={nameFilter}
setNameFilter={setNameFilter}
/>
}
header={headerRef.current}
legend={
<Legend
isRelay={!isIntentions && !!(api.query.parasShared || api.query.shared)?.activeValidatorIndices}
minCommission={minCommission}
/>
}
>
{list?.map(([address, isElected, isFavorite]): React.ReactNode => (
<Address
address={address}
filterName={nameFilter}
hasQueries={hasQueries}
isElected={isElected}
isFavorite={isFavorite}
isMain={!isIntentions}
isPara={paraValidators[address]}
key={address}
lastBlock={byAuthor[address]}
minCommission={minCommission}
nominatedBy={nominatedBy?.[address]}
points={eraPoints[address]}
recentlyOnline={recentlyOnline?.[address]}
toggleFavorite={toggleFavorite}
validatorInfo={infoMap?.[address]}
/>
))}
</Table>
);
}
export default React.memo(CurrentList);
@@ -0,0 +1,53 @@
// Copyright 2017-2025 @polkadot/app-staking-async authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React from 'react';
import { Icon, styled } from '@polkadot/react-components';
interface Props {
children?: React.ReactNode;
className?: string;
withIcon?: boolean;
}
function StakingAsyncOverview ({ children, className = '', withIcon = true }: Props): React.ReactElement<Props> {
return (
<StyledArticle className={`${className} mark warning`}>
{withIcon && <Icon icon='exclamation-triangle' />}
<strong>Staking Async is Polkadot&apos;s staking system</strong> which elects validators <em>for the Relay Chain</em>, <em>on AssetHub</em>.
The actual collators of the AssetHub parachain are managed by the collator-selection system. To nominate a Relay Chain validator, please use this page and everything works as before. To setup a validator, please see{' '}
<a
href='https://docs.google.com/document/d/1X4EjL-7he70vtUumNhEqnUs7XdTCDj8TQpGJhNuAklY/edit?tab=t.0#heading=h.xh97bpw96bkk'
rel='noreferrer'
target='_blank'
>
this guide
</a>.
<br />
<br />
For more information about Staking Async and AssetHub migration, please see the{' '}
<a
href='https://docs.google.com/document/d/1XR3vL2p4QV0wC7FrlC8eN-q62BqNFTFElbj21wEmMGg/edit?tab=t.tyioldyxov9u'
rel='noreferrer'
target='_blank'
>
Asset Hub Migration FAQ
</a>.
{children}
</StyledArticle>
);
}
const StyledArticle = styled.article`
max-width: 50vw;
margin-inline: auto !important;
.ui--Icon {
color: rgba(255, 196, 12, 1);
margin-right: 0.5rem;
}
`;
export default React.memo(StakingAsyncOverview);
@@ -0,0 +1,151 @@
// Copyright 2017-2025 @polkadot/app-staking-async authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { DeriveStakingOverview } from '@polkadot/api-derive/types';
import type { SortedTargets } from '@polkadot/app-staking/types';
import type { Option, u32 } from '@polkadot/types-codec';
import type { BN } from '@polkadot/util';
import React, { useEffect, useState } from 'react';
import { CardSummary, styled, SummaryBox } from '@polkadot/react-components';
import { useApi, useCall } from '@polkadot/react-hooks';
import { formatNumber } from '@polkadot/util';
import { useTranslation } from '../translate.js';
import SummarySession from './SummarySession.js';
interface Props {
className?: string;
nominators?: string[];
stakingOverview?: DeriveStakingOverview;
targets: SortedTargets;
}
const OPT_CURRENTERA = {
transform: (currentEra: Option<u32>): BN | null =>
currentEra.unwrapOr(null)
};
const useActiveNominators = () => {
const { api } = useApi();
const currentEra = useCall(api.query.staking.currentEra, undefined, OPT_CURRENTERA);
const [exposedNominators, setExposedNominators] = useState<string[]>([]);
useEffect(() => {
const getExposedNominators = async () => {
const exposedNominators = (await api.query.staking.erasStakersPaged.entries(currentEra)).map(([_, value]) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
return (value).unwrap().others.map((x) => x.who.toString());
}).flat();
setExposedNominators([...new Set(exposedNominators)]);
};
getExposedNominators().catch(console.log);
}, [api.query.staking.erasStakersPaged, currentEra]);
return exposedNominators;
};
function Summary ({ className = '', stakingOverview, targets: { counterForNominators, inflation: { idealStake, inflation, stakedFraction }, waitingIds } }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const activeNominators = useActiveNominators();
const percent = <span className='percent'>%</span>;
return (
<StyledSummaryBox className={className}>
<section>
<CardSummary label={t('validators')}>
{stakingOverview
? <>{formatNumber(stakingOverview.validators.length)}&nbsp;/&nbsp;{formatNumber(stakingOverview.validatorCount)}</>
: <span className='--tmp'>999 / 999</span>
}
</CardSummary>
<CardSummary
className='media--900'
label={t('waiting')}
>
{waitingIds
? formatNumber(waitingIds.length)
: <span className='--tmp'>99</span>
}
</CardSummary>
<CardSummary
className='media--1000'
label={
counterForNominators
? t('active / nominators')
: t('nominators')
}
>
{activeNominators
? (
<>
{formatNumber(activeNominators.length)}
{counterForNominators && (
<>&nbsp;/&nbsp;{formatNumber(counterForNominators)}</>
)}
</>
)
: <span className='--tmp'>999 / 999</span>
}
</CardSummary>
</section>
<section>
{(idealStake > 0) && Number.isFinite(idealStake) && (
<CardSummary
className='media--1400'
label={t('ideal staked')}
>
<>{(idealStake * 100).toFixed(1)}{percent}</>
</CardSummary>
)}
{(stakedFraction > 0) && (
<CardSummary
className='media--1300'
label={t('staked')}
>
<>{(stakedFraction * 100).toFixed(1)}{percent}</>
</CardSummary>
)}
{(inflation > 0) && Number.isFinite(inflation) && (
<CardSummary
className='media--1200'
label={t('inflation')}
>
<>{inflation.toFixed(1)}{percent}</>
</CardSummary>
)}
</section>
<section>
<SummarySession />
</section>
</StyledSummaryBox>
);
}
const StyledSummaryBox = styled(SummaryBox)`
.validator--Account-block-icon {
display: inline-block;
margin-right: 0.75rem;
margin-top: -0.25rem;
vertical-align: middle;
}
.validator--Summary-authors {
.validator--Account-block-icon+.validator--Account-block-icon {
margin-left: -1.5rem;
}
}
.percent {
font-size: var(--font-percent-tiny);
}
`;
export default React.memo(Summary);
@@ -0,0 +1,120 @@
// Copyright 2017-2025 @polkadot/app-staking-async authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { DeriveSessionProgress } from '@polkadot/api-derive/types';
import type { Forcing } from '@polkadot/types/interfaces';
import React, { useMemo } from 'react';
import { CardSummary } from '@polkadot/react-components';
import { useApi, useBlockInterval, useCall, useStakingAsyncApis } from '@polkadot/react-hooks';
import { BN, BN_THREE, BN_TWO, BN_ZERO, formatNumber } from '@polkadot/util';
import { useTranslation } from '../translate.js';
interface Props {
className?: string;
withEra?: boolean;
withSession?: boolean;
}
function SummarySession ({ className, withEra = true, withSession = true }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api } = useApi(); // Asset Hub API
const blockTime = useBlockInterval();
const { rcApi } = useStakingAsyncApis();
const sessionInfo = useCall<DeriveSessionProgress>(rcApi?.derive.session?.progress);
const ahSessionInfo = useCall<DeriveSessionProgress>(api?.derive.session?.progress);
const forcing = useCall<Forcing>(rcApi?.query.staking?.forceEra);
const eraLabel = t('era');
const sessionLabel = rcApi?.query.babe
? t('epoch')
: t('session');
const activeEraStart = sessionInfo?.activeEraStart.unwrapOr(null);
const eraProgress = useMemo(() => {
if (!ahSessionInfo) {
return BN_ZERO;
}
const currentEraStart = ahSessionInfo.activeEraStart.unwrapOrDefault();
if (currentEraStart.isZero()) {
return BN_ZERO;
}
const currentTimestamp = new BN(Math.floor(Date.now()));
const elapsed = currentTimestamp.sub(currentEraStart);
return elapsed.div(blockTime);
}, [ahSessionInfo, blockTime]);
const eraDuration = useMemo(
() => {
const epochDuration = rcApi?.consts.babe?.epochDuration;
const sessionsPerEra = api?.consts.staking?.sessionsPerEra;
return epochDuration?.mul(sessionsPerEra);
},
[api?.consts.staking?.sessionsPerEra, rcApi?.consts.babe?.epochDuration]
);
return (
<>
{rcApi?.derive.session && (
<>
{withSession && (
rcApi?.query.babe
? (
<CardSummary
apiOverride={rcApi}
className={className}
label={sessionLabel}
progress={{
isBlurred: !sessionInfo,
total: sessionInfo?.sessionLength || BN_THREE,
value: sessionInfo?.sessionProgress || BN_TWO,
withTime: true
}}
/>
)
: (
<CardSummary
apiOverride={rcApi}
label={sessionLabel}
>
#{sessionInfo
? formatNumber(sessionInfo.currentIndex)
: <span className='--tmp'>123</span>}
{withEra && activeEraStart && <div className='isSecondary'>&nbsp;</div>}
</CardSummary>
)
)}
<CardSummary
apiOverride={rcApi}
className={className}
label={eraLabel}
progress={{
isBlurred: !(sessionInfo && forcing),
total: sessionInfo && forcing
? forcing.isForceAlways
? sessionInfo.sessionLength
: eraDuration
: BN_THREE,
value: sessionInfo && forcing
? forcing.isForceAlways
? sessionInfo.sessionProgress
: eraProgress
: BN_TWO,
withTime: true
}}
/>
</>
)}
</>
);
}
export default React.memo(SummarySession);
@@ -0,0 +1,120 @@
// Copyright 2017-2025 @polkadot/app-staking-async authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { DeriveHeartbeats, DeriveStakingOverview } from '@polkadot/api-derive/types';
import type { NominatedByMap, SortedTargets } from '@polkadot/app-staking/types';
import type { StakerState } from '@polkadot/react-hooks/types';
import type { BN } from '@polkadot/util';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Button, ToggleGroup } from '@polkadot/react-components';
import { useApi, useBlockAuthors, useCall } from '@polkadot/react-hooks';
import { useTranslation } from '../translate.js';
import ActionsBanner from './ActionsBanner.js';
import CurrentList from './CurrentList.js';
import StakingAsyncOverview from './StakingAsyncOverview.js';
import Summary from './Summary.js';
interface Props {
className?: string;
favorites: string[];
hasAccounts: boolean;
hasQueries: boolean;
minCommission?: BN;
nominatedBy?: NominatedByMap;
ownStashes?: StakerState[];
paraValidators?: Record<string, boolean>;
stakingOverview?: DeriveStakingOverview;
targets: SortedTargets;
toggleFavorite: (address: string) => void;
toggleLedger?: () => void;
toggleNominatedBy: () => void;
}
const EMPTY_PARA_VALS: Record<string, boolean> = {};
const EMPTY_BY_AUTHOR: Record<string, string> = {};
const EMPTY_ERA_POINTS: Record<string, string> = {};
function Overview ({ className = '', favorites, hasAccounts, hasQueries, minCommission, nominatedBy, ownStashes, paraValidators, stakingOverview, targets, toggleFavorite, toggleLedger, toggleNominatedBy }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api } = useApi();
const { byAuthor, eraPoints } = useBlockAuthors();
const [intentIndex, _setIntentIndex] = useState(0);
const [typeIndex, setTypeIndex] = useState(1);
const recentlyOnline = useCall<DeriveHeartbeats>(api.derive.imOnline?.receivedHeartbeats);
const setIntentIndex = useCallback(
(index: number): void => {
index && toggleNominatedBy();
_setIntentIndex(index);
},
[toggleNominatedBy]
);
const filterOptions = useRef([
{ text: t('Own validators'), value: 'mine' },
{ text: t('All validators'), value: 'all' }
]);
const intentOptions = useRef([
{ text: t('Active'), value: 'active' },
{ text: t('Waiting'), value: 'waiting' }
]);
const ownStashIds = useMemo(
() => ownStashes?.map(({ stashId }) => stashId),
[ownStashes]
);
useEffect((): void => {
toggleLedger && toggleLedger();
}, [toggleLedger]);
const isOwn = typeIndex === 0;
return (
<div className={`${className} staking--Overview`}>
<StakingAsyncOverview withIcon />
<Summary
stakingOverview={stakingOverview}
targets={targets}
/>
{hasAccounts && (ownStashes?.length === 0) && (
<ActionsBanner />
)}
<Button.Group>
<ToggleGroup
onChange={setTypeIndex}
options={filterOptions.current}
value={typeIndex}
/>
<ToggleGroup
onChange={setIntentIndex}
options={intentOptions.current}
value={intentIndex}
/>
</Button.Group>
<CurrentList
byAuthor={intentIndex === 0 ? byAuthor : EMPTY_BY_AUTHOR}
eraPoints={intentIndex === 0 ? eraPoints : EMPTY_ERA_POINTS}
favorites={favorites}
hasQueries={hasQueries}
isIntentions={intentIndex === 1}
isOwn={isOwn}
key={intentIndex}
minCommission={intentIndex === 0 ? minCommission : undefined}
nominatedBy={intentIndex === 1 ? nominatedBy : undefined}
ownStashIds={ownStashIds}
paraValidators={(intentIndex === 0 && paraValidators) || EMPTY_PARA_VALS}
recentlyOnline={intentIndex === 0 ? recentlyOnline : undefined}
stakingOverview={stakingOverview}
targets={targets}
toggleFavorite={toggleFavorite}
/>
</div>
);
}
export default React.memo(Overview);