Staking Next Page (#11483)

* feat: initialized staking-next page

* feat: enhance staking-next page with dynamic tabs and translation support

* feat: enabled payouts page for staking-next

* feat: enabled pools page for staking-next

* feat: enabled bags page for staking-next

* fix: build errors

* chore: added translation files

* refactor: relay and system staking page

* feat: enabled overview page for staking-next

* feat: enabled actions page for staking-next

* feat: enabled targets page for staking-next

* feat: enabled query page for staking-next

* feat: add Command Center component and integrate with Relay Staking Page

* feat: limit event display to a maximum of 5 and adjust styling in Command Center

* feat: display more info for relay command center

* chore: added translations

* refactor: relay section for command center

* feat: storage fetch for ah api

* feat: integrate Command Center into StakingApp routing

* feat: add AssetHubSection component and update RelaySection styling

* chore: added translation

* feat: implement API check for staking-next route

* feat: enhance AssetHubSection with detailed multiblock and staking summaries

* chore: added translations

* chore: delete folder

* chore: added folders

* feat: update layout and styling in CommandCenter components

* feat: improve layout of relay chain and events summary in AssetHubSection

* feat: add Slashes component to StakingApp and update TypeScript paths

* feat: enhance CommandCenter with relay and para endpoints integration

* feat: refactor CommandCenter to integrate relay and para endpoints with improved state management

* feat: enhance AssetHubSection and RelaySection with API connection indicators

* feat: enhance CommandCenter and RelaySection with children prop and dropdown for API selection

* feat: improve API disconnection handling and reset state in CommandCenter

* feat: improve command center API handling and enhance relay section styling

* feat: add translations for Active Validators and All Validators in locale files

* feat: update route path for targets to all-validators in StakingApp

* feat: update README with details on Asset Hub migration and staking workflow changes

* feat: update staking-related API references to use assetHubStakingClient and improve UI components

* feat: rename stakign-next to staking-async

* fix: pallets and events renaming

* feat: enhance AssetHub and Relay sections with URL and relay chain support

* fix: avoid duplication of events
This commit is contained in:
Arjun Porwal 2025-06-19 10:34:38 +05:30 committed by GitHub
parent 2180b3bff9
commit d449a243f9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 1238 additions and 6 deletions

View File

@ -38,6 +38,7 @@ import settings from './settings.js';
import signing from './signing.js'; import signing from './signing.js';
import society from './society.js'; import society from './society.js';
import staking from './staking.js'; import staking from './staking.js';
import stakingAsync from './staking-async.js';
import staking2 from './staking2.js'; import staking2 from './staking2.js';
import stakingLegacy from './stakingLegacy.js'; import stakingLegacy from './stakingLegacy.js';
import storage from './storage.js'; import storage from './storage.js';
@ -58,6 +59,8 @@ export default function create (t: TFunction): Routes {
poll(t), poll(t),
transfer(t), transfer(t),
teleport(t), teleport(t),
// Staking for AssetHub Migration
stakingAsync(t),
staking(t), staking(t),
staking2(t), staking2(t),
// Legacy staking Pre v14 pallet version. // Legacy staking Pre v14 pallet version.

View File

@ -0,0 +1,29 @@
// Copyright 2017-2025 @polkadot/apps-routing authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ApiPromise } from '@polkadot/api';
import type { Route, TFunction } from './types.js';
import Component from '@polkadot/app-staking-async';
function needsApiCheck (api: ApiPromise): boolean {
try {
return !!((api.tx.stakingAhClient) || (api.tx.staking && api.tx.stakingRcClient));
} catch {
return false;
}
}
export default function create (t: TFunction): Route {
return {
Component,
display: {
needsApi: [],
needsApiCheck
},
group: 'network',
icon: 'certificate',
name: 'staking-async',
text: t('nav.staking-async', 'Staking Async', { ns: 'apps-routing' })
};
}

View File

@ -41,6 +41,7 @@
{ "path": "../page-signing/tsconfig.build.json" }, { "path": "../page-signing/tsconfig.build.json" },
{ "path": "../page-society/tsconfig.build.json" }, { "path": "../page-society/tsconfig.build.json" },
{ "path": "../page-staking/tsconfig.build.json" }, { "path": "../page-staking/tsconfig.build.json" },
{ "path": "../page-staking-async/tsconfig.build.json" },
{ "path": "../page-staking2/tsconfig.build.json" }, { "path": "../page-staking2/tsconfig.build.json" },
{ "path": "../page-staking-legacy/tsconfig.build.json" }, { "path": "../page-staking-legacy/tsconfig.build.json" },
{ "path": "../page-storage/tsconfig.build.json" }, { "path": "../page-storage/tsconfig.build.json" },

View File

@ -0,0 +1,28 @@
{
"Accounts": "Accounts",
"Active Validators": "Active 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 events available": "No events available",
"Payouts": "Payouts",
"Pools": "Pools",
"Relay chain": "Relay chain",
"Slashes": "Slashes",
"There is a validator set queued in ah-client.": "There is a validator set queued in ah-client.",
"Validator stats": "Validator stats",
"active era": "active era",
"current era": "current era",
"era session index": "era session index",
"events": "events",
"historical range": "historical range",
"id": "id",
"multiblock phase": "multiblock phase",
"multiblock queued score": "multiblock queued score",
"number of validators": "number of validators",
"session": "session",
"signed submissions": "signed submissions",
"snapshot range": "snapshot range"
}

View File

@ -34,6 +34,7 @@
"nav.signing": "Sign and verify", "nav.signing": "Sign and verify",
"nav.society": "Society", "nav.society": "Society",
"nav.staking": "Staking", "nav.staking": "Staking",
"nav.staking-async": "Staking Async",
"nav.storage": "Chain state", "nav.storage": "Chain state",
"nav.sudo": "Sudo", "nav.sudo": "Sudo",
"nav.tech-comm": "Tech. comm.", "nav.tech-comm": "Tech. comm.",

View File

@ -30,6 +30,7 @@
"app-settings.json", "app-settings.json",
"app-signing.json", "app-signing.json",
"app-society.json", "app-society.json",
"app-staking-async.json",
"app-staking-legacy.json", "app-staking-legacy.json",
"app-staking.json", "app-staking.json",
"app-staking2.json", "app-staking2.json",

View File

@ -40,6 +40,7 @@
"Accounts": "", "Accounts": "",
"Accounts injected from any of these extensions will appear in this application and be available for use. The above list is updated as more extensions with external signing capability become available.": "", "Accounts injected from any of these extensions will appear in this application and be available for use. The above list is updated as more extensions with external signing capability become available.": "",
"Active": "", "Active": "",
"Active Validators": "",
"Active nominations ({{count}})": "", "Active nominations ({{count}})": "",
"Add": "", "Add": "",
"Add Bounty": "", "Add Bounty": "",
@ -70,6 +71,7 @@
"Addresses": "", "Addresses": "",
"Advanced creation options": "", "Advanced creation options": "",
"After delay": "", "After delay": "",
"All Validators": "",
"All active/available cores": "", "All active/available cores": "",
"All active/available tracks": "", "All active/available tracks": "",
"All available slices": "", "All available slices": "",
@ -104,6 +106,7 @@
"Apps Version": "", "Apps Version": "",
"As a council member, you can suggest an initial value for the tip, each other council member can suggest their own.": "", "As a council member, you can suggest an initial value for the tip, each other council member can suggest their own.": "",
"As such it is recommended that you setup a proxy to control operations via the stash.": "", "As such it is recommended that you setup a proxy to control operations via the stash.": "",
"Asset Hub chain": "",
"At block": "", "At block": "",
"Auctions": "", "Auctions": "",
"Auctions will be deprecated in favor of Coretime. When Coretime is active in Polkadot, this page will be removed.": "", "Auctions will be deprecated in favor of Coretime. When Coretime is active in Polkadot, this page will be removed.": "",
@ -177,6 +180,7 @@
"Close deadline": "", "Close deadline": "",
"Close proposal": "", "Close proposal": "",
"Color": "", "Color": "",
"Command Center": "",
"Committee prime member, default voting": "", "Committee prime member, default voting": "",
"Completed": "", "Completed": "",
"Confirm ABI removal": "", "Confirm ABI removal": "",
@ -360,6 +364,7 @@
"Join nomination pool": "", "Join nomination pool": "",
"Judge": "", "Judge": "",
"Keys from rotateKeys": "", "Keys from rotateKeys": "",
"Last Session Report End Index": "",
"Latency": "", "Latency": "",
"Learn more...": "", "Learn more...": "",
"Ledger app type (originated from)": "", "Ledger app type (originated from)": "",
@ -969,6 +974,7 @@
"There are no pending proposals": "", "There are no pending proposals": "",
"There are no registered parachains": "", "There are no registered parachains": "",
"There are no unapplied/pending slashes": "", "There are no unapplied/pending slashes": "",
"There is a validator set queued in ah-client.": "",
"There is an existing reference count on the sender account. As such the account cannot be reaped from the state.": "", "There is an existing reference count on the sender account. As such the account cannot be reaped from the state.": "",
"There is currently an ongoing election for new validator candidates. As such staking operations are not permitted.": "", "There is currently an ongoing election for new validator candidates. As such staking operations are not permitted.": "",
"There is no on-chain attestation statement associated with the Ethereum account {{ethereumAddress}}": "", "There is no on-chain attestation statement associated with the Ethereum account {{ethereumAddress}}": "",
@ -1184,6 +1190,7 @@
"activate": "", "activate": "",
"active": "", "active": "",
"active / nominators": "", "active / nominators": "",
"active era": "",
"active issuance": "", "active issuance": "",
"active raised / cap": "", "active raised / cap": "",
"active total": "", "active total": "",
@ -1352,6 +1359,7 @@
"current approval (failing)": "", "current approval (failing)": "",
"current approval (passing)": "", "current approval (passing)": "",
"current curator": "", "current curator": "",
"current era": "",
"current lease": "", "current lease": "",
"current price": "", "current price": "",
"current range winning bid": "", "current range winning bid": "",
@ -1435,6 +1443,7 @@
"epoch": "", "epoch": "",
"era": "", "era": "",
"era points": "", "era points": "",
"era session index": "",
"era {{era}}": "", "era {{era}}": "",
"era {{era}}, {{count}} slashes": "", "era {{era}}, {{count}} slashes": "",
"era {{era}}/unapplied": "", "era {{era}}/unapplied": "",
@ -1500,6 +1509,7 @@
"hex-encoded call": "", "hex-encoded call": "",
"hex-encoded storage key": "", "hex-encoded storage key": "",
"historic results": "", "historic results": "",
"historical range": "",
"holders": "", "holders": "",
"https://example.com": "", "https://example.com": "",
"id": "", "id": "",
@ -1601,6 +1611,8 @@
"mnemonic seed": "", "mnemonic seed": "",
"mortal, valid from #{{startAt}} to #{{endsAt}}": "", "mortal, valid from #{{startAt}} to #{{endsAt}}": "",
"motions": "", "motions": "",
"multiblock phase": "",
"multiblock queued score": "",
"multisig": "", "multisig": "",
"multisig call data": "", "multisig call data": "",
"multisig name": "", "multisig name": "",
@ -1631,6 +1643,7 @@
"nominators": "", "nominators": "",
"nominators to be removed": "", "nominators to be removed": "",
"none": "", "none": "",
"number of validators": "",
"of {{locked}} vested": "", "of {{locked}} vested": "",
"on-chain bonding duration": "", "on-chain bonding duration": "",
"on-chain multisig accounts": "", "on-chain multisig accounts": "",
@ -1797,7 +1810,9 @@
"signature crypto type": "", "signature crypto type": "",
"signature of supplied data": "", "signature of supplied data": "",
"signature {{type}}": "", "signature {{type}}": "",
"signed submissions": "",
"skeptic": "", "skeptic": "",
"snapshot range": "",
"society head": "", "society head": "",
"sold/offered": "", "sold/offered": "",
"somebody@example.com": "", "somebody@example.com": "",

View File

View File

View File

@ -0,0 +1,11 @@
# @polkadot/app-staking-async
With Asset Hub migration, the workflow of the staking system will change. The "user interactions" (nominate, bond, etc) are by and large the same, yet some details outlined below will change:
On the relay chain, the session pallet will rotate session (aka. epochs) at a fixed rate as it did before. It will send these to AH in the form of a SessionReport message. Possibly, it will also send messages about offences to AH so that they can be applied and actually slash staker balances in AH.
`pallet-session` on the relay chain will only interact with `pallet-staking-async-ah-client`. `ah-client` could at any point, if it has one, return a validator set to session to be used for the next session.
In any session change in pallet-session where a new validator set is activated, that SessionReport will contain two pieces of information:
More Info can be found here - https://hackmd.io/7PiBrGxxRG2ib-WRZYJZhQ

View File

@ -0,0 +1,23 @@
{
"bugs": "https://github.com/polkadot-js/apps/issues",
"engines": {
"node": ">=18"
},
"homepage": "https://github.com/polkadot-js/apps/tree/master/packages/page-staking-async#readme",
"license": "Apache-2.0",
"name": "@polkadot/app-staking-async",
"private": true,
"repository": {
"directory": "packages/page-staking-async",
"type": "git",
"url": "https://github.com/polkadot-js/apps.git"
},
"sideEffects": false,
"type": "module",
"version": "0.152.2-11-x",
"peerDependencies": {
"react": "*",
"react-dom": "*",
"react-is": "*"
}
}

View File

@ -0,0 +1,193 @@
// Copyright 2017-2025 @polkadot/app-staking-async authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ReactNode } from 'react';
import type { ApiPromise } from '@polkadot/api';
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 { Event as EventDisplay } from '@polkadot/react-params';
import { formatNumber } from '@polkadot/util';
import { useTranslation } from '../translate.js';
interface Props {
children: ReactNode;
ahApi?: ApiPromise;
ahOutput: IAhOutput[];
ahUrl: string;
isRelayChain: boolean
}
function AssetHubSection ({ ahApi, ahOutput, ahUrl, children, isRelayChain }: Props) {
const { t } = useTranslation();
return (
<div>
<h1
style={{
alignItems: 'center',
display: 'flex',
textTransform: 'capitalize'
}}
>
{t('Asset Hub chain')}
{children}
</h1>
{!ahApi && <Spinner label='Connecting to Asset Hub' />}
<StyledSection>
{ahOutput.map((ah) => {
return (
<div
className='assethub__chain'
key={ah.finalizedBlock}
>
<div className='details'>
<div className='session__summary'>
<h4 className='--digits'>
{!isRelayChain
? <Link to={`/explorer/query/${ah.finalizedBlock}`}>#{formatNumber(ah.finalizedBlock)}</Link>
: (
<Link
target='_blank'
to={`${window.location.origin}/?rpc=${ahUrl}#/explorer/query/${ah.finalizedBlock}`}
>
#{formatNumber(ah.finalizedBlock)}
</Link>)
}
</h4>
</div>
<div>
<div className='staking__summary'>
<CardSummary label={t('current era')}>
{ah.staking.currentEra}
</CardSummary>
<CardSummary label={t('era session index')}>
{ah.staking.erasStartSessionIndex}
</CardSummary>
<CardSummary label={t('active era')}>
{ah.staking.activeEra.index}
</CardSummary>
</div>
<div className='multiblock__summary'>
<CardSummary label={t('multiblock phase')}>
{ah.multiblock.phase}
</CardSummary>
{ah.multiblock.queuedScore &&
<CardSummary label={t('multiblock queued score')}>
{ah.multiblock.queuedScore}
</CardSummary>}
<CardSummary label={t('signed submissions')}>
{ah.multiblock.signedSubmissions}
</CardSummary>
{!!ah.multiblock.snapshotRange.length &&
<CardSummary label={t('snapshot range')}>
{ah.multiblock.snapshotRange}
</CardSummary>}
</div>
<div className='rcClient__summary'>
<CardSummary label={t('Last Session Report End Index')}>
{ah.rcClient.lastSessionReportEndIndex}
</CardSummary>
</div>
</div>
</div>
<div className='events__summary'>
<h3>{t('events')}</h3>
{ah.events.map((event) => {
const eventName = `${event.section}.${event.method}`;
return (
<Expander
isLeft
key={event.index.toString()}
summary={eventName}
summaryMeta={event.meta}
>
<EventDisplay
className='details'
eventName={eventName}
value={event}
withExpander
/>
</Expander>
);
})}
{ah.events.length === 0 && <MarkWarning content={t('No events available')} />}
</div>
</div>
);
})}
</StyledSection>
</div>);
}
const StyledSection = styled.section`
margin-block: 1rem;
overflow: auto;
white-space: nowrap;
.warning {
max-width: fit-content;
margin-left: 0;
}
.ui--Labelled-content {
font-size: var(--font-size-h3);
font-weight: var(--font-weight-normal);
}
.assethub__chain {
display: grid;
grid-template-columns: repeat(2, 1fr);
place-items: start;
background: var(--bg-table);
margin-bottom: 0.35rem;
padding: 0.8rem 1rem;
border-radius: 0.5rem;
.details {
display: flex;
align-items: center;
gap: 0.8rem;
.session__summary {
display: flex;
align-items: center;
h4 {
font-weight: 400;
font-size: medium;
}
}
.staking__summary {
display: flex;
justify-content: end;
}
.multiblock__summary, .rcClient__summary {
display: flex;
justify-content: end;
margin-top: 1.5rem;
}
}
.events__summary {
display: grid;
gap: 1rem;
justify-self: center;
h3 {
font-weight: 500;
font-size: var(--font-size-h2);
}
}
}
`;
export default React.memo(AssetHubSection);

View File

@ -0,0 +1,359 @@
// Copyright 2017-2025 @polkadot/app-staking-async authors & contributors
// SPDX-License-Identifier: Apache-2.0
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 { 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 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: {
index: number,
hasQueuedInSession: boolean,
historicalRange?: [number, number]
},
stakingAhClient: {
mode: string
hasNextActiveId?: number,
hasQueuedInClient?: [number, AccountId32[]]
},
events: Event[]
}
export interface IAhOutput {
finalizedBlock: number,
staking: {
currentEra: number,
activeEra: {index: number, start: string},
erasStartSessionIndex?: number,
bondedEras: Vec<ITuple<[u32, u32]>>
},
rcClient: {
lastSessionReportEndIndex: string
},
multiblock: {
phase: string,
round: number,
snapshotRange: string[]
queuedScore: string|null,
signedSubmissions: number
},
events: Event[]
}
const commandCenterHandler = async (
rcApi: ApiPromise,
ahApi: ApiPromise,
setRcOutout: React.Dispatch<React.SetStateAction<IRcOutput[]>>,
setAhOutout: React.Dispatch<React.SetStateAction<IAhOutput[]>>
): Promise<void> => {
await rcApi.rpc.chain.subscribeFinalizedHeads(async (header) => {
// --- RC:
// current session index
const index = await rcApi.query.session.currentIndex();
// whether the session pallet has a queued validator set within it
const hasQueuedInSession = await rcApi.query.session.queuedChanged();
// the range of historical session data that we have in the RC.
const historicalRange = await rcApi.query.historical.storedRange();
// whether there is a validator set queued in ah-client. for this we need to display only the id and the length of the set.
const hasQueuedInClient =
await rcApi.query.stakingAhClient.validatorSet();
// whether we have already passed a new validator set to session, and therefore in the next session rotation we want to pass this id to AH.
const hasNextActiveId =
await rcApi.query.stakingAhClient.nextSessionChangesValidators();
// Operating mode of the client.
const mode = await rcApi.query.stakingAhClient.mode();
// Events that we are interested in from RC:
const eventsOfInterest = (await (await rcApi.at(header.hash.toHex())).query.system.events())
.map((e) => e.event)
.filter((e) => {
const ahClientEvents = (e: IEventData) =>
e.section === 'stakingAhClient';
const sessionEvents = (e: IEventData) =>
e.section === 'session' || e.section === 'historical';
return ahClientEvents(e.data) || sessionEvents(e.data);
});
setRcOutout((prev) => {
const newItem = {
events: eventsOfInterest,
finalizedBlock: header.number.toNumber(),
session: {
hasQueuedInSession: hasQueuedInSession.isTrue,
historicalRange: historicalRange.isSome
? [historicalRange.unwrap()[0].toNumber(), historicalRange.unwrap()[1].toNumber()] as [number, number]
: undefined,
index: index.toNumber()
},
stakingAhClient: {
hasNextActiveId: hasNextActiveId.isEmpty
? undefined
: rcApi.createType('Option<u32>', hasNextActiveId).unwrap().toNumber(),
hasQueuedInClient: (() => {
const parsed = rcApi.createType('Option<(u32,Vec<AccountId32>)>', hasQueuedInClient);
return parsed.isNone ? undefined : [parsed.unwrap()[0].toNumber(), parsed.unwrap()[1]] as [number, AccountId32[]];
})(),
mode: mode.toString()
}
};
return [newItem, ...prev]
.reduce((acc, curr) => {
if (!acc.find((item) => item.finalizedBlock === curr.finalizedBlock)) {
acc.push(curr);
}
return acc;
}, [] as typeof prev)
.slice(0, MAX_EVENTS);
});
});
await ahApi.rpc.chain.subscribeFinalizedHeads(async (header) => {
// the current planned era
const currentEra = (await ahApi.query.staking.currentEra()).unwrap();
// the active era
const activeEra = (await ahApi.query.staking.activeEra()).unwrap();
// the starting index of the active era
const bondedEras = await ahApi.query.staking.bondedEras();
const activeEraStartSessionIndex = bondedEras.find(([e]) => e.eq(activeEra.index))?.[1];
// the starting index of the active era
// const erasStartSessionIndex = await ahApi.query.staking.erasStartSessionIndex(activeEra.unwrap().index);
// the basic state of the election provider
const phase = await ahApi.query.multiBlockElection.currentPhase();
const round = await ahApi.query.multiBlockElection.round();
const snapshotRange = (
await ahApi.query.multiBlockElection.pagedVoterSnapshotHash.entries()
)
.map(([k]) => k.args[0])
.sort();
const queuedScore =
await ahApi.query.multiBlockElectionVerifier.queuedSolutionScore(round);
const signedSubmissions =
await ahApi.query.multiBlockElectionSigned.sortedScores(round);
// The client
const lastSessionReportEndIndex = await ahApi.query.stakingRcClient.lastSessionReportEndingIndex();
// Events that we are interested in from RC:
const eventsOfInterest = (await (await ahApi.at(header.hash.toHex())).query.system.events())
.map((e) => e.event)
.filter((e) => {
const election = (e: IEventData) => e.section === 'multiBlockElection' || e.section === 'multiBlockElectionVerifier' || e.section === 'multiBlockElectionSigned' || e.section === 'multiBlockElectionUnsigned';
const rcClient = (e: IEventData) => e.section === 'stakingRcClient';
const staking = (e: IEventData) => e.section === 'staking' && (e.method === 'EraPaid' || e.method === 'SessionRotated' || e.method === 'PagedElectionProceeded');
return election(e.data) || rcClient(e.data) || staking(e.data);
});
setAhOutout((prev) => {
const parsedQueuedScore = ahApi.createType('Option<SpNposElectionsElectionScore>', queuedScore);
const newItem = {
events: eventsOfInterest,
finalizedBlock: header.number.toNumber(),
multiblock: {
phase: phase.toString(),
queuedScore: parsedQueuedScore.isSome ? parsedQueuedScore.unwrap().toString() : null,
round: ahApi.createType('u32', round).toNumber(),
signedSubmissions: ahApi.createType(
'Vec<(AccountId32,SpNposElectionsElectionScore)>',
signedSubmissions
).length,
snapshotRange: snapshotRange.map((a) => a.toString())
},
rcClient: { lastSessionReportEndIndex: lastSessionReportEndIndex.toString() },
staking: {
activeEra: {
index: activeEra.index.toNumber(),
start: activeEra.toString()
},
bondedEras,
currentEra: currentEra.toNumber(),
erasStartSessionIndex: activeEraStartSessionIndex?.toNumber()
}
};
return [newItem, ...prev]
.reduce((acc, curr) => {
if (!acc.find((item) => item.finalizedBlock === curr.finalizedBlock)) {
acc.push(curr);
}
return acc;
}, [] as typeof prev)
.slice(0, MAX_EVENTS);
});
});
};
function CommandCenter () {
const { api, apiEndpoint, apiUrl } = useApi();
const [rcOutput, setRcOutput] = useState<IRcOutput[]>([]);
const [ahOutput, setAhOutput] = useState<IAhOutput[]>([]);
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 rcEndPointOptions = useRef(rcEndPoints.map((e) => ({ text: e, value: e })));
const ahEndPointOptions = useRef(ahEndPoints.map((e) => ({ text: e, value: e })));
const _onSelectAhUrl = useCallback((newAhUrl: string) => {
if (newAhUrl !== ahUrl) {
ahApi?.disconnect().catch(console.log);
setAhUrl(newAhUrl);
}
}, [ahApi, ahUrl]);
const _onSelectRcUrl = useCallback((newRcUrl: string) => {
if (newRcUrl !== rcUrl) {
rcApi?.disconnect().catch(console.log);
setRcUrl(newRcUrl);
}
}, [rcApi, rcUrl]);
useEffect(() => {
if (isRelayChain) {
setRcUrl(apiUrl);
const ahUrl = ahEndPoints.at(0);
setAhUrl(ahUrl);
} else {
setAhUrl(apiUrl);
const rcUrl = rcEndPoints.at(0);
setRcUrl(rcUrl);
}
}, [ahEndPoints, apiUrl, isRelayChain, rcEndPoints]);
useEffect(() => {
setRcApi(undefined);
setAhApi(undefined);
setRcOutput([]);
setAhOutput([]);
if (isRelayChain) {
setRcApi(api);
if (ahUrl) {
getApi(ahUrl).then((ahApi) => setAhApi(ahApi)).catch(console.log);
}
} else if (api.tx.staking && api.tx.stakingRcClient) { // Check if Asset Hub chain
setAhApi(api);
if (rcUrl) {
getApi(rcUrl).then((rcApi) => setRcApi(rcApi)).catch(console.log);
}
}
}, [ahUrl, api, isRelayChain, rcUrl]);
useEffect(() => {
ahApi && rcApi && commandCenterHandler(rcApi, ahApi, setRcOutput, setAhOutput).catch(console.log);
}, [ahApi, rcApi]);
return (
<StyledDiv>
<RelaySection
isRelayChain={!!isRelayChain}
rcApi={rcApi}
rcOutput={rcOutput}
rcUrl={rcUrl || ''}
>
<Dropdown
defaultValue={rcUrl}
isButton
isDisabled={!!isRelayChain}
onChange={_onSelectRcUrl}
options={rcEndPointOptions.current}
/>
</RelaySection>
<AssetHubSection
ahApi={ahApi}
ahOutput={ahOutput}
ahUrl={ahUrl || ''}
isRelayChain={!!isRelayChain}
>
<Dropdown
defaultValue={ahUrl}
isButton
isDisabled={!isRelayChain}
onChange={_onSelectAhUrl}
options={ahEndPointOptions.current}
/>
</AssetHubSection>
</StyledDiv>
);
}
const StyledDiv = styled.div`
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
@media screen and (max-width: 1200px){
grid-template-columns: repeat(1, 1fr);
}
.ui--Spinner {
margin-top: 4rem;
}
.ui {
margin-left: 0.5rem !important;
width: 20rem !important;
}
`;
export default React.memo(CommandCenter);

View File

@ -0,0 +1,171 @@
// Copyright 2017-2025 @polkadot/app-staking-async authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ReactNode } from 'react';
import type { ApiPromise } from '@polkadot/api';
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 { Event as EventDisplay } from '@polkadot/react-params';
import { formatNumber } from '@polkadot/util';
import { useTranslation } from '../translate.js';
interface Props {
children: ReactNode;
rcApi?: ApiPromise;
rcOutput: IRcOutput[];
rcUrl: string;
isRelayChain: boolean
}
function RelaySection ({ children, isRelayChain, rcApi, rcOutput, rcUrl }: Props) {
const { t } = useTranslation();
return (
<div>
<h1
style={{
alignItems: 'center',
display: 'flex',
textTransform: 'capitalize'
}}
>
{t('Relay chain')}
{children}
</h1>
{!rcApi && <Spinner label='Connecting to Relay chain' />}
<StyledSection>
{rcOutput.map((rc) => {
return (
<div
className='relay__chain'
key={rc.finalizedBlock}
>
<div className='details'>
<div className='session__summary'>
<h4 className='--digits'>
{isRelayChain
? <Link to={`/explorer/query/${rc.finalizedBlock}`}>#{formatNumber(rc.finalizedBlock)}</Link>
: (
<Link
target='_blank'
to={`${window.location.origin}/?rpc=${rcUrl}#/explorer/query/${rc.finalizedBlock}`}
>
#{formatNumber(rc.finalizedBlock)}
</Link>)
}
</h4>
<CardSummary label={t('session')}>
#{formatNumber(rc.session.index)}
</CardSummary>
{rc.session.historicalRange &&
<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.')} />
<CardSummary label={t('id')}>
{rc.stakingAhClient.hasQueuedInClient[0]}
</CardSummary>
<CardSummary label={t('number of validators')}>
{rc.stakingAhClient.hasQueuedInClient[1].length}
</CardSummary>
</div>}
</div>
</div>
<div className='events__summary'>
<h3>{t('events')}</h3>
{rc.events.map((event) => {
const eventName = `${event.section}.${event.method}`;
return (
<Expander
isLeft
key={event.index.toString()}
summary={eventName}
summaryMeta={event.meta}
>
<EventDisplay
className='details'
eventName={eventName}
value={event}
withExpander
/>
</Expander>
);
})}
{rc.events.length === 0 && <MarkWarning content={t('No events available')} />}
</div>
</div>
);
})}
</StyledSection>
</div>);
}
const StyledSection = styled.section`
margin-block: 1rem;
overflow: auto;
.warning {
max-width: fit-content;
margin-left: 0;
}
.ui--Labelled-content {
font-size: var(--font-size-h3);
font-weight: var(--font-weight-normal);
}
.relay__chain {
display: grid;
grid-template-columns: repeat(2, 1fr);
background: var(--bg-table);
margin-bottom: 0.35rem;
padding: 0.8rem 1rem;
border-radius: 0.5rem;
.details {
display: flex;
flex-direction: column;
gap: 0.8rem;
.session__summary {
display: flex;
align-items: center;
h4 {
font-weight: 400;
font-size: medium;
}
}
.stakingAhClient__summary {
.stakingAhClient__hasQueuedInClient {
display: flex;
justify-content: space-evenly;
}
}
}
.events__summary {
justify-self: center;
h3 {
font-weight: 500;
font-size: var(--font-size-h2);
}
}
}
`;
export default React.memo(RelaySection);

View File

@ -0,0 +1,40 @@
// 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 React, { useMemo } from 'react';
import { Route, Routes } from 'react-router-dom';
import { Tabs } from '@polkadot/react-components';
import CommandCenter from '../CommandCenter/index.js';
import { useTranslation } from '../translate.js';
function StakingApp ({ basePath }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const items = useMemo(() => [
{
isRoot: true,
name: 'command-center',
text: t('Command Center')
}], [t]);
return <>
<Tabs
basePath={basePath}
items={items}
/>
<Routes>
<Route path={basePath}>
<Route
element={<CommandCenter />}
index
/>
</Route>
</Routes>
</>;
}
export default React.memo(StakingApp);

View File

@ -0,0 +1,240 @@
// 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 { AppProps as Props } from '@polkadot/react-components/types';
import type { ElectionStatus, ParaValidatorIndex, ValidatorId } from '@polkadot/types/interfaces';
import type { BN } from '@polkadot/util';
import React, { useCallback, useMemo, useState } from 'react';
import { Route, Routes } from 'react-router';
import Actions from '@polkadot/app-staking/Actions';
import Bags from '@polkadot/app-staking/Bags';
import Payouts from '@polkadot/app-staking/Payouts';
import Query from '@polkadot/app-staking/Query';
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';
import { useAccounts, useApi, useAvailableSlashes, useCall, useCallMulti, useFavorites, useOwnStashInfos } from '@polkadot/react-hooks';
import { isFunction } from '@polkadot/util';
import CommandCenter from '../CommandCenter/index.js';
import { STORE_FAVS_BASE } from '../constants.js';
import { useTranslation } from '../translate.js';
const HIDDEN_ACC = ['actions', 'payout'];
const OPT_MULTI = {
defaultValue: [false, undefined, {}] as [boolean, BN | undefined, Record<string, boolean>],
transform: ([eraElectionStatus, minValidatorBond, validators, activeValidatorIndices]: [ElectionStatus | null, BN | undefined, ValidatorId[] | null, ParaValidatorIndex[] | null]): [boolean, BN | undefined, Record<string, boolean>] => [
!!eraElectionStatus && eraElectionStatus.isOpen,
minValidatorBond && !minValidatorBond.isZero()
? minValidatorBond
: undefined,
validators && activeValidatorIndices
? activeValidatorIndices.reduce((all, index) => ({ ...all, [validators[index.toNumber()].toString()]: true }), {})
: {}
]
};
function StakingApp ({ basePath }: 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);
const { areAccountsLoaded, hasAccounts } = useAccounts();
const ownStashes = useOwnStashInfos();
const slashes = useAvailableSlashes();
const targets = useSortedTargets(favorites, withLedger);
const [isInElection, minCommission, paraValidators] = useCallMulti<[boolean, BN | undefined, Record<string, boolean>]>([
api.query.staking.eraElectionStatus,
api.query.staking.minCommission,
api.query.session.validators,
(api.query.parasShared || api.query.shared)?.activeValidatorIndices
], OPT_MULTI);
const nominatedBy = useNominations(loadNominations);
const ownPools = useOwnPools();
const stakingOverview = useCall<DeriveStakingOverview>(api.derive.staking.overview);
const toggleNominatedBy = useCallback(
() => setLoadNominations(true),
[]
);
const toggleLedger = useCallback(
() => setWithLedger(true),
[]
);
const hasQueries = useMemo(
() => hasAccounts && !!(api.query.imOnline?.authoredBlocks) && !!(api.query.staking.activeEra),
[api, hasAccounts]
);
const hasStashes = useMemo(
() => hasAccounts && !!ownStashes && (ownStashes.length !== 0),
[hasAccounts, ownStashes]
);
const ownValidators = useMemo(
() => (ownStashes || []).filter(({ isStashValidating }) => isStashValidating),
[ownStashes]
);
const items = useMemo(() => [
{
isRoot: true,
name: 'active-validators',
text: t('Active Validators')
},
{
name: 'actions',
text: t('Accounts')
},
hasStashes && isFunction(api.query.staking.activeEra) && {
name: 'payout',
text: t('Payouts')
},
isFunction(api.query.nominationPools?.minCreateBond) && {
name: 'pools',
text: t('Pools')
},
{
alias: 'returns',
name: 'all-validators',
text: t('All Validators')
},
hasStashes && isFunction((api.query.voterBagsList || api.query.bagsList || api.query.voterList)?.counterForListNodes) && {
name: 'bags',
text: t('Bags')
},
{
count: slashes.reduce((count, [, unapplied]) => count + unapplied.length, 0),
name: 'slashes',
text: t('Slashes')
},
{
hasParams: true,
name: 'query',
text: t('Validator stats')
},
{
name: 'command-center',
text: t('Command Center')
}
].filter((q): q is { name: string; text: string } => !!q), [api, hasStashes, slashes, t]);
return (
<>
<Tabs
basePath={basePath}
hidden={
areAccountsLoaded && !hasAccounts
? HIDDEN_ACC
: undefined
}
items={items}
/>
<Routes>
<Route path={basePath}>
<Route
element={
<Bags ownStashes={ownStashes} />
}
path='bags'
/>
<Route
element={
<Payouts
historyDepth={targets.historyDepth}
isInElection={isInElection}
ownPools={ownPools}
ownValidators={ownValidators}
/>
}
path='payout'
/>
<Route
element={
<Pools ownPools={ownPools} />
}
path='pools'
/>
<Route
element={
<Query basePath={basePath} />
}
path='query/:value?'
/>
<Route
element={
<Actions
isInElection={isInElection}
minCommission={minCommission}
ownPools={ownPools}
ownStashes={ownStashes}
targets={targets}
/>
}
path='actions'
/>
<Route
element={
<Targets
isInElection={isInElection}
nominatedBy={nominatedBy}
ownStashes={ownStashes}
stakingOverview={stakingOverview}
targets={targets}
toggleFavorite={toggleFavorite}
toggleLedger={toggleLedger}
toggleNominatedBy={toggleNominatedBy}
/>
}
path='all-validators'
/>
<Route
element={
<Slashes
ownStashes={ownStashes}
slashes={slashes}
/>
}
path='slashes'
/>
<Route
element={<CommandCenter />}
path='command-center'
/>
<Route
element={
<Validators
favorites={favorites}
hasAccounts={hasAccounts}
hasQueries={hasQueries}
minCommission={minCommission}
nominatedBy={nominatedBy}
ownStashes={ownStashes}
paraValidators={paraValidators}
stakingOverview={stakingOverview}
targets={targets}
toggleFavorite={toggleFavorite}
toggleNominatedBy={toggleNominatedBy}
/>
}
index
/>
</Route>
</Routes>
</>
);
}
export default React.memo(StakingApp);

View File

@ -0,0 +1,4 @@
// Copyright 2017-2025 @polkadot/app-staking-async authors & contributors
// SPDX-License-Identifier: Apache-2.0
export const STORE_FAVS_BASE = 'staking:favorites';

View File

@ -0,0 +1,69 @@
// 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 React from 'react';
import { styled } from '@polkadot/react-components';
import { useApi } from '@polkadot/react-hooks';
import StakingRelayApp from './Relay/index.js';
import StakingSystemApp from './System/index.js';
function StakingApp ({ basePath, className = '', onStatusChange }: Props): React.ReactElement<Props> {
const { api } = useApi();
return (
<StyledMain className={`${className} staking--App`}>
{api?.tx?.stakingAhClient
? (
<StakingRelayApp
basePath={basePath}
onStatusChange={onStatusChange}
/>
)
: (
<StakingSystemApp
basePath={basePath}
onStatusChange={onStatusChange}
/>
)
}
</StyledMain>
);
}
const StyledMain = styled.main`
.staking--Chart {
margin-top: 1.5rem;
h1 {
margin-bottom: 0.5rem;
}
.ui--Spinner {
margin: 2.5rem auto;
}
}
.staking--optionsBar {
margin: 0.5rem 0 1rem;
text-align: center;
white-space: normal;
.staking--buttonToggle {
display: inline-block;
margin-right: 1rem;
margin-top: 0.5rem;
}
}
.ui--Expander.stakeOver {
.ui--Expander-summary {
color: var(--color-error);
}
}
`;
export default React.memo(StakingApp);

View File

@ -0,0 +1,8 @@
// Copyright 2017-2025 @polkadot/app-staking-async authors & contributors
// SPDX-License-Identifier: Apache-2.0
import { useTranslation as useTranslationBase } from 'react-i18next';
export function useTranslation (): { t: (key: string, options?: { replace: Record<string, unknown> }) => string } {
return useTranslationBase('app-staking-async');
}

View File

@ -0,0 +1,14 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"baseUrl": "..",
"outDir": "./build",
"rootDir": "./src"
},
"references": [
{ "path": "../react-components/tsconfig.build.json" },
{ "path": "../react-hooks/tsconfig.build.json" },
{ "path": "../page-staking2/tsconfig.build.json" },
{ "path": "../page-staking/tsconfig.build.json" },
]
}

View File

@ -13,16 +13,17 @@ import { useTranslation } from '../translate.js';
import Validator from './Validator.js'; import Validator from './Validator.js';
interface Props { interface Props {
basePath: string,
className?: string; className?: string;
} }
function doQuery (validatorId?: string | null): void { function doQuery (basePath: string, validatorId?: string | null): void {
if (validatorId) { if (validatorId) {
window.location.hash = `/staking/query/${validatorId}`; window.location.hash = `${basePath}/query/${validatorId}`;
} }
} }
function Query ({ className }: Props): React.ReactElement<Props> { function Query ({ basePath, className }: Props): React.ReactElement<Props> {
const { t } = useTranslation(); const { t } = useTranslation();
const { api } = useApi(); const { api } = useApi();
const { value } = useParams<{ value: string }>(); const { value } = useParams<{ value: string }>();
@ -35,8 +36,8 @@ function Query ({ className }: Props): React.ReactElement<Props> {
); );
const _onQuery = useCallback( const _onQuery = useCallback(
() => doQuery(validatorId), () => doQuery(basePath, validatorId),
[validatorId] [basePath, validatorId]
); );
if (!labels) { if (!labels) {

View File

@ -168,7 +168,7 @@ function StakingApp ({ basePath, className = '' }: Props): React.ReactElement<Pr
/> />
<Route <Route
element={ element={
<Query /> <Query basePath={basePath} />
} }
path='query/:value?' path='query/:value?'
/> />

View File

@ -68,6 +68,7 @@ i18next
'app-signing', 'app-signing',
'app-society', 'app-society',
'app-staking', 'app-staking',
'app-staking-async',
'app-staking-legacy', 'app-staking-legacy',
'app-storage', 'app-storage',
'app-sudo', 'app-sudo',

View File

@ -70,6 +70,15 @@
"@polkadot/app-signing": ["page-signing/src/index.tsx"], "@polkadot/app-signing": ["page-signing/src/index.tsx"],
"@polkadot/app-society": ["page-society/src/index.tsx"], "@polkadot/app-society": ["page-society/src/index.tsx"],
"@polkadot/app-staking": ["page-staking/src/index.tsx"], "@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/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"],
"@polkadot/app-staking/Query": ["page-staking/src/Query/index.tsx"],
"@polkadot/app-staking/*": ["page-staking/src/*.ts"],
"@polkadot/app-staking-async": ["page-staking-async/src/index.tsx"],
"@polkadot/app-staking2": ["page-staking2/src/index.tsx"], "@polkadot/app-staking2": ["page-staking2/src/index.tsx"],
"@polkadot/app-staking2/Legend": ["page-staking2/src/Legend.tsx"], "@polkadot/app-staking2/Legend": ["page-staking2/src/Legend.tsx"],
"@polkadot/app-staking2/Pools": ["page-staking2/src/Pools/index.tsx"], "@polkadot/app-staking2/Pools": ["page-staking2/src/Pools/index.tsx"],

View File

@ -51,6 +51,7 @@
{ "path": "./packages/page-signing/tsconfig.build.json" }, { "path": "./packages/page-signing/tsconfig.build.json" },
{ "path": "./packages/page-society/tsconfig.build.json" }, { "path": "./packages/page-society/tsconfig.build.json" },
{ "path": "./packages/page-staking/tsconfig.build.json" }, { "path": "./packages/page-staking/tsconfig.build.json" },
{ "path": "./packages/page-staking-async/tsconfig.build.json" },
{ "path": "./packages/page-staking2/tsconfig.build.json" }, { "path": "./packages/page-staking2/tsconfig.build.json" },
{ "path": "./packages/page-staking-legacy/tsconfig.build.json" }, { "path": "./packages/page-staking-legacy/tsconfig.build.json" },
{ "path": "./packages/page-storage/tsconfig.build.json" }, { "path": "./packages/page-storage/tsconfig.build.json" },

View File

@ -2019,6 +2019,16 @@ __metadata:
languageName: unknown languageName: unknown
linkType: soft linkType: soft
"@polkadot/app-staking-async@workspace:packages/page-staking-async":
version: 0.0.0-use.local
resolution: "@polkadot/app-staking-async@workspace:packages/page-staking-async"
peerDependencies:
react: "*"
react-dom: "*"
react-is: "*"
languageName: unknown
linkType: soft
"@polkadot/app-staking-legacy@workspace:packages/page-staking-legacy": "@polkadot/app-staking-legacy@workspace:packages/page-staking-legacy":
version: 0.0.0-use.local version: 0.0.0-use.local
resolution: "@polkadot/app-staking-legacy@workspace:packages/page-staking-legacy" resolution: "@polkadot/app-staking-legacy@workspace:packages/page-staking-legacy"