Staking Async Feedback (#11681)

* feat: enhance tooltips and warnings in AssetHub and Relay sections

* fix: remove unnecessary tooltip for stakingAhClient mode in RelaySection

* fix: update labels for session index and active era in AssetHubSection

* feat: implement getApi utility function and update CommandCenter and StakingApp components to use it

* feat: created overview page and refactor code for rcApi and ahApi

* feat: implement useActiveNominators hook and update Summary component to display active nominators

* feat: add getGenesis import and update needsApiCheck to hide staking route for Westend relay

* feat: add StakingAsyncOverview component and integrate it into Overview

* fix: formatted snapshot range correctly

* chore: hide staking tab for asset hub chains

* chore: get page index

* chore: hide staking tab for paseo

* fix: update needsApiCheck to hide staking route for specific Relay chains
This commit is contained in:
Arjun Porwal 2025-09-17 09:25:35 +05:30 committed by GitHub
parent 27956a60c3
commit fb53d098a8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 1333 additions and 67 deletions

View File

@ -13,6 +13,11 @@ import { assert, BN_ONE } from '@polkadot/util';
function needsApiCheck (api: ApiPromise): boolean {
try {
// Hide for every Asset Hub chain and for Relay chains which have stakingAhClient storagr
if (api.query.stakingAhClient || api.tx.stakingRcClient) {
return false;
}
// we need a known Exposure type
const { nominatorCount, own, pageCount, total } = api.registry.createType<SpStakingPagedExposureMetadata>(
unwrapStorageType(api.registry, api.query.staking.erasStakersOverview.creator.meta.type),

View File

@ -1,28 +1,58 @@
{
"Accounts": "Accounts",
"Active": "Active",
"Active Validators": "Active Validators",
"All Validators": "All Validators",
"All validators": "All validators",
"Asset Hub chain": "Asset Hub chain",
"Bags": "Bags",
"Command Center": "Command Center",
"Last Session Report End Index": "Last Session Report End Index",
"No active validators found": "No active validators found",
"No events available": "No events available",
"No waiting validators found": "No waiting validators found",
"Nominations ({{count}})": "Nominations ({{count}})",
"Own validators": "Own validators",
"Payouts": "Payouts",
"Pools": "Pools",
"Preparing validator list": "Preparing validator list",
"Relay Active Era Index": "Relay Active Era Index",
"Relay Session Index": "Relay Session Index",
"Relay chain": "Relay chain",
"Renomination required ({{count}})": "Renomination required ({{count}})",
"Retrieving nominators": "Retrieving nominators",
"Retrieving online status": "Retrieving online status",
"Retrieving validator info": "Retrieving validator info",
"Retrieving validators": "Retrieving validators",
"Slashes": "Slashes",
"There is a validator set queued in ah-client.": "There is a validator set queued in ah-client.",
"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.": "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.",
"Validator stats": "Validator stats",
"Waiting": "Waiting",
"active / nominators": "active / nominators",
"active era": "active era",
"commission": "commission",
"current era": "current era",
"era session index": "era session index",
"events": "events",
"graphs": "graphs",
"historic results": "historic results",
"historical range": "historical range",
"id": "id",
"ideal staked": "ideal staked",
"inflation": "inflation",
"intentions": "intentions",
"last #": "last #",
"multiblock phase": "multiblock phase",
"multiblock queued score": "multiblock queued score",
"nominators": "nominators",
"number of validators": "number of validators",
"other stake": "other stake",
"own stake": "own stake",
"session": "session",
"signed submissions": "signed submissions",
"snapshot range": "snapshot range"
"snapshot range": "snapshot range",
"staked": "staked",
"validators": "validators",
"waiting": "waiting"
}

View File

@ -627,6 +627,8 @@
"Reject curator": "",
"Rejection proposal to council": "",
"Rejections": "",
"Relay Active Era Index": "",
"Relay Session Index": "",
"Relay chain": "",
"Remove": "",
"Remove ABI": "",

View File

@ -8,7 +8,7 @@ import type { IAhOutput } from './index.js';
import React from 'react';
import { Link } from 'react-router-dom';
import { CardSummary, Expander, MarkWarning, Spinner, styled } from '@polkadot/react-components';
import { CardSummary, Expander, Icon, MarkWarning, Spinner, styled, Tooltip } from '@polkadot/react-components';
import { Event as EventDisplay } from '@polkadot/react-params';
import { formatNumber } from '@polkadot/util';
@ -40,6 +40,9 @@ function AssetHubSection ({ ahApi, ahOutput, ahUrl, children, isRelayChain }: Pr
{!ahApi && <Spinner label='Connecting to Asset Hub' />}
<StyledSection>
{ahOutput.map((ah) => {
const uniquePages = Array.from(new Set(ah.multiblock.snapshotRange.map((p) => Number(p.toString())))).sort((a, b) => a - b);
const [minPage, maxPage] = [uniquePages[0], uniquePages[uniquePages.length - 1]];
return (
<div
className='assethub__chain'
@ -66,10 +69,10 @@ function AssetHubSection ({ ahApi, ahOutput, ahUrl, children, isRelayChain }: Pr
<CardSummary label={t('current era')}>
{ah.staking.currentEra}
</CardSummary>
<CardSummary label={t('era session index')}>
<CardSummary label={t('Relay Session Index')}>
{ah.staking.erasStartSessionIndex}
</CardSummary>
<CardSummary label={t('active era')}>
<CardSummary label={t('Relay Active Era Index')}>
{ah.staking.activeEra.index}
</CardSummary>
</div>
@ -86,7 +89,7 @@ function AssetHubSection ({ ahApi, ahOutput, ahUrl, children, isRelayChain }: Pr
</CardSummary>
{!!ah.multiblock.snapshotRange.length &&
<CardSummary label={t('snapshot range')}>
{ah.multiblock.snapshotRange}
{`${minPage} → ${maxPage}`}
</CardSummary>}
</div>
<div className='rcClient__summary'>
@ -118,7 +121,19 @@ function AssetHubSection ({ ahApi, ahOutput, ahUrl, children, isRelayChain }: Pr
);
})}
{ah.events.length === 0 && <MarkWarning content={t('No events available')} />}
{ah.events.length === 0 &&
<div className='warning__tooltip'>
<MarkWarning content={t('No events available')} />
<Icon
icon='info-circle'
tooltip={`${ah.finalizedBlock}-ah-no-events`}
/>
<Tooltip
text={'No relevant events found for multiBlockElection, multiBlockElectionVerifier, multiBlockElectionUnsigned, stakingRcClient or staking in the current block.'}
trigger={`${ah.finalizedBlock}-ah-no-events`}
/>
</div>
}
</div>
</div>
);
@ -186,6 +201,11 @@ const StyledSection = styled.section`
font-weight: 500;
font-size: var(--font-size-h2);
}
.warning__tooltip {
display: flex;
align-items: center;
gap: 0.5rem;
}
}
}
`;

View File

@ -1,34 +1,22 @@
// Copyright 2017-2025 @polkadot/app-staking-async authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ApiPromise } from '@polkadot/api';
import type { AccountId32, Event } from '@polkadot/types/interfaces';
import type { IEventData, ITuple } from '@polkadot/types/types';
import type { u32, Vec } from '@polkadot/types-codec';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { ApiPromise, WsProvider } from '@polkadot/api';
import { createWsEndpoints } from '@polkadot/apps-config';
import { Dropdown, styled } from '@polkadot/react-components';
import { useApi } from '@polkadot/react-hooks';
import { getApi } from '../utils.js';
import AssetHubSection from './ah.js';
import RelaySection from './relay.js';
const allEndPoints = createWsEndpoints((k, v) => v?.toString() || k);
const MAX_EVENTS = 25;
const getApi = async (url: string[]|string) => {
const api = await ApiPromise.create({
provider: new WsProvider(url)
});
await api.isReadyOrError;
return api;
};
export interface IRcOutput {
finalizedBlock: number,
session: {
@ -139,7 +127,7 @@ const commandCenterHandler = async (
await ahApi.rpc.chain.subscribeFinalizedHeads(async (header) => {
// the current planned era
const currentEra = (await ahApi.query.staking.currentEra()).unwrap();
const currentEra = await rcApi.query.session.currentIndex();
// the active era
const activeEra = (await ahApi.query.staking.activeEra()).unwrap();
// the starting index of the active era
@ -154,7 +142,7 @@ const commandCenterHandler = async (
const snapshotRange = (
await ahApi.query.multiBlockElection.pagedVoterSnapshotHash.entries()
)
.map(([k]) => k.args[0])
.map(([k]) => k.args[1])
.sort();
const queuedScore =
await ahApi.query.multiBlockElectionVerifier.queuedSolutionScore(round);
@ -216,8 +204,16 @@ const commandCenterHandler = async (
});
};
function CommandCenter () {
const { api, apiEndpoint, apiUrl } = useApi();
interface Props {
ahApi?: ApiPromise
rcApi?: ApiPromise
isRelayChain: boolean
rcEndPoints: string[]
ahEndPoints: string[]
}
function CommandCenter ({ ahApi: initialAhApi, ahEndPoints, isRelayChain, rcApi: initialRcApi, rcEndPoints }: Props) {
const { api, apiUrl } = useApi();
const [rcOutput, setRcOutput] = useState<IRcOutput[]>([]);
const [ahOutput, setAhOutput] = useState<IAhOutput[]>([]);
@ -225,27 +221,8 @@ function CommandCenter () {
const [rcUrl, setRcUrl] = useState<string|undefined>(undefined);
const [ahUrl, setAhUrl] = useState<string|undefined>(undefined);
const [ahApi, setAhApi] = useState<ApiPromise>();
const [rcApi, setRcApi] = useState<ApiPromise>();
// Check if it is relay chain
const isRelayChain = useMemo(() => api.tx.stakingAhClient, [api.tx.stakingAhClient]);
const rcEndPoints = useMemo(() => {
return (isRelayChain
? apiEndpoint?.providers
: apiEndpoint?.valueRelay) || [];
}, [apiEndpoint?.providers, apiEndpoint?.valueRelay, isRelayChain]);
const ahEndPoints: string[] = useMemo(() => {
if (isRelayChain) {
return allEndPoints.find(({ genesisHashRelay, paraId }) =>
paraId === 1000 && genesisHashRelay === api.genesisHash.toHex()
)?.providers || [];
}
return apiEndpoint?.providers || [];
}, [api.genesisHash, apiEndpoint?.providers, isRelayChain]);
const [ahApi, setAhApi] = useState<ApiPromise|undefined>(initialAhApi);
const [rcApi, setRcApi] = useState<ApiPromise|undefined>(initialRcApi);
const rcEndPointOptions = useRef(rcEndPoints.map((e) => ({ text: e, value: e })));
const ahEndPointOptions = useRef(ahEndPoints.map((e) => ({ text: e, value: e })));
@ -267,12 +244,12 @@ function CommandCenter () {
useEffect(() => {
if (isRelayChain) {
setRcUrl(apiUrl);
const ahUrl = ahEndPoints.at(0);
const ahUrl = ahEndPoints.at(Math.floor(Math.random() * ahEndPoints.length));
setAhUrl(ahUrl);
} else {
setAhUrl(apiUrl);
const rcUrl = rcEndPoints.at(0);
const rcUrl = rcEndPoints.at(Math.floor(Math.random() * rcEndPoints.length));
setRcUrl(rcUrl);
}
@ -341,6 +318,10 @@ const StyledDiv = styled.div`
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
.dropdown {
min-width: max-content !important;
}
@media screen and (max-width: 1200px){
grid-template-columns: repeat(1, 1fr);

View File

@ -8,7 +8,7 @@ import type { IRcOutput } from './index.js';
import React from 'react';
import { Link } from 'react-router-dom';
import { CardSummary, Expander, MarkWarning, Spinner, styled } from '@polkadot/react-components';
import { CardSummary, Expander, Icon, MarkWarning, Spinner, styled, Tooltip } from '@polkadot/react-components';
import { Event as EventDisplay } from '@polkadot/react-params';
import { formatNumber } from '@polkadot/util';
@ -60,16 +60,15 @@ function RelaySection ({ children, isRelayChain, rcApi, rcOutput, rcUrl }: Props
}
</h4>
<CardSummary label={t('session')}>
#{formatNumber(rc.session.index)}
#{formatNumber(rc.session.index)}
</CardSummary>
{rc.session.historicalRange &&
<CardSummary label={t('historical range')}>
[{rc.session.historicalRange?.[0]}, {rc.session.historicalRange?.[1]}]
</CardSummary>
<CardSummary label={t('historical range')}>
{`${rc.session.historicalRange?.[0]} → ${rc.session.historicalRange?.[1]}`}
</CardSummary>
}
</div>
<div className='stakingAhClient__summary'>
<MarkWarning content={rc.stakingAhClient.mode} />
{rc.stakingAhClient.hasQueuedInClient &&
<div className='stakingAhClient__hasQueuedInClient'>
<MarkWarning content={t('There is a validator set queued in ah-client.')} />
@ -104,7 +103,19 @@ function RelaySection ({ children, isRelayChain, rcApi, rcOutput, rcUrl }: Props
);
})}
{rc.events.length === 0 && <MarkWarning content={t('No events available')} />}
{rc.events.length === 0 &&
<div className='warning__tooltip'>
<MarkWarning content={t('No events available')} />
<Icon
icon='info-circle'
tooltip={`${rc.finalizedBlock}-rc-no-events`}
/>
<Tooltip
text={'No relevant events found for stakingAhClient, session, or historical in the current block.'}
trigger={`${rc.finalizedBlock}-rc-no-events`}
/>
</div>
}
</div>
</div>
);
@ -127,6 +138,12 @@ const StyledSection = styled.section`
font-weight: var(--font-weight-normal);
}
.warning__tooltip {
display: flex;
align-items: center;
gap: 0.5rem;
}
.relay__chain {
display: grid;
grid-template-columns: repeat(2, 1fr);

View File

@ -1,7 +1,8 @@
// Copyright 2017-2025 @polkadot/app-staking-async authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { AppProps as Props } from '@polkadot/react-components/types';
import type { ApiPromise } from '@polkadot/api';
import type { AppProps } from '@polkadot/react-components/types';
import React, { useMemo } from 'react';
import { Route, Routes } from 'react-router-dom';
@ -11,7 +12,15 @@ import { Tabs } from '@polkadot/react-components';
import CommandCenter from '../CommandCenter/index.js';
import { useTranslation } from '../translate.js';
function StakingApp ({ basePath }: Props): React.ReactElement<Props> {
interface Props extends AppProps {
ahApi?: ApiPromise
rcApi?: ApiPromise
isRelayChain: boolean
rcEndPoints: string[]
ahEndPoints: string[]
}
function StakingApp ({ ahApi, ahEndPoints, basePath, isRelayChain, rcApi, rcEndPoints }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const items = useMemo(() => [
@ -29,7 +38,15 @@ function StakingApp ({ basePath }: Props): React.ReactElement<Props> {
<Routes>
<Route path={basePath}>
<Route
element={<CommandCenter />}
element={
<CommandCenter
ahApi={ahApi}
ahEndPoints={ahEndPoints}
isRelayChain={isRelayChain}
rcApi={rcApi}
rcEndPoints={rcEndPoints}
/>
}
index
/>
</Route>

View File

@ -1,8 +1,9 @@
// Copyright 2017-2025 @polkadot/app-staking-async authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ApiPromise } from '@polkadot/api';
import type { DeriveStakingOverview } from '@polkadot/api-derive/types';
import type { AppProps as Props } from '@polkadot/react-components/types';
import type { AppProps } from '@polkadot/react-components/types';
import type { ElectionStatus, ParaValidatorIndex, ValidatorId } from '@polkadot/types/interfaces';
import type { BN } from '@polkadot/util';
@ -17,7 +18,6 @@ import Slashes from '@polkadot/app-staking/Slashes';
import Targets from '@polkadot/app-staking/Targets';
import useNominations from '@polkadot/app-staking/useNominations';
import useSortedTargets from '@polkadot/app-staking/useSortedTargets';
import Validators from '@polkadot/app-staking/Validators';
import Pools from '@polkadot/app-staking2/Pools';
import useOwnPools from '@polkadot/app-staking2/Pools/useOwnPools';
import { Tabs } from '@polkadot/react-components';
@ -27,6 +27,7 @@ import { isFunction } from '@polkadot/util';
import CommandCenter from '../CommandCenter/index.js';
import { STORE_FAVS_BASE } from '../constants.js';
import { useTranslation } from '../translate.js';
import Validators from '../Validators/index.js';
const HIDDEN_ACC = ['actions', 'payout'];
@ -43,9 +44,18 @@ const OPT_MULTI = {
]
};
function StakingApp ({ basePath }: Props): React.ReactElement<Props> {
interface Props extends AppProps {
ahApi?: ApiPromise
rcApi?: ApiPromise
isRelayChain: boolean
rcEndPoints: string[]
ahEndPoints: string[]
}
function StakingApp ({ ahApi, ahEndPoints, basePath, isRelayChain, rcApi, rcEndPoints }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api } = useApi();
const [withLedger, setWithLedger] = useState(false);
const [favorites, toggleFavorite] = useFavorites(STORE_FAVS_BASE);
const [loadNominations, setLoadNominations] = useState(false);
@ -61,7 +71,7 @@ function StakingApp ({ basePath }: Props): React.ReactElement<Props> {
], OPT_MULTI);
const nominatedBy = useNominations(loadNominations);
const ownPools = useOwnPools();
const stakingOverview = useCall<DeriveStakingOverview>(api.derive.staking.overview);
const stakingOverview = useCall<DeriveStakingOverview>(rcApi?.derive.staking.overview);
const toggleNominatedBy = useCallback(
() => setLoadNominations(true),
@ -210,7 +220,15 @@ function StakingApp ({ basePath }: Props): React.ReactElement<Props> {
path='slashes'
/>
<Route
element={<CommandCenter />}
element={
<CommandCenter
ahApi={ahApi}
ahEndPoints={ahEndPoints}
isRelayChain={isRelayChain}
rcApi={rcApi}
rcEndPoints={rcEndPoints}
/>
}
path='command-center'
/>
<Route

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -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;
}

View File

@ -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);

View File

@ -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);

View File

@ -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 SummarySession from '@polkadot/app-explorer/SummarySession';
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';
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);

View File

@ -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);

View File

@ -1,32 +1,82 @@
// Copyright 2017-2025 @polkadot/app-staking-async authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ApiPromise } from '@polkadot/api';
import type { AppProps as Props } from '@polkadot/react-components/types';
import React from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { createWsEndpoints } from '@polkadot/apps-config';
import { styled } from '@polkadot/react-components';
import { useApi } from '@polkadot/react-hooks';
import StakingRelayApp from './Relay/index.js';
import StakingSystemApp from './System/index.js';
import { getApi } from './utils.js';
const allEndPoints = createWsEndpoints((k, v) => v?.toString() || k);
function StakingApp ({ basePath, className = '', onStatusChange }: Props): React.ReactElement<Props> {
const { api } = useApi();
const { api, apiEndpoint } = useApi();
const [ahApi, setAhApi] = useState<ApiPromise>();
const [rcApi, setRcApi] = useState<ApiPromise>();
const isRelayChain = useMemo(() => !!api.tx.stakingAhClient, [api.tx.stakingAhClient]);
const rcEndPoints = useMemo(() => {
return (isRelayChain
? apiEndpoint?.providers
: apiEndpoint?.valueRelay) || [];
}, [apiEndpoint?.providers, apiEndpoint?.valueRelay, isRelayChain]);
const ahEndPoints: string[] = useMemo(() => {
if (isRelayChain) {
return allEndPoints.find(({ genesisHashRelay, paraId }) =>
paraId === 1000 && genesisHashRelay === api.genesisHash.toHex()
)?.providers || [];
}
return apiEndpoint?.providers || [];
}, [api.genesisHash, apiEndpoint?.providers, isRelayChain]);
useEffect(() => {
if (isRelayChain) {
// Pick random endpoint
const ahUrl = ahEndPoints.at(Math.floor(Math.random() * ahEndPoints.length));
!!ahUrl && getApi(ahUrl).then((ahApi) => setAhApi(ahApi)).catch(console.log);
} else {
// Pick random endpoint
const rcUrl = rcEndPoints.at(Math.floor(Math.random() * rcEndPoints.length));
!!rcUrl && getApi(rcUrl).then((rcApi) => setRcApi(rcApi)).catch(console.log);
}
}, [ahEndPoints, isRelayChain, rcEndPoints]);
return (
<StyledMain className={`${className} staking--App`}>
{api?.tx?.stakingAhClient
{isRelayChain
? (
<StakingRelayApp
ahApi={ahApi}
ahEndPoints={ahEndPoints}
basePath={basePath}
isRelayChain={isRelayChain}
onStatusChange={onStatusChange}
rcApi={rcApi}
rcEndPoints={rcEndPoints}
/>
)
: (
<StakingSystemApp
ahApi={ahApi}
ahEndPoints={ahEndPoints}
basePath={basePath}
isRelayChain={isRelayChain}
onStatusChange={onStatusChange}
rcApi={rcApi}
rcEndPoints={rcEndPoints}
/>
)
}

View File

@ -0,0 +1,14 @@
// Copyright 2017-2025 @polkadot/app-staking-async authors & contributors
// SPDX-License-Identifier: Apache-2.0
import { ApiPromise, WsProvider } from '@polkadot/api';
export const getApi = async (url: string[]|string) => {
const api = await ApiPromise.create({
provider: new WsProvider(url)
});
await api.isReadyOrError;
return api;
};

View File

@ -69,6 +69,9 @@ const StyledReactTooltip = styled(ReactTooltip as unknown as React.ComponentType
&.ui--Tooltip {
z-index: 1002;
word-break: break-word; /* Fallback for older browsers */
overflow-wrap: break-word; /* Modern standard */
hyphens: auto; /* Enables automatic hyphenation */
}
table {

View File

@ -72,7 +72,8 @@
"@polkadot/app-staking": ["page-staking/src/index.tsx"],
"@polkadot/app-staking/Payouts": ["page-staking/src/Payouts/index.tsx"],
"@polkadot/app-staking/Bags": ["page-staking/src/Bags/index.tsx"],
"@polkadot/app-staking/Validators": ["page-staking/src/Validators/index.tsx"],
"@polkadot/app-staking/Filtering": ["page-staking/src/Filtering.tsx"],
"@polkadot/app-staking/MaxBadge": ["page-staking/src/MaxBadge.tsx"],
"@polkadot/app-staking/Actions": ["page-staking/src/Actions/index.tsx"],
"@polkadot/app-staking/Targets": ["page-staking/src/Targets/index.tsx"],
"@polkadot/app-staking/Slashes": ["page-staking/src/Slashes/index.tsx"],