diff --git a/packages/apps-routing/src/index.ts b/packages/apps-routing/src/index.ts index 10624d4b1c73..65ba15b671f8 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 stakingNext from './staking-next.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 + stakingNext(t), staking(t), staking2(t), // Legacy staking Pre v14 pallet version. diff --git a/packages/apps-routing/src/staking-next.ts b/packages/apps-routing/src/staking-next.ts new file mode 100644 index 000000000000..44842e42b650 --- /dev/null +++ b/packages/apps-routing/src/staking-next.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-next'; + +function needsApiCheck (api: ApiPromise): boolean { + try { + return !!((api.tx.stakingNextAhClient) || (api.tx.staking && api.tx.stakingNextRcClient)); + } catch { + return false; + } +} + +export default function create (t: TFunction): Route { + return { + Component, + display: { + needsApi: [], + needsApiCheck + }, + group: 'network', + icon: 'certificate', + name: 'staking-next', + text: t('nav.staking-next', 'Staking Next', { ns: 'apps-routing' }) + }; +} diff --git a/packages/apps-routing/tsconfig.build.json b/packages/apps-routing/tsconfig.build.json index b1f8b42f3575..325e6e3a6e59 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-next/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-next.json b/packages/apps/public/locales/en/app-staking-next.json new file mode 100644 index 000000000000..f2c8a1777893 --- /dev/null +++ b/packages/apps/public/locales/en/app-staking-next.json @@ -0,0 +1,28 @@ +{ + "Accounts": "Accounts", + "Active Validators": "Active Validators", + "All Validators": "All Validators", + "Asset Hub chain": "Asset Hub chain", + "Asset Hub client pallet (AhClient) is blocked currently, useful for migration signal from the fellowship.": "Asset Hub client pallet (AhClient) is blocked currently, useful for migration signal from the fellowship.", + "Bags": "Bags", + "Command Center": "Command Center", + "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 c5e93800442b..f60995dedb6c 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-next": "Staking Next", "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 afeac98eba19..dcba0639aad2 100644 --- a/packages/apps/public/locales/en/index.json +++ b/packages/apps/public/locales/en/index.json @@ -31,6 +31,7 @@ "app-signing.json", "app-society.json", "app-staking-legacy.json", + "app-staking-next.json", "app-staking.json", "app-staking2.json", "app-storage.json", diff --git a/packages/apps/public/locales/en/translation.json b/packages/apps/public/locales/en/translation.json index 63fe62a39d45..01e492bc562c 100644 --- a/packages/apps/public/locales/en/translation.json +++ b/packages/apps/public/locales/en/translation.json @@ -39,6 +39,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": "", @@ -69,6 +70,7 @@ "Addresses": "", "Advanced creation options": "", "After delay": "", + "All Validators": "", "All active/available cores": "", "All active/available tracks": "", "All available slices": "", @@ -102,6 +104,8 @@ "Approving of all or none of the options is equivalent and will not affect the outcome of the poll.": "", "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": "", + "Asset Hub client pallet (AhClient) is blocked currently, useful for migration signal from the fellowship.": "", "At block": "", "Auctions": "", "Auctions will be deprecated in favor of Coretime. When Coretime is active in Polkadot, this page will be removed.": "", @@ -175,6 +179,7 @@ "Close deadline": "", "Close proposal": "", "Color": "", + "Command Center": "", "Committee prime member, default voting": "", "Completed": "", "Confirm ABI removal": "", @@ -953,6 +958,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}}": "", @@ -1162,6 +1168,7 @@ "activate": "", "active": "", "active / nominators": "", + "active era": "", "active issuance": "", "active raised / cap": "", "active total": "", @@ -1329,6 +1336,7 @@ "current approval (failing)": "", "current approval (passing)": "", "current curator": "", + "current era": "", "current lease": "", "current price": "", "current range winning bid": "", @@ -1409,6 +1417,7 @@ "epoch": "", "era": "", "era points": "", + "era session index": "", "era {{era}}": "", "era {{era}}, {{count}} slashes": "", "era {{era}}/unapplied": "", @@ -1473,6 +1482,7 @@ "hex-encoded call": "", "hex-encoded storage key": "", "historic results": "", + "historical range": "", "holders": "", "https://example.com": "", "id": "", @@ -1573,6 +1583,8 @@ "mnemonic seed": "", "mortal, valid from #{{startAt}} to #{{endsAt}}": "", "motions": "", + "multiblock phase": "", + "multiblock queued score": "", "multisig": "", "multisig call data": "", "multisig name": "", @@ -1603,6 +1615,7 @@ "nominators": "", "nominators to be removed": "", "none": "", + "number of validators": "", "of {{locked}} vested": "", "on-chain bonding duration": "", "on-chain multisig accounts": "", @@ -1769,7 +1782,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-next/.skip-build b/packages/page-staking-next/.skip-build new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/page-staking-next/.skip-npm b/packages/page-staking-next/.skip-npm new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/page-staking-next/README.md b/packages/page-staking-next/README.md new file mode 100644 index 000000000000..6f910f1cf72b --- /dev/null +++ b/packages/page-staking-next/README.md @@ -0,0 +1,11 @@ +# @polkadot/app-staking-next + +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-next-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-next/package.json b/packages/page-staking-next/package.json new file mode 100644 index 000000000000..484e3377ca76 --- /dev/null +++ b/packages/page-staking-next/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-next#readme", + "license": "Apache-2.0", + "name": "@polkadot/app-staking-next", + "private": true, + "repository": { + "directory": "packages/page-staking-next", + "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-next/src/CommandCenter/ah.tsx b/packages/page-staking-next/src/CommandCenter/ah.tsx new file mode 100644 index 000000000000..0f12280f742b --- /dev/null +++ b/packages/page-staking-next/src/CommandCenter/ah.tsx @@ -0,0 +1,175 @@ +// Copyright 2017-2025 @polkadot/app-staking-next 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[]; +} + +function AssetHubSection ({ ahApi, ahOutput, children }: Props) { + const { t } = useTranslation(); + + return ( +
+

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

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

+ #{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} + } +
+
+
+
+

{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; + + .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 { + 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-next/src/CommandCenter/index.tsx b/packages/page-staking-next/src/CommandCenter/index.tsx new file mode 100644 index 000000000000..ffe8ca81023c --- /dev/null +++ b/packages/page-staking-next/src/CommandCenter/index.tsx @@ -0,0 +1,326 @@ +// Copyright 2017-2025 @polkadot/app-staking-next authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { AccountId32, Event } from '@polkadot/types/interfaces'; +import type { IEventData } from '@polkadot/types/types'; + +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] + }, + stakingNextAhClient: { + isBlocked: boolean + hasNextActiveId?: number, + hasQueuedInClient?: [number, AccountId32[]] + }, + events: Event[] +} + +export interface IAhOutput { + finalizedBlock: number, + staking: { + currentEra: number, + activeEra: {index: number, start: string}, + erasStartSessionIndex: number + }, + 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.stakingNextAhClient.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.stakingNextAhClient.nextSessionChangesValidators(); + // whether the AhClient pallet is blocked or not, useful for migration signal from the fellowship. + const isBlocked = await rcApi.query.stakingNextAhClient?.isBlocked?.(); + + // 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 === 'stakingNextAhClient'; + const sessionEvents = (e: IEventData) => + e.section === 'session' || e.section === 'historical'; + + return ahClientEvents(e.data) || sessionEvents(e.data); + }); + + setRcOutout((prev) => { + const parsedHasQueuedInClient = rcApi.createType('Option<(u32,Vec)>', hasQueuedInClient); + + return [ + { + events: eventsOfInterest, + finalizedBlock: header.number.toNumber(), + session: { + hasQueuedInSession: hasQueuedInSession.isTrue, + historicalRange: historicalRange.isSome ? [historicalRange.unwrap()[0].toNumber(), historicalRange.unwrap()[1].toNumber()] : undefined, + index: index.toNumber() + }, + stakingNextAhClient: { + hasNextActiveId: hasNextActiveId.isEmpty ? undefined : rcApi.createType('Option', hasNextActiveId).unwrap().toNumber(), + hasQueuedInClient: parsedHasQueuedInClient.isNone ? undefined : [parsedHasQueuedInClient.unwrap()[0].toNumber(), parsedHasQueuedInClient.unwrap()[1]], + isBlocked: isBlocked?.toHuman() !== 'Not' + } + }, + ...prev.slice(0, MAX_EVENTS - 1)]; + }); + }); + + await ahApi.rpc.chain.subscribeFinalizedHeads(async (header) => { + // the current planned era + const currentEra = await ahApi.query.staking.currentEra(); + // the active era + const activeEra = await ahApi.query.staking.activeEra(); + // 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.multiBlock.currentPhase(); + const round = await ahApi.query.multiBlock.round(); + const snapshotRange = ( + await ahApi.query.multiBlock.pagedVoterSnapshotHash.entries() + ) + .map(([k]) => k.args[0]) + .sort(); + const queuedScore = + await ahApi.query.multiBlockVerifier.queuedSolutionScore(); + const signedSubmissions = + await ahApi.query.multiBlockSigned.sortedScores(round); + + // 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 === 'multiBlock' || + e.section === 'multiBlockVerifier' || + e.section === 'multiBlockSigned' || + e.section === 'multiBlockUnsigned'; + const rcClient = (e: IEventData) => e.section === 'stakingNextRcClient'; + 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); + + return [ + { + 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()) + }, + staking: { + activeEra: { + index: activeEra.unwrap().index.toNumber(), + start: activeEra.unwrap().toString() + }, + currentEra: currentEra.unwrap().toNumber(), + erasStartSessionIndex: erasStartSessionIndex.unwrap().toNumber() + } + }, + ...prev.slice(0, MAX_EVENTS - 1) + ]; + }); + }); +}; + +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.stakingNextAhClient, [api.tx.stakingNextAhClient]); + + 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.stakingNextRcClient) { // 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-next/src/CommandCenter/relay.tsx b/packages/page-staking-next/src/CommandCenter/relay.tsx new file mode 100644 index 000000000000..58dd47666354 --- /dev/null +++ b/packages/page-staking-next/src/CommandCenter/relay.tsx @@ -0,0 +1,160 @@ +// Copyright 2017-2025 @polkadot/app-staking-next 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[]; +} + +function RelaySection ({ children, rcApi, rcOutput }: Props) { + const { t } = useTranslation(); + + return ( +
+

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

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

+ #{formatNumber(rc.finalizedBlock)} +

+ + #{formatNumber(rc.session.index)} + + {rc.session.historicalRange && + + [{rc.session.historicalRange?.[0]}, {rc.session.historicalRange?.[1]}] + + } +
+
+ {rc.stakingNextAhClient.isBlocked && } + {rc.stakingNextAhClient.hasQueuedInClient && +
+ + + {rc.stakingNextAhClient.hasQueuedInClient[0]} + + + {rc.stakingNextAhClient.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; + } + } + + .stakingNextAhClient__summary { + .stakingNextAhClient__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-next/src/Relay/index.tsx b/packages/page-staking-next/src/Relay/index.tsx new file mode 100644 index 000000000000..a9ba48e8de0a --- /dev/null +++ b/packages/page-staking-next/src/Relay/index.tsx @@ -0,0 +1,40 @@ +// Copyright 2017-2025 @polkadot/app-staking-next 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-next/src/System/index.tsx b/packages/page-staking-next/src/System/index.tsx new file mode 100644 index 000000000000..34997c8a2c81 --- /dev/null +++ b/packages/page-staking-next/src/System/index.tsx @@ -0,0 +1,240 @@ +// Copyright 2017-2025 @polkadot/app-staking-next 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 ( + <> +