From d449a243f9ce3f9d989eee67ff70f49287d25f39 Mon Sep 17 00:00:00 2001 From: Arjun Porwal <65214523+ap211unitech@users.noreply.github.com> Date: Thu, 19 Jun 2025 10:34:38 +0530 Subject: [PATCH] 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 --- packages/apps-routing/src/index.ts | 3 + packages/apps-routing/src/staking-async.ts | 29 ++ packages/apps-routing/tsconfig.build.json | 1 + .../public/locales/en/app-staking-async.json | 28 ++ .../apps/public/locales/en/apps-routing.json | 1 + packages/apps/public/locales/en/index.json | 1 + .../apps/public/locales/en/translation.json | 15 + packages/page-staking-async/.skip-build | 0 packages/page-staking-async/.skip-npm | 0 packages/page-staking-async/README.md | 11 + packages/page-staking-async/package.json | 23 ++ .../src/CommandCenter/ah.tsx | 193 ++++++++++ .../src/CommandCenter/index.tsx | 359 ++++++++++++++++++ .../src/CommandCenter/relay.tsx | 171 +++++++++ .../page-staking-async/src/Relay/index.tsx | 40 ++ .../page-staking-async/src/System/index.tsx | 240 ++++++++++++ packages/page-staking-async/src/constants.ts | 4 + packages/page-staking-async/src/index.tsx | 69 ++++ packages/page-staking-async/src/translate.ts | 8 + .../page-staking-async/tsconfig.build.json | 14 + packages/page-staking/src/Query/index.tsx | 11 +- packages/page-staking/src/index.tsx | 2 +- packages/react-components/src/i18n/index.ts | 1 + tsconfig.base.json | 9 + tsconfig.build.json | 1 + yarn.lock | 10 + 26 files changed, 1238 insertions(+), 6 deletions(-) create mode 100644 packages/apps-routing/src/staking-async.ts create mode 100644 packages/apps/public/locales/en/app-staking-async.json create mode 100644 packages/page-staking-async/.skip-build create mode 100644 packages/page-staking-async/.skip-npm create mode 100644 packages/page-staking-async/README.md create mode 100644 packages/page-staking-async/package.json create mode 100644 packages/page-staking-async/src/CommandCenter/ah.tsx create mode 100644 packages/page-staking-async/src/CommandCenter/index.tsx create mode 100644 packages/page-staking-async/src/CommandCenter/relay.tsx create mode 100644 packages/page-staking-async/src/Relay/index.tsx create mode 100644 packages/page-staking-async/src/System/index.tsx create mode 100644 packages/page-staking-async/src/constants.ts create mode 100644 packages/page-staking-async/src/index.tsx create mode 100644 packages/page-staking-async/src/translate.ts create mode 100644 packages/page-staking-async/tsconfig.build.json diff --git a/packages/apps-routing/src/index.ts b/packages/apps-routing/src/index.ts index 10624d4b1c..39392cc61d 100644 --- a/packages/apps-routing/src/index.ts +++ b/packages/apps-routing/src/index.ts @@ -38,6 +38,7 @@ import settings from './settings.js'; import signing from './signing.js'; import society from './society.js'; import staking from './staking.js'; +import stakingAsync from './staking-async.js'; import staking2 from './staking2.js'; import stakingLegacy from './stakingLegacy.js'; import storage from './storage.js'; @@ -58,6 +59,8 @@ export default function create (t: TFunction): Routes { poll(t), transfer(t), teleport(t), + // Staking for AssetHub Migration + stakingAsync(t), staking(t), staking2(t), // Legacy staking Pre v14 pallet version. diff --git a/packages/apps-routing/src/staking-async.ts b/packages/apps-routing/src/staking-async.ts new file mode 100644 index 0000000000..7e7e036a99 --- /dev/null +++ b/packages/apps-routing/src/staking-async.ts @@ -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' }) + }; +} diff --git a/packages/apps-routing/tsconfig.build.json b/packages/apps-routing/tsconfig.build.json index b1f8b42f35..dfccf8ff9b 100644 --- a/packages/apps-routing/tsconfig.build.json +++ b/packages/apps-routing/tsconfig.build.json @@ -41,6 +41,7 @@ { "path": "../page-signing/tsconfig.build.json" }, { "path": "../page-society/tsconfig.build.json" }, { "path": "../page-staking/tsconfig.build.json" }, + { "path": "../page-staking-async/tsconfig.build.json" }, { "path": "../page-staking2/tsconfig.build.json" }, { "path": "../page-staking-legacy/tsconfig.build.json" }, { "path": "../page-storage/tsconfig.build.json" }, diff --git a/packages/apps/public/locales/en/app-staking-async.json b/packages/apps/public/locales/en/app-staking-async.json new file mode 100644 index 0000000000..2130e0322f --- /dev/null +++ b/packages/apps/public/locales/en/app-staking-async.json @@ -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" +} \ No newline at end of file diff --git a/packages/apps/public/locales/en/apps-routing.json b/packages/apps/public/locales/en/apps-routing.json index c5e9380044..4d191bc658 100644 --- a/packages/apps/public/locales/en/apps-routing.json +++ b/packages/apps/public/locales/en/apps-routing.json @@ -34,6 +34,7 @@ "nav.signing": "Sign and verify", "nav.society": "Society", "nav.staking": "Staking", + "nav.staking-async": "Staking Async", "nav.storage": "Chain state", "nav.sudo": "Sudo", "nav.tech-comm": "Tech. comm.", diff --git a/packages/apps/public/locales/en/index.json b/packages/apps/public/locales/en/index.json index afeac98eba..fb5a4bb32b 100644 --- a/packages/apps/public/locales/en/index.json +++ b/packages/apps/public/locales/en/index.json @@ -30,6 +30,7 @@ "app-settings.json", "app-signing.json", "app-society.json", + "app-staking-async.json", "app-staking-legacy.json", "app-staking.json", "app-staking2.json", diff --git a/packages/apps/public/locales/en/translation.json b/packages/apps/public/locales/en/translation.json index ae18760153..8aa0ca2947 100644 --- a/packages/apps/public/locales/en/translation.json +++ b/packages/apps/public/locales/en/translation.json @@ -40,6 +40,7 @@ "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.": "", "Active": "", + "Active Validators": "", "Active nominations ({{count}})": "", "Add": "", "Add Bounty": "", @@ -70,6 +71,7 @@ "Addresses": "", "Advanced creation options": "", "After delay": "", + "All Validators": "", "All active/available cores": "", "All active/available tracks": "", "All available slices": "", @@ -104,6 +106,7 @@ "Apps Version": "", "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.": "", + "Asset Hub chain": "", "At block": "", "Auctions": "", "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 proposal": "", "Color": "", + "Command Center": "", "Committee prime member, default voting": "", "Completed": "", "Confirm ABI removal": "", @@ -360,6 +364,7 @@ "Join nomination pool": "", "Judge": "", "Keys from rotateKeys": "", + "Last Session Report End Index": "", "Latency": "", "Learn more...": "", "Ledger app type (originated from)": "", @@ -969,6 +974,7 @@ "There are no pending proposals": "", "There are no registered parachains": "", "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 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}}": "", @@ -1184,6 +1190,7 @@ "activate": "", "active": "", "active / nominators": "", + "active era": "", "active issuance": "", "active raised / cap": "", "active total": "", @@ -1352,6 +1359,7 @@ "current approval (failing)": "", "current approval (passing)": "", "current curator": "", + "current era": "", "current lease": "", "current price": "", "current range winning bid": "", @@ -1435,6 +1443,7 @@ "epoch": "", "era": "", "era points": "", + "era session index": "", "era {{era}}": "", "era {{era}}, {{count}} slashes": "", "era {{era}}/unapplied": "", @@ -1500,6 +1509,7 @@ "hex-encoded call": "", "hex-encoded storage key": "", "historic results": "", + "historical range": "", "holders": "", "https://example.com": "", "id": "", @@ -1601,6 +1611,8 @@ "mnemonic seed": "", "mortal, valid from #{{startAt}} to #{{endsAt}}": "", "motions": "", + "multiblock phase": "", + "multiblock queued score": "", "multisig": "", "multisig call data": "", "multisig name": "", @@ -1631,6 +1643,7 @@ "nominators": "", "nominators to be removed": "", "none": "", + "number of validators": "", "of {{locked}} vested": "", "on-chain bonding duration": "", "on-chain multisig accounts": "", @@ -1797,7 +1810,9 @@ "signature crypto type": "", "signature of supplied data": "", "signature {{type}}": "", + "signed submissions": "", "skeptic": "", + "snapshot range": "", "society head": "", "sold/offered": "", "somebody@example.com": "", diff --git a/packages/page-staking-async/.skip-build b/packages/page-staking-async/.skip-build new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/page-staking-async/.skip-npm b/packages/page-staking-async/.skip-npm new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/page-staking-async/README.md b/packages/page-staking-async/README.md new file mode 100644 index 0000000000..f010fd0a1d --- /dev/null +++ b/packages/page-staking-async/README.md @@ -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 \ No newline at end of file diff --git a/packages/page-staking-async/package.json b/packages/page-staking-async/package.json new file mode 100644 index 0000000000..de1e0e7c4f --- /dev/null +++ b/packages/page-staking-async/package.json @@ -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": "*" + } +} diff --git a/packages/page-staking-async/src/CommandCenter/ah.tsx b/packages/page-staking-async/src/CommandCenter/ah.tsx new file mode 100644 index 0000000000..1e468aa8e3 --- /dev/null +++ b/packages/page-staking-async/src/CommandCenter/ah.tsx @@ -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 ( +
+

+ {t('Asset Hub chain')} + {children} +

+ {!ahApi && } + + {ahOutput.map((ah) => { + return ( +
+
+
+

+ {!isRelayChain + ? #{formatNumber(ah.finalizedBlock)} + : ( + + #{formatNumber(ah.finalizedBlock)} + ) + } + +

+
+
+
+ + {ah.staking.currentEra} + + + {ah.staking.erasStartSessionIndex} + + + {ah.staking.activeEra.index} + +
+
+ + {ah.multiblock.phase} + + {ah.multiblock.queuedScore && + + {ah.multiblock.queuedScore} + } + + {ah.multiblock.signedSubmissions} + + {!!ah.multiblock.snapshotRange.length && + + {ah.multiblock.snapshotRange} + } +
+
+ + {ah.rcClient.lastSessionReportEndIndex} + +
+
+
+
+

{t('events')}

+ {ah.events.map((event) => { + const eventName = `${event.section}.${event.method}`; + + return ( + + + + + ); + })} + {ah.events.length === 0 && } +
+
+ ); + })} +
+
); +} + +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); diff --git a/packages/page-staking-async/src/CommandCenter/index.tsx b/packages/page-staking-async/src/CommandCenter/index.tsx new file mode 100644 index 0000000000..4d1a800966 --- /dev/null +++ b/packages/page-staking-async/src/CommandCenter/index.tsx @@ -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> + }, + 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>, + setAhOutout: React.Dispatch> +): Promise => { + 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', hasNextActiveId).unwrap().toNumber(), + hasQueuedInClient: (() => { + const parsed = rcApi.createType('Option<(u32,Vec)>', 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', 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([]); + const [ahOutput, setAhOutput] = useState([]); + + const [rcUrl, setRcUrl] = useState(undefined); + const [ahUrl, setAhUrl] = useState(undefined); + + const [ahApi, setAhApi] = useState(); + const [rcApi, setRcApi] = useState(); + + // 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 ( + + + + + + + + + ); +} + +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); diff --git a/packages/page-staking-async/src/CommandCenter/relay.tsx b/packages/page-staking-async/src/CommandCenter/relay.tsx new file mode 100644 index 0000000000..81dd768a2c --- /dev/null +++ b/packages/page-staking-async/src/CommandCenter/relay.tsx @@ -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 ( +
+

+ {t('Relay chain')} + {children} +

+ {!rcApi && } + + {rcOutput.map((rc) => { + return ( +
+
+
+

+ {isRelayChain + ? #{formatNumber(rc.finalizedBlock)} + : ( + + #{formatNumber(rc.finalizedBlock)} + ) + } +

+ + #{formatNumber(rc.session.index)} + + {rc.session.historicalRange && + + [{rc.session.historicalRange?.[0]}, {rc.session.historicalRange?.[1]}] + + } +
+
+ + {rc.stakingAhClient.hasQueuedInClient && +
+ + + {rc.stakingAhClient.hasQueuedInClient[0]} + + + {rc.stakingAhClient.hasQueuedInClient[1].length} + +
} +
+
+
+

{t('events')}

+ {rc.events.map((event) => { + const eventName = `${event.section}.${event.method}`; + + return ( + + + + + ); + })} + {rc.events.length === 0 && } +
+
+ ); + })} +
+
); +} + +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); diff --git a/packages/page-staking-async/src/Relay/index.tsx b/packages/page-staking-async/src/Relay/index.tsx new file mode 100644 index 0000000000..c192dfbc23 --- /dev/null +++ b/packages/page-staking-async/src/Relay/index.tsx @@ -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 { + const { t } = useTranslation(); + + const items = useMemo(() => [ + { + isRoot: true, + name: 'command-center', + text: t('Command Center') + }], [t]); + + return <> + + + + } + index + /> + + + ; +} + +export default React.memo(StakingApp); diff --git a/packages/page-staking-async/src/System/index.tsx b/packages/page-staking-async/src/System/index.tsx new file mode 100644 index 0000000000..b6faf29a38 --- /dev/null +++ b/packages/page-staking-async/src/System/index.tsx @@ -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], + transform: ([eraElectionStatus, minValidatorBond, validators, activeValidatorIndices]: [ElectionStatus | null, BN | undefined, ValidatorId[] | null, ParaValidatorIndex[] | null]): [boolean, BN | undefined, Record] => [ + !!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 { + 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]>([ + 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(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 ( + <> +