diff --git a/packages/apps-routing/src/staking.ts b/packages/apps-routing/src/staking.ts index 8cb67dde9f..42d1766254 100644 --- a/packages/apps-routing/src/staking.ts +++ b/packages/apps-routing/src/staking.ts @@ -13,6 +13,11 @@ import { assert, BN_ONE } from '@polkadot/util'; function needsApiCheck (api: ApiPromise): boolean { try { + // Hide for every Asset Hub chain and for Relay chains which have stakingAhClient storagr + if (api.query.stakingAhClient || api.tx.stakingRcClient) { + return false; + } + // we need a known Exposure type const { nominatorCount, own, pageCount, total } = api.registry.createType( unwrapStorageType(api.registry, api.query.staking.erasStakersOverview.creator.meta.type), diff --git a/packages/apps/public/locales/en/app-staking-async.json b/packages/apps/public/locales/en/app-staking-async.json index 2130e0322f..e32c6250a4 100644 --- a/packages/apps/public/locales/en/app-staking-async.json +++ b/packages/apps/public/locales/en/app-staking-async.json @@ -1,28 +1,58 @@ { "Accounts": "Accounts", + "Active": "Active", "Active Validators": "Active Validators", "All Validators": "All Validators", + "All validators": "All validators", "Asset Hub chain": "Asset Hub chain", "Bags": "Bags", "Command Center": "Command Center", "Last Session Report End Index": "Last Session Report End Index", + "No active validators found": "No active validators found", "No events available": "No events available", + "No waiting validators found": "No waiting validators found", + "Nominations ({{count}})": "Nominations ({{count}})", + "Own validators": "Own validators", "Payouts": "Payouts", "Pools": "Pools", + "Preparing validator list": "Preparing validator list", + "Relay Active Era Index": "Relay Active Era Index", + "Relay Session Index": "Relay Session Index", "Relay chain": "Relay chain", + "Renomination required ({{count}})": "Renomination required ({{count}})", + "Retrieving nominators": "Retrieving nominators", + "Retrieving online status": "Retrieving online status", + "Retrieving validator info": "Retrieving validator info", + "Retrieving validators": "Retrieving validators", "Slashes": "Slashes", "There is a validator set queued in ah-client.": "There is a validator set queued in ah-client.", + "Use the account actions to create a new validator/nominator stash and bond it to participate in staking. Do not send funds directly via a transfer to a validator.": "Use the account actions to create a new validator/nominator stash and bond it to participate in staking. Do not send funds directly via a transfer to a validator.", "Validator stats": "Validator stats", + "Waiting": "Waiting", + "active / nominators": "active / nominators", "active era": "active era", + "commission": "commission", "current era": "current era", "era session index": "era session index", "events": "events", + "graphs": "graphs", + "historic results": "historic results", "historical range": "historical range", "id": "id", + "ideal staked": "ideal staked", + "inflation": "inflation", + "intentions": "intentions", + "last #": "last #", "multiblock phase": "multiblock phase", "multiblock queued score": "multiblock queued score", + "nominators": "nominators", "number of validators": "number of validators", + "other stake": "other stake", + "own stake": "own stake", "session": "session", "signed submissions": "signed submissions", - "snapshot range": "snapshot range" + "snapshot range": "snapshot range", + "staked": "staked", + "validators": "validators", + "waiting": "waiting" } \ No newline at end of file diff --git a/packages/apps/public/locales/en/translation.json b/packages/apps/public/locales/en/translation.json index f6261d9bbe..445b44c191 100644 --- a/packages/apps/public/locales/en/translation.json +++ b/packages/apps/public/locales/en/translation.json @@ -627,6 +627,8 @@ "Reject curator": "", "Rejection proposal to council": "", "Rejections": "", + "Relay Active Era Index": "", + "Relay Session Index": "", "Relay chain": "", "Remove": "", "Remove ABI": "", diff --git a/packages/page-staking-async/src/CommandCenter/ah.tsx b/packages/page-staking-async/src/CommandCenter/ah.tsx index 1e468aa8e3..b01193cecd 100644 --- a/packages/page-staking-async/src/CommandCenter/ah.tsx +++ b/packages/page-staking-async/src/CommandCenter/ah.tsx @@ -8,7 +8,7 @@ import type { IAhOutput } from './index.js'; import React from 'react'; import { Link } from 'react-router-dom'; -import { CardSummary, Expander, MarkWarning, Spinner, styled } from '@polkadot/react-components'; +import { CardSummary, Expander, Icon, MarkWarning, Spinner, styled, Tooltip } from '@polkadot/react-components'; import { Event as EventDisplay } from '@polkadot/react-params'; import { formatNumber } from '@polkadot/util'; @@ -40,6 +40,9 @@ function AssetHubSection ({ ahApi, ahOutput, ahUrl, children, isRelayChain }: Pr {!ahApi && } {ahOutput.map((ah) => { + const uniquePages = Array.from(new Set(ah.multiblock.snapshotRange.map((p) => Number(p.toString())))).sort((a, b) => a - b); + const [minPage, maxPage] = [uniquePages[0], uniquePages[uniquePages.length - 1]]; + return (
{ah.staking.currentEra} - + {ah.staking.erasStartSessionIndex} - + {ah.staking.activeEra.index}
@@ -86,7 +89,7 @@ function AssetHubSection ({ ahApi, ahOutput, ahUrl, children, isRelayChain }: Pr {!!ah.multiblock.snapshotRange.length && - {ah.multiblock.snapshotRange} + {`${minPage} → ${maxPage}`} }
@@ -118,7 +121,19 @@ function AssetHubSection ({ ahApi, ahOutput, ahUrl, children, isRelayChain }: Pr ); })} - {ah.events.length === 0 && } + {ah.events.length === 0 && +
+ + + +
+ }
); @@ -186,6 +201,11 @@ const StyledSection = styled.section` font-weight: 500; font-size: var(--font-size-h2); } + .warning__tooltip { + display: flex; + align-items: center; + gap: 0.5rem; + } } } `; diff --git a/packages/page-staking-async/src/CommandCenter/index.tsx b/packages/page-staking-async/src/CommandCenter/index.tsx index 4d1a800966..474678f5cc 100644 --- a/packages/page-staking-async/src/CommandCenter/index.tsx +++ b/packages/page-staking-async/src/CommandCenter/index.tsx @@ -1,34 +1,22 @@ // Copyright 2017-2025 @polkadot/app-staking-async authors & contributors // SPDX-License-Identifier: Apache-2.0 +import type { ApiPromise } from '@polkadot/api'; import type { AccountId32, Event } from '@polkadot/types/interfaces'; import type { IEventData, ITuple } from '@polkadot/types/types'; import type { u32, Vec } from '@polkadot/types-codec'; -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { ApiPromise, WsProvider } from '@polkadot/api'; -import { createWsEndpoints } from '@polkadot/apps-config'; import { Dropdown, styled } from '@polkadot/react-components'; import { useApi } from '@polkadot/react-hooks'; +import { getApi } from '../utils.js'; import AssetHubSection from './ah.js'; import RelaySection from './relay.js'; -const allEndPoints = createWsEndpoints((k, v) => v?.toString() || k); - const MAX_EVENTS = 25; -const getApi = async (url: string[]|string) => { - const api = await ApiPromise.create({ - provider: new WsProvider(url) - }); - - await api.isReadyOrError; - - return api; -}; - export interface IRcOutput { finalizedBlock: number, session: { @@ -139,7 +127,7 @@ const commandCenterHandler = async ( await ahApi.rpc.chain.subscribeFinalizedHeads(async (header) => { // the current planned era - const currentEra = (await ahApi.query.staking.currentEra()).unwrap(); + const currentEra = await rcApi.query.session.currentIndex(); // the active era const activeEra = (await ahApi.query.staking.activeEra()).unwrap(); // the starting index of the active era @@ -154,7 +142,7 @@ const commandCenterHandler = async ( const snapshotRange = ( await ahApi.query.multiBlockElection.pagedVoterSnapshotHash.entries() ) - .map(([k]) => k.args[0]) + .map(([k]) => k.args[1]) .sort(); const queuedScore = await ahApi.query.multiBlockElectionVerifier.queuedSolutionScore(round); @@ -216,8 +204,16 @@ const commandCenterHandler = async ( }); }; -function CommandCenter () { - const { api, apiEndpoint, apiUrl } = useApi(); +interface Props { + ahApi?: ApiPromise + rcApi?: ApiPromise + isRelayChain: boolean + rcEndPoints: string[] + ahEndPoints: string[] +} + +function CommandCenter ({ ahApi: initialAhApi, ahEndPoints, isRelayChain, rcApi: initialRcApi, rcEndPoints }: Props) { + const { api, apiUrl } = useApi(); const [rcOutput, setRcOutput] = useState([]); const [ahOutput, setAhOutput] = useState([]); @@ -225,27 +221,8 @@ function CommandCenter () { 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 [ahApi, setAhApi] = useState(initialAhApi); + const [rcApi, setRcApi] = useState(initialRcApi); const rcEndPointOptions = useRef(rcEndPoints.map((e) => ({ text: e, value: e }))); const ahEndPointOptions = useRef(ahEndPoints.map((e) => ({ text: e, value: e }))); @@ -267,12 +244,12 @@ function CommandCenter () { useEffect(() => { if (isRelayChain) { setRcUrl(apiUrl); - const ahUrl = ahEndPoints.at(0); + const ahUrl = ahEndPoints.at(Math.floor(Math.random() * ahEndPoints.length)); setAhUrl(ahUrl); } else { setAhUrl(apiUrl); - const rcUrl = rcEndPoints.at(0); + const rcUrl = rcEndPoints.at(Math.floor(Math.random() * rcEndPoints.length)); setRcUrl(rcUrl); } @@ -341,6 +318,10 @@ const StyledDiv = styled.div` display: grid; grid-template-columns: repeat(2, 1fr); gap: 1rem; + + .dropdown { + min-width: max-content !important; + } @media screen and (max-width: 1200px){ grid-template-columns: repeat(1, 1fr); diff --git a/packages/page-staking-async/src/CommandCenter/relay.tsx b/packages/page-staking-async/src/CommandCenter/relay.tsx index 81dd768a2c..6e6750230b 100644 --- a/packages/page-staking-async/src/CommandCenter/relay.tsx +++ b/packages/page-staking-async/src/CommandCenter/relay.tsx @@ -8,7 +8,7 @@ import type { IRcOutput } from './index.js'; import React from 'react'; import { Link } from 'react-router-dom'; -import { CardSummary, Expander, MarkWarning, Spinner, styled } from '@polkadot/react-components'; +import { CardSummary, Expander, Icon, MarkWarning, Spinner, styled, Tooltip } from '@polkadot/react-components'; import { Event as EventDisplay } from '@polkadot/react-params'; import { formatNumber } from '@polkadot/util'; @@ -60,16 +60,15 @@ function RelaySection ({ children, isRelayChain, rcApi, rcOutput, rcUrl }: Props } - #{formatNumber(rc.session.index)} + #{formatNumber(rc.session.index)} {rc.session.historicalRange && - - [{rc.session.historicalRange?.[0]}, {rc.session.historicalRange?.[1]}] - + + {`${rc.session.historicalRange?.[0]} → ${rc.session.historicalRange?.[1]}`} + }
- {rc.stakingAhClient.hasQueuedInClient &&
@@ -104,7 +103,19 @@ function RelaySection ({ children, isRelayChain, rcApi, rcOutput, rcUrl }: Props ); })} - {rc.events.length === 0 && } + {rc.events.length === 0 && +
+ + + +
+ }
); @@ -127,6 +138,12 @@ const StyledSection = styled.section` font-weight: var(--font-weight-normal); } + .warning__tooltip { + display: flex; + align-items: center; + gap: 0.5rem; + } + .relay__chain { display: grid; grid-template-columns: repeat(2, 1fr); diff --git a/packages/page-staking-async/src/Relay/index.tsx b/packages/page-staking-async/src/Relay/index.tsx index c192dfbc23..92c074d3a2 100644 --- a/packages/page-staking-async/src/Relay/index.tsx +++ b/packages/page-staking-async/src/Relay/index.tsx @@ -1,7 +1,8 @@ // Copyright 2017-2025 @polkadot/app-staking-async authors & contributors // SPDX-License-Identifier: Apache-2.0 -import type { AppProps as Props } from '@polkadot/react-components/types'; +import type { ApiPromise } from '@polkadot/api'; +import type { AppProps } from '@polkadot/react-components/types'; import React, { useMemo } from 'react'; import { Route, Routes } from 'react-router-dom'; @@ -11,7 +12,15 @@ import { Tabs } from '@polkadot/react-components'; import CommandCenter from '../CommandCenter/index.js'; import { useTranslation } from '../translate.js'; -function StakingApp ({ basePath }: Props): React.ReactElement { +interface Props extends AppProps { + ahApi?: ApiPromise + rcApi?: ApiPromise + isRelayChain: boolean + rcEndPoints: string[] + ahEndPoints: string[] +} + +function StakingApp ({ ahApi, ahEndPoints, basePath, isRelayChain, rcApi, rcEndPoints }: Props): React.ReactElement { const { t } = useTranslation(); const items = useMemo(() => [ @@ -29,7 +38,15 @@ function StakingApp ({ basePath }: Props): React.ReactElement { } + element={ + + } index /> diff --git a/packages/page-staking-async/src/System/index.tsx b/packages/page-staking-async/src/System/index.tsx index b6faf29a38..5bab2c288d 100644 --- a/packages/page-staking-async/src/System/index.tsx +++ b/packages/page-staking-async/src/System/index.tsx @@ -1,8 +1,9 @@ // Copyright 2017-2025 @polkadot/app-staking-async authors & contributors // SPDX-License-Identifier: Apache-2.0 +import type { ApiPromise } from '@polkadot/api'; import type { DeriveStakingOverview } from '@polkadot/api-derive/types'; -import type { AppProps as Props } from '@polkadot/react-components/types'; +import type { AppProps } from '@polkadot/react-components/types'; import type { ElectionStatus, ParaValidatorIndex, ValidatorId } from '@polkadot/types/interfaces'; import type { BN } from '@polkadot/util'; @@ -17,7 +18,6 @@ import Slashes from '@polkadot/app-staking/Slashes'; import Targets from '@polkadot/app-staking/Targets'; import useNominations from '@polkadot/app-staking/useNominations'; import useSortedTargets from '@polkadot/app-staking/useSortedTargets'; -import Validators from '@polkadot/app-staking/Validators'; import Pools from '@polkadot/app-staking2/Pools'; import useOwnPools from '@polkadot/app-staking2/Pools/useOwnPools'; import { Tabs } from '@polkadot/react-components'; @@ -27,6 +27,7 @@ import { isFunction } from '@polkadot/util'; import CommandCenter from '../CommandCenter/index.js'; import { STORE_FAVS_BASE } from '../constants.js'; import { useTranslation } from '../translate.js'; +import Validators from '../Validators/index.js'; const HIDDEN_ACC = ['actions', 'payout']; @@ -43,9 +44,18 @@ const OPT_MULTI = { ] }; -function StakingApp ({ basePath }: Props): React.ReactElement { +interface Props extends AppProps { + ahApi?: ApiPromise + rcApi?: ApiPromise + isRelayChain: boolean + rcEndPoints: string[] + ahEndPoints: string[] +} + +function StakingApp ({ ahApi, ahEndPoints, basePath, isRelayChain, rcApi, rcEndPoints }: Props): React.ReactElement { const { t } = useTranslation(); const { api } = useApi(); + const [withLedger, setWithLedger] = useState(false); const [favorites, toggleFavorite] = useFavorites(STORE_FAVS_BASE); const [loadNominations, setLoadNominations] = useState(false); @@ -61,7 +71,7 @@ function StakingApp ({ basePath }: Props): React.ReactElement { ], OPT_MULTI); const nominatedBy = useNominations(loadNominations); const ownPools = useOwnPools(); - const stakingOverview = useCall(api.derive.staking.overview); + const stakingOverview = useCall(rcApi?.derive.staking.overview); const toggleNominatedBy = useCallback( () => setLoadNominations(true), @@ -210,7 +220,15 @@ function StakingApp ({ basePath }: Props): React.ReactElement { path='slashes' /> } + element={ + + } path='command-center' /> | null { + const { t } = useTranslation(); + + return ( + + ); +} + +export default React.memo(ActionsBanner); diff --git a/packages/page-staking-async/src/Validators/Address/NominatedBy.tsx b/packages/page-staking-async/src/Validators/Address/NominatedBy.tsx new file mode 100644 index 0000000000..f052b30547 --- /dev/null +++ b/packages/page-staking-async/src/Validators/Address/NominatedBy.tsx @@ -0,0 +1,88 @@ +// Copyright 2017-2025 @polkadot/app-staking-async authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { ApiPromise } from '@polkadot/api'; +import type { NominatedBy as NominatedByType } from '@polkadot/app-staking/types'; +import type { SlashingSpans } from '@polkadot/types/interfaces'; + +import React, { useMemo } from 'react'; + +import { AddressMini, ExpanderScroll } from '@polkadot/react-components'; +import { useApi } from '@polkadot/react-hooks'; +import { formatNumber } from '@polkadot/util'; + +import { useTranslation } from '../../translate.js'; + +interface Props { + nominators?: NominatedByType[]; + slashingSpans?: SlashingSpans | null; +} + +interface Chilled { + active: null | [number, () => React.ReactNode[]]; + chilled: null | [number, () => React.ReactNode[]]; +} + +function extractFunction (all: string[]): null | [number, () => React.ReactNode[]] { + return all.length + ? [ + all.length, + () => all.map((value): React.ReactNode => + + ) + ] + : null; +} + +function extractChilled (api: ApiPromise, nominators: NominatedByType[] = [], slashingSpans?: SlashingSpans | null): Chilled { + // NOTE With the introduction of the SlashReported event, + // nominators are not auto-chilled on validator slash + const chilled = slashingSpans && !api.events.staking.SlashReported + ? nominators + .filter(({ submittedIn }) => + slashingSpans.lastNonzeroSlash.gt(submittedIn) + ) + .map(({ nominatorId }) => nominatorId) + : []; + + return { + active: extractFunction( + nominators + .filter(({ nominatorId }) => !chilled.includes(nominatorId)) + .map(({ nominatorId }) => nominatorId) + ), + chilled: extractFunction(chilled) + }; +} + +function NominatedBy ({ nominators, slashingSpans }: Props): React.ReactElement { + const { t } = useTranslation(); + const { api } = useApi(); + + const { active, chilled } = useMemo( + () => extractChilled(api, nominators, slashingSpans), + [api, nominators, slashingSpans] + ); + + return ( + + {active && ( + + )} + {chilled && ( + + )} + + ); +} + +export default React.memo(NominatedBy); diff --git a/packages/page-staking-async/src/Validators/Address/StakeOther.tsx b/packages/page-staking-async/src/Validators/Address/StakeOther.tsx new file mode 100644 index 0000000000..35c8f02d4b --- /dev/null +++ b/packages/page-staking-async/src/Validators/Address/StakeOther.tsx @@ -0,0 +1,101 @@ +// Copyright 2017-2025 @polkadot/app-staking-async authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { u32 } from '@polkadot/types'; +import type { NominatorValue } from './types.js'; + +import React, { useMemo } from 'react'; + +import { AddressMini, ExpanderScroll } from '@polkadot/react-components'; +import { useApi } from '@polkadot/react-hooks'; +import { FormatBalance } from '@polkadot/react-query'; +import { BN, BN_ZERO } from '@polkadot/util'; + +interface Props { + stakeOther?: BN; + nominators?: NominatorValue[]; +} + +function extractFunction (all: NominatorValue[]): null | [number, () => React.ReactNode[]] { + return [ + all.length, + () => all.map(({ nominatorId, value }): React.ReactNode => + + ) + ]; +} + +function sumValue (all: { value: BN }[]): BN { + const total = new BN(0); + + for (let i = 0, count = all.length; i < count; i++) { + total.iadd(all[i].value); + } + + return total; +} + +function extractTotals (maxPaid: BN | undefined, nominators?: NominatorValue[], stakeOther?: BN): [null | [number, () => React.ReactNode[]], BN, null | [number, () => React.ReactNode[]], BN] { + if (!nominators) { + return [null, BN_ZERO, null, BN_ZERO]; + } + + const sorted = nominators.sort((a, b) => b.value.cmp(a.value)); + + if (!maxPaid || maxPaid.gtn(sorted.length)) { + return [extractFunction(sorted), stakeOther || BN_ZERO, null, BN_ZERO]; + } + + const max = maxPaid.toNumber(); + const rewarded = sorted.slice(0, max); + const rewardedTotal = sumValue(rewarded); + const unrewarded = sorted.slice(max); + const unrewardedTotal = sumValue(unrewarded); + + return [extractFunction(rewarded), rewardedTotal, extractFunction(unrewarded), unrewardedTotal]; +} + +function StakeOther ({ nominators, stakeOther }: Props): React.ReactElement { + const { api } = useApi(); + + const [rewarded, rewardedTotal, unrewarded, unrewardedTotal] = useMemo( + () => extractTotals(api.consts.staking?.maxNominatorRewardedPerValidator as u32, nominators, stakeOther), + [api, nominators, stakeOther] + ); + + return ( + + {(!rewarded || rewarded[0] !== 0) && ( + + } + /> + )} + {unrewarded && ( + + } + /> + )} + + ); +} + +export default React.memo(StakeOther); diff --git a/packages/page-staking-async/src/Validators/Address/Status.tsx b/packages/page-staking-async/src/Validators/Address/Status.tsx new file mode 100644 index 0000000000..e69410d61e --- /dev/null +++ b/packages/page-staking-async/src/Validators/Address/Status.tsx @@ -0,0 +1,119 @@ +// Copyright 2017-2025 @polkadot/app-staking-async authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { BN } from '@polkadot/util'; + +import React, { useMemo } from 'react'; + +import MaxBadge from '@polkadot/app-staking/MaxBadge'; +import { Badge } from '@polkadot/react-components'; +import { useAccounts } from '@polkadot/react-hooks'; + +interface Props { + isChilled?: boolean; + isElected: boolean; + isMain?: boolean; + isPara?: boolean; + isRelay?: boolean; + nominators?: { nominatorId: string }[]; + onlineCount?: false | BN; + onlineMessage?: boolean; +} + +const NO_NOMS: { nominatorId: string }[] = []; + +function Status ({ isChilled, isElected, isMain, isPara, isRelay, nominators = NO_NOMS, onlineCount, onlineMessage }: Props): React.ReactElement { + const { allAccounts } = useAccounts(); + const blockCount = onlineCount && onlineCount.toNumber(); + + const isNominating = useMemo( + () => nominators.some(({ nominatorId }) => allAccounts.includes(nominatorId)), + [allAccounts, nominators] + ); + + return ( + <> + {isNominating + ? ( + + ) + : ( + + ) + } + {isRelay && ( + isPara + ? ( + + ) + : ( + + ) + )} + {isChilled + ? ( + + ) + : isElected + ? ( + + ) + : ( + + ) + } + {isMain && ( + blockCount + ? ( + + ) + : onlineMessage + ? ( + + ) + : ( + + ) + )} + + + ); +} + +export default React.memo(Status); diff --git a/packages/page-staking-async/src/Validators/Address/index.tsx b/packages/page-staking-async/src/Validators/Address/index.tsx new file mode 100644 index 0000000000..a32c3c3b33 --- /dev/null +++ b/packages/page-staking-async/src/Validators/Address/index.tsx @@ -0,0 +1,236 @@ +// Copyright 2017-2025 @polkadot/app-staking-async authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { ApiPromise } from '@polkadot/api'; +import type { DeriveHeartbeatAuthor } from '@polkadot/api-derive/types'; +import type { NominatedBy as NominatedByType, ValidatorInfo } from '@polkadot/app-staking/types'; +import type { Option } from '@polkadot/types'; +import type { SlashingSpans, ValidatorPrefs } from '@polkadot/types/interfaces'; +import type { BN } from '@polkadot/util'; +import type { NominatorValue } from './types.js'; + +import React, { useMemo } from 'react'; + +import { AddressSmall, Columar, Icon, LinkExternal, Table, Tag } from '@polkadot/react-components'; +import { checkVisibility } from '@polkadot/react-components/util'; +import { useApi, useCall, useDeriveAccountInfo, useToggle } from '@polkadot/react-hooks'; +import { FormatBalance } from '@polkadot/react-query'; +import { BN_ZERO } from '@polkadot/util'; + +import { useTranslation } from '../../translate.js'; +import NominatedBy from './NominatedBy.js'; +import StakeOther from './StakeOther.js'; +import Status from './Status.js'; + +interface Props { + address: string; + className?: string; + filterName: string; + hasQueries: boolean; + isElected: boolean; + isFavorite: boolean; + isMain?: boolean; + isPara?: boolean; + lastBlock?: string; + minCommission?: BN; + nominatedBy?: NominatedByType[]; + points?: string; + recentlyOnline?: DeriveHeartbeatAuthor; + toggleFavorite: (accountId: string) => void; + validatorInfo?: ValidatorInfo; + withIdentity?: boolean; +} + +interface StakingState { + isChilled?: boolean; + commission?: string; + nominators?: NominatorValue[]; + stakeTotal?: BN; + stakeOther?: BN; + stakeOwn?: BN; +} + +function expandInfo ({ exposureMeta, exposurePaged, validatorPrefs }: ValidatorInfo, minCommission?: BN): StakingState { + let nominators: NominatorValue[] | undefined; + let stakeTotal: BN | undefined; + let stakeOther: BN | undefined; + let stakeOwn: BN | undefined; + + if (exposureMeta?.total) { + nominators = exposurePaged.others.map(({ value, who }) => ({ + nominatorId: who.toString(), + value: value.unwrap() + })); + stakeTotal = exposureMeta.total?.unwrap() || BN_ZERO; + stakeOwn = exposureMeta.own.unwrap(); + stakeOther = stakeTotal.sub(stakeOwn); + } + + const commission = (validatorPrefs as ValidatorPrefs)?.commission?.unwrap(); + + return { + commission: commission?.toHuman(), + isChilled: commission && minCommission && commission.isZero() && commission.lt(minCommission), + nominators, + stakeOther, + stakeOwn, + stakeTotal + }; +} + +const transformSlashes = { + transform: (opt: Option) => opt.unwrapOr(null) +}; + +function useAddressCalls (api: ApiPromise, address: string, isMain?: boolean) { + const params = useMemo(() => [address], [address]); + const accountInfo = useDeriveAccountInfo(address); + const slashingSpans = useCall(!isMain && api.query.staking.slashingSpans, params, transformSlashes); + + return { accountInfo, slashingSpans }; +} + +function Address ({ address, className = '', filterName, hasQueries, isElected, isFavorite, isMain, isPara, lastBlock, minCommission, nominatedBy, points, recentlyOnline, toggleFavorite, validatorInfo, withIdentity }: Props): React.ReactElement | null { + const { t } = useTranslation(); + const { api, apiIdentity } = useApi(); + const [isExpanded, toggleIsExpanded] = useToggle(false); + const { accountInfo, slashingSpans } = useAddressCalls(api, address, isMain); + + const { commission, isChilled, nominators, stakeOther, stakeOwn } = useMemo( + () => validatorInfo + ? expandInfo(validatorInfo, minCommission) + : {}, + [minCommission, validatorInfo] + ); + + const isVisible = useMemo( + () => accountInfo ? checkVisibility(apiIdentity, address, accountInfo, filterName, withIdentity) : true, + [accountInfo, address, filterName, apiIdentity, withIdentity] + ); + + const statsLink = useMemo( + () => `#/staking/query/${address}`, + [address] + ); + + const pointsAnimClass = useMemo( + () => points && `greyAnim-${Date.now() % 25}`, + [points] + ); + + if (!isVisible) { + return null; + } + + return ( + <> + + + + + + + + {isMain && pointsAnimClass && ( + + )} + + {isMain + ? ( + + ) + : ( + + ) + } + + {commission || 50.00%} + + {isMain && ( + + {lastBlock} + + )} + + + {isExpanded && ( + + + + + + {isMain && stakeOwn?.gtn(0) && ( + <> +
{t('own stake')}
+ + + )} +
+ + {hasQueries && ( + <> +
{t('graphs')}
+ + +  {t('historic results')} + + + )} +
+
+ + + + + + + + + )} + + ); +} + +export default React.memo(Address); diff --git a/packages/page-staking-async/src/Validators/Address/types.ts b/packages/page-staking-async/src/Validators/Address/types.ts new file mode 100644 index 0000000000..286ca92bd2 --- /dev/null +++ b/packages/page-staking-async/src/Validators/Address/types.ts @@ -0,0 +1,9 @@ +// Copyright 2017-2025 @polkadot/app-staking-async authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { Balance } from '@polkadot/types/interfaces'; + +export interface NominatorValue { + nominatorId: string; + value: Balance; +} diff --git a/packages/page-staking-async/src/Validators/CurrentList.tsx b/packages/page-staking-async/src/Validators/CurrentList.tsx new file mode 100644 index 0000000000..72ee28bd8f --- /dev/null +++ b/packages/page-staking-async/src/Validators/CurrentList.tsx @@ -0,0 +1,210 @@ +// Copyright 2017-2025 @polkadot/app-staking-async authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { DeriveHeartbeats, DeriveStakingOverview } from '@polkadot/api-derive/types'; +import type { NominatedByMap, SortedTargets, ValidatorInfo } from '@polkadot/app-staking/types'; +import type { AccountId } from '@polkadot/types/interfaces'; +import type { BN } from '@polkadot/util'; + +import React, { useMemo, useRef, useState } from 'react'; + +import Filtering from '@polkadot/app-staking/Filtering'; +import Legend from '@polkadot/app-staking2/Legend'; +import { Table } from '@polkadot/react-components'; +import { useApi, useBlockAuthors, useNextTick } from '@polkadot/react-hooks'; + +import { useTranslation } from '../translate.js'; +import Address from './Address/index.js'; + +interface Props { + className?: string; + byAuthor: Record; + eraPoints: Record; + favorites: string[]; + hasQueries: boolean; + isIntentions?: boolean; + isIntentionsTrigger?: boolean; + isOwn: boolean; + minCommission?: BN; + nominatedBy?: NominatedByMap; + ownStashIds?: string[]; + paraValidators: Record; + recentlyOnline?: DeriveHeartbeats; + setNominators?: (nominators: string[]) => void; + stakingOverview?: DeriveStakingOverview; + targets: SortedTargets; + toggleFavorite: (address: string) => void; +} + +type AccountExtend = [string, boolean, boolean]; + +interface Filtered { + validators?: AccountExtend[]; + waiting?: AccountExtend[]; +} + +function filterAccounts (isOwn: boolean, accounts: string[] = [], ownStashIds: string[] = [], elected: string[], favorites: string[], without: string[]): AccountExtend[] { + return accounts + .filter((accountId) => + !without.includes(accountId) && ( + !isOwn || + ownStashIds.includes(accountId) + ) + ) + .map((accountId): AccountExtend => [ + accountId, + elected.includes(accountId), + favorites.includes(accountId) + ]) + .sort(([accA,, isFavA]: AccountExtend, [accB,, isFavB]: AccountExtend): number => { + const isStashA = ownStashIds.includes(accA); + const isStashB = ownStashIds.includes(accB); + + return isFavA === isFavB + ? isStashA === isStashB + ? 0 + : (isStashA ? -1 : 1) + : (isFavA ? -1 : 1); + }); +} + +function accountsToString (accounts: AccountId[]): string[] { + const result = new Array(accounts.length); + + for (let i = 0; i < accounts.length; i++) { + result[i] = accounts[i].toString(); + } + + return result; +} + +function getFiltered (isOwn: boolean, stakingOverview: DeriveStakingOverview | undefined, favorites: string[], next?: string[], ownStashIds?: string[]): Filtered { + if (!stakingOverview) { + return {}; + } + + const allElected = accountsToString(stakingOverview.nextElected); + const validatorIds = accountsToString(stakingOverview.validators); + + return { + validators: filterAccounts(isOwn, validatorIds, ownStashIds, allElected, favorites, []), + waiting: filterAccounts(isOwn, allElected, ownStashIds, allElected, favorites, validatorIds).concat( + filterAccounts(isOwn, next, ownStashIds, [], favorites, allElected) + ) + }; +} + +function mapValidators (infos: ValidatorInfo[]): Record { + const result: Record = {}; + + for (let i = 0, count = infos.length; i < count; i++) { + const info = infos[i]; + + result[info.key] = info; + } + + return result; +} + +const DEFAULT_PARAS = {}; + +function CurrentList ({ className, favorites, hasQueries, isIntentions, isOwn, minCommission, nominatedBy, ownStashIds, paraValidators = DEFAULT_PARAS, recentlyOnline, stakingOverview, targets, toggleFavorite }: Props): React.ReactElement | null { + const { t } = useTranslation(); + const { api } = useApi(); + const { byAuthor, eraPoints } = useBlockAuthors(); + const [nameFilter, setNameFilter] = useState(''); + const isNextTick = useNextTick(); + + const { validators, waiting } = useMemo( + () => getFiltered(isOwn, stakingOverview, favorites, targets.waitingIds, ownStashIds), + [favorites, isOwn, ownStashIds, stakingOverview, targets] + ); + + const list = useMemo( + () => isNextTick + ? isIntentions + ? nominatedBy && waiting + : validators + : undefined, + [isIntentions, isNextTick, nominatedBy, validators, waiting] + ); + + const infoMap = useMemo( + () => targets.validators && mapValidators(targets.validators), + [targets] + ); + + const headerRef = useRef<([React.ReactNode?, string?, number?] | false)[]>( + isIntentions + ? [ + [t('intentions'), 'start', 3], + [t('nominators'), 'expand'], + [t('commission'), 'number'], + [] + ] + : [ + [t('validators'), 'start', 3], + [t('other stake'), 'expand'], + [t('commission')], + [t('last #')], + [] + ] + ); + + return ( + + {!waiting &&
{t('Retrieving validators')}
} + {!infoMap &&
{t('Retrieving validator info')}
} + {isIntentions + ? !nominatedBy &&
{t('Retrieving nominators')}
+ : !recentlyOnline &&
{t('Retrieving online status')}
+ } + {!list &&
{t('Preparing validator list')}
} + + } + filter={ + + } + header={headerRef.current} + legend={ + + } + > + {list?.map(([address, isElected, isFavorite]): React.ReactNode => ( +
+ ))} +
+ ); +} + +export default React.memo(CurrentList); diff --git a/packages/page-staking-async/src/Validators/StakingAsyncOverview.tsx b/packages/page-staking-async/src/Validators/StakingAsyncOverview.tsx new file mode 100644 index 0000000000..0e6c4af130 --- /dev/null +++ b/packages/page-staking-async/src/Validators/StakingAsyncOverview.tsx @@ -0,0 +1,53 @@ +// Copyright 2017-2025 @polkadot/app-staking-async authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import React from 'react'; + +import { Icon, styled } from '@polkadot/react-components'; + +interface Props { + children?: React.ReactNode; + className?: string; + withIcon?: boolean; +} + +function StakingAsyncOverview ({ children, className = '', withIcon = true }: Props): React.ReactElement { + return ( + + {withIcon && } + Staking Async is Polkadot's staking system which elects validators for the Relay Chain, on AssetHub. + The actual collators of the AssetHub parachain are managed by the collator-selection system. To nominate a Relay Chain validator, please use this page and everything works as before. To setup a validator, please see{' '} + + this guide + . +
+
+ For more information about Staking Async and AssetHub migration, please see the{' '} + + Asset Hub Migration FAQ + . + {children} +
+ + ); +} + +const StyledArticle = styled.article` + max-width: 50vw; + margin-inline: auto !important; + + .ui--Icon { + color: rgba(255, 196, 12, 1); + margin-right: 0.5rem; + } +`; + +export default React.memo(StakingAsyncOverview); diff --git a/packages/page-staking-async/src/Validators/Summary.tsx b/packages/page-staking-async/src/Validators/Summary.tsx new file mode 100644 index 0000000000..defd614be4 --- /dev/null +++ b/packages/page-staking-async/src/Validators/Summary.tsx @@ -0,0 +1,151 @@ +// Copyright 2017-2025 @polkadot/app-staking-async authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { DeriveStakingOverview } from '@polkadot/api-derive/types'; +import type { SortedTargets } from '@polkadot/app-staking/types'; +import type { Option, u32 } from '@polkadot/types-codec'; +import type { BN } from '@polkadot/util'; + +import React, { useEffect, useState } from 'react'; + +import SummarySession from '@polkadot/app-explorer/SummarySession'; +import { CardSummary, styled, SummaryBox } from '@polkadot/react-components'; +import { useApi, useCall } from '@polkadot/react-hooks'; +import { formatNumber } from '@polkadot/util'; + +import { useTranslation } from '../translate.js'; + +interface Props { + className?: string; + nominators?: string[]; + stakingOverview?: DeriveStakingOverview; + targets: SortedTargets; +} + +const OPT_CURRENTERA = { + transform: (currentEra: Option): BN | null => + currentEra.unwrapOr(null) +}; + +const useActiveNominators = () => { + const { api } = useApi(); + const currentEra = useCall(api.query.staking.currentEra, undefined, OPT_CURRENTERA); + const [exposedNominators, setExposedNominators] = useState([]); + + useEffect(() => { + const getExposedNominators = async () => { + const exposedNominators = (await api.query.staking.erasStakersPaged.entries(currentEra)).map(([_, value]) => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + return (value).unwrap().others.map((x) => x.who.toString()); + }).flat(); + + setExposedNominators([...new Set(exposedNominators)]); + }; + + getExposedNominators().catch(console.log); + }, [api.query.staking.erasStakersPaged, currentEra]); + + return exposedNominators; +}; + +function Summary ({ className = '', stakingOverview, targets: { counterForNominators, inflation: { idealStake, inflation, stakedFraction }, waitingIds } }: Props): React.ReactElement { + const { t } = useTranslation(); + + const activeNominators = useActiveNominators(); + + const percent = %; + + return ( + +
+ + {stakingOverview + ? <>{formatNumber(stakingOverview.validators.length)} / {formatNumber(stakingOverview.validatorCount)} + : 999 / 999 + } + + + {waitingIds + ? formatNumber(waitingIds.length) + : 99 + } + + + {activeNominators + ? ( + <> + {formatNumber(activeNominators.length)} + {counterForNominators && ( + <> / {formatNumber(counterForNominators)} + )} + + ) + : 999 / 999 + } + +
+
+ {(idealStake > 0) && Number.isFinite(idealStake) && ( + + <>{(idealStake * 100).toFixed(1)}{percent} + + )} + {(stakedFraction > 0) && ( + + <>{(stakedFraction * 100).toFixed(1)}{percent} + + )} + {(inflation > 0) && Number.isFinite(inflation) && ( + + <>{inflation.toFixed(1)}{percent} + + )} +
+
+ +
+
+ ); +} + +const StyledSummaryBox = styled(SummaryBox)` + .validator--Account-block-icon { + display: inline-block; + margin-right: 0.75rem; + margin-top: -0.25rem; + vertical-align: middle; + } + + .validator--Summary-authors { + .validator--Account-block-icon+.validator--Account-block-icon { + margin-left: -1.5rem; + } + } + + .percent { + font-size: var(--font-percent-tiny); + } +`; + +export default React.memo(Summary); diff --git a/packages/page-staking-async/src/Validators/index.tsx b/packages/page-staking-async/src/Validators/index.tsx new file mode 100644 index 0000000000..49be5f8bcd --- /dev/null +++ b/packages/page-staking-async/src/Validators/index.tsx @@ -0,0 +1,120 @@ +// Copyright 2017-2025 @polkadot/app-staking-async authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { DeriveHeartbeats, DeriveStakingOverview } from '@polkadot/api-derive/types'; +import type { NominatedByMap, SortedTargets } from '@polkadot/app-staking/types'; +import type { StakerState } from '@polkadot/react-hooks/types'; +import type { BN } from '@polkadot/util'; + +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { Button, ToggleGroup } from '@polkadot/react-components'; +import { useApi, useBlockAuthors, useCall } from '@polkadot/react-hooks'; + +import { useTranslation } from '../translate.js'; +import ActionsBanner from './ActionsBanner.js'; +import CurrentList from './CurrentList.js'; +import StakingAsyncOverview from './StakingAsyncOverview.js'; +import Summary from './Summary.js'; + +interface Props { + className?: string; + favorites: string[]; + hasAccounts: boolean; + hasQueries: boolean; + minCommission?: BN; + nominatedBy?: NominatedByMap; + ownStashes?: StakerState[]; + paraValidators?: Record; + stakingOverview?: DeriveStakingOverview; + targets: SortedTargets; + toggleFavorite: (address: string) => void; + toggleLedger?: () => void; + toggleNominatedBy: () => void; +} + +const EMPTY_PARA_VALS: Record = {}; +const EMPTY_BY_AUTHOR: Record = {}; +const EMPTY_ERA_POINTS: Record = {}; + +function Overview ({ className = '', favorites, hasAccounts, hasQueries, minCommission, nominatedBy, ownStashes, paraValidators, stakingOverview, targets, toggleFavorite, toggleLedger, toggleNominatedBy }: Props): React.ReactElement { + const { t } = useTranslation(); + const { api } = useApi(); + const { byAuthor, eraPoints } = useBlockAuthors(); + const [intentIndex, _setIntentIndex] = useState(0); + const [typeIndex, setTypeIndex] = useState(1); + const recentlyOnline = useCall(api.derive.imOnline?.receivedHeartbeats); + + const setIntentIndex = useCallback( + (index: number): void => { + index && toggleNominatedBy(); + _setIntentIndex(index); + }, + [toggleNominatedBy] + ); + + const filterOptions = useRef([ + { text: t('Own validators'), value: 'mine' }, + { text: t('All validators'), value: 'all' } + ]); + + const intentOptions = useRef([ + { text: t('Active'), value: 'active' }, + { text: t('Waiting'), value: 'waiting' } + ]); + + const ownStashIds = useMemo( + () => ownStashes?.map(({ stashId }) => stashId), + [ownStashes] + ); + + useEffect((): void => { + toggleLedger && toggleLedger(); + }, [toggleLedger]); + + const isOwn = typeIndex === 0; + + return ( +
+ + + {hasAccounts && (ownStashes?.length === 0) && ( + + )} + + + + + +
+ ); +} + +export default React.memo(Overview); diff --git a/packages/page-staking-async/src/index.tsx b/packages/page-staking-async/src/index.tsx index 0380da11fe..6fd49c2ebf 100644 --- a/packages/page-staking-async/src/index.tsx +++ b/packages/page-staking-async/src/index.tsx @@ -1,32 +1,82 @@ // Copyright 2017-2025 @polkadot/app-staking-async authors & contributors // SPDX-License-Identifier: Apache-2.0 +import type { ApiPromise } from '@polkadot/api'; import type { AppProps as Props } from '@polkadot/react-components/types'; -import React from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; +import { createWsEndpoints } from '@polkadot/apps-config'; import { styled } from '@polkadot/react-components'; import { useApi } from '@polkadot/react-hooks'; import StakingRelayApp from './Relay/index.js'; import StakingSystemApp from './System/index.js'; +import { getApi } from './utils.js'; + +const allEndPoints = createWsEndpoints((k, v) => v?.toString() || k); function StakingApp ({ basePath, className = '', onStatusChange }: Props): React.ReactElement { - const { api } = useApi(); + const { api, apiEndpoint } = useApi(); + + const [ahApi, setAhApi] = useState(); + const [rcApi, setRcApi] = useState(); + + const isRelayChain = useMemo(() => !!api.tx.stakingAhClient, [api.tx.stakingAhClient]); + + const rcEndPoints = useMemo(() => { + return (isRelayChain + ? apiEndpoint?.providers + : apiEndpoint?.valueRelay) || []; + }, [apiEndpoint?.providers, apiEndpoint?.valueRelay, isRelayChain]); + + const ahEndPoints: string[] = useMemo(() => { + if (isRelayChain) { + return allEndPoints.find(({ genesisHashRelay, paraId }) => + paraId === 1000 && genesisHashRelay === api.genesisHash.toHex() + )?.providers || []; + } + + return apiEndpoint?.providers || []; + }, [api.genesisHash, apiEndpoint?.providers, isRelayChain]); + + useEffect(() => { + if (isRelayChain) { + // Pick random endpoint + const ahUrl = ahEndPoints.at(Math.floor(Math.random() * ahEndPoints.length)); + + !!ahUrl && getApi(ahUrl).then((ahApi) => setAhApi(ahApi)).catch(console.log); + } else { + // Pick random endpoint + const rcUrl = rcEndPoints.at(Math.floor(Math.random() * rcEndPoints.length)); + + !!rcUrl && getApi(rcUrl).then((rcApi) => setRcApi(rcApi)).catch(console.log); + } + }, [ahEndPoints, isRelayChain, rcEndPoints]); return ( - {api?.tx?.stakingAhClient + {isRelayChain ? ( ) : ( ) } diff --git a/packages/page-staking-async/src/utils.ts b/packages/page-staking-async/src/utils.ts new file mode 100644 index 0000000000..ae8667fc95 --- /dev/null +++ b/packages/page-staking-async/src/utils.ts @@ -0,0 +1,14 @@ +// Copyright 2017-2025 @polkadot/app-staking-async authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import { ApiPromise, WsProvider } from '@polkadot/api'; + +export const getApi = async (url: string[]|string) => { + const api = await ApiPromise.create({ + provider: new WsProvider(url) + }); + + await api.isReadyOrError; + + return api; +}; diff --git a/packages/react-components/src/Tooltip.tsx b/packages/react-components/src/Tooltip.tsx index 5f8e335cbe..77789c181d 100644 --- a/packages/react-components/src/Tooltip.tsx +++ b/packages/react-components/src/Tooltip.tsx @@ -69,6 +69,9 @@ const StyledReactTooltip = styled(ReactTooltip as unknown as React.ComponentType &.ui--Tooltip { z-index: 1002; + word-break: break-word; /* Fallback for older browsers */ + overflow-wrap: break-word; /* Modern standard */ + hyphens: auto; /* Enables automatic hyphenation */ } table { diff --git a/tsconfig.base.json b/tsconfig.base.json index 5e73628ee2..0be0b67218 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -72,7 +72,8 @@ "@polkadot/app-staking": ["page-staking/src/index.tsx"], "@polkadot/app-staking/Payouts": ["page-staking/src/Payouts/index.tsx"], "@polkadot/app-staking/Bags": ["page-staking/src/Bags/index.tsx"], - "@polkadot/app-staking/Validators": ["page-staking/src/Validators/index.tsx"], + "@polkadot/app-staking/Filtering": ["page-staking/src/Filtering.tsx"], + "@polkadot/app-staking/MaxBadge": ["page-staking/src/MaxBadge.tsx"], "@polkadot/app-staking/Actions": ["page-staking/src/Actions/index.tsx"], "@polkadot/app-staking/Targets": ["page-staking/src/Targets/index.tsx"], "@polkadot/app-staking/Slashes": ["page-staking/src/Slashes/index.tsx"],