Display validators info after AHM (#11871)

* feat: improved validator set keys ux

* feat: refactor InjectKeys and useStakingAsyncApis for improved API readiness checks

* feat: migrated actions to staking async

* feat: refactor ListNominees and useInactives for improved nomination handling and data fetching

* fix: build
This commit is contained in:
Arjun Porwal 2025-10-09 22:05:07 +05:30 committed by GitHub
parent bba9ddfee9
commit ee4621e753
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
43 changed files with 3826 additions and 2 deletions

View File

@ -0,0 +1,109 @@
// Copyright 2017-2025 @polkadot/app-staking-async authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ApiPromise } from '@polkadot/api';
import type { DeriveBalancesAll, DeriveStakingAccount } from '@polkadot/api-derive/types';
import type { AmountValidateState } from '../types.js';
import React, { useMemo, useState } from 'react';
import { InputAddress, InputBalance, Modal, TxButton } from '@polkadot/react-components';
import { useApi, useCall } from '@polkadot/react-hooks';
import { BalanceFree } from '@polkadot/react-query';
import { BN, BN_ZERO } from '@polkadot/util';
import { useTranslation } from '../../translate.js';
import ValidateAmount from './InputValidateAmount.js';
interface Props {
controllerId: string | null;
onClose: () => void;
stakingInfo?: DeriveStakingAccount;
stashId: string;
}
function calcBalance (api: ApiPromise, stakingInfo?: DeriveStakingAccount, stashBalance?: DeriveBalancesAll): BN | null {
if (stakingInfo?.stakingLedger && stashBalance) {
const sumUnlocking = (stakingInfo.unlocking || []).reduce((acc, { value }) => acc.iadd(value), new BN(0));
const redeemable = stakingInfo.redeemable || BN_ZERO;
const available = stashBalance.freeBalance.sub(stakingInfo.stakingLedger.active?.unwrap() || BN_ZERO).sub(sumUnlocking).sub(redeemable);
return available.gt(api.consts.balances.existentialDeposit)
? available.sub(api.consts.balances.existentialDeposit)
: BN_ZERO;
}
return null;
}
function BondExtra ({ controllerId, onClose, stakingInfo, stashId }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api } = useApi();
const [amountError, setAmountError] = useState<AmountValidateState | null>(null);
const [maxAdditional, setMaxAdditional] = useState<BN | undefined>();
const stashBalance = useCall<DeriveBalancesAll>(api.derive.balances?.all, [stashId]);
const currentAmount = useMemo(
() => stakingInfo?.stakingLedger?.active?.unwrap(),
[stakingInfo]
);
const startBalance = useMemo(
() => calcBalance(api, stakingInfo, stashBalance),
[api, stakingInfo, stashBalance]
);
return (
<Modal
header= {t('Bond more funds')}
onClose={onClose}
size='large'
>
<Modal.Content>
<Modal.Columns hint={t('Since this transaction deals with funding, the stash account will be used.')}>
<InputAddress
defaultValue={stashId}
isDisabled
label={t('stash account')}
/>
</Modal.Columns>
{startBalance && (
<Modal.Columns hint={t('The amount placed at-stake should allow some free funds for future transactions.')}>
<InputBalance
autoFocus
defaultValue={startBalance}
isError={!!amountError?.error || !maxAdditional || maxAdditional.isZero()}
label={t('additional funds to bond')}
labelExtra={
<BalanceFree
label={<span className='label'>{t('balance')}</span>}
params={stashId}
/>
}
onChange={setMaxAdditional}
/>
<ValidateAmount
controllerId={controllerId}
currentAmount={currentAmount}
onError={setAmountError}
stashId={stashId}
value={maxAdditional}
/>
</Modal.Columns>
)}
</Modal.Content>
<Modal.Actions>
<TxButton
accountId={stashId}
icon='sign-in-alt'
isDisabled={!maxAdditional?.gt(BN_ZERO) || !!amountError?.error}
label={t('Bond more')}
onStart={onClose}
params={[maxAdditional]}
tx={api.tx.staking.bondExtra}
/>
</Modal.Actions>
</Modal>
);
}
export default React.memo(BondExtra);

View File

@ -0,0 +1,137 @@
// Copyright 2017-2025 @polkadot/app-staking-async authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { KeypairType } from '@polkadot/util-crypto/types';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Button, Dropdown, Input, MarkWarning, Modal } from '@polkadot/react-components';
import { useQueue } from '@polkadot/react-hooks';
import { keyring } from '@polkadot/ui-keyring';
import { assert, u8aToHex } from '@polkadot/util';
import { keyExtractSuri, mnemonicValidate } from '@polkadot/util-crypto';
import { useTranslation } from '../../translate.js';
interface Props {
onClose: () => void;
}
const CRYPTO_MAP: Record<string, KeypairType[]> = {
aura: ['ed25519', 'sr25519'],
babe: ['sr25519'],
gran: ['ed25519'],
imon: ['ed25519', 'sr25519'],
para: ['sr25519']
};
const EMPTY_KEY = '0x';
function InjectKeys ({ onClose }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
const { queueRpc } = useQueue();
// this needs to align with what is set as the first value in `type`
const [crypto, setCrypto] = useState<KeypairType>('sr25519');
const [publicKey, setPublicKey] = useState(EMPTY_KEY);
const [suri, setSuri] = useState('');
const [keyType, setKeyType] = useState('babe');
const keyTypeOptRef = useRef([
{ text: t('Aura'), value: 'aura' },
{ text: t('Babe'), value: 'babe' },
{ text: t('Grandpa'), value: 'gran' },
{ text: t('I\'m Online'), value: 'imon' },
{ text: t('Parachains'), value: 'para' }
]);
useEffect((): void => {
setCrypto(CRYPTO_MAP[keyType][0]);
}, [keyType]);
useEffect((): void => {
try {
const { phrase } = keyExtractSuri(suri);
assert(mnemonicValidate(phrase), 'Invalid mnemonic phrase');
setPublicKey(u8aToHex(keyring.createFromUri(suri, {}, crypto).publicKey));
} catch {
setPublicKey(EMPTY_KEY);
}
}, [crypto, suri]);
const _onSubmit = useCallback(
(): void => queueRpc({
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
rpc: { method: 'insertKey', section: 'author' } as any,
values: [keyType, suri, publicKey]
}),
[keyType, publicKey, queueRpc, suri]
);
const _cryptoOptions = useMemo(
() => CRYPTO_MAP[keyType].map((value): { text: string; value: KeypairType } => ({
text: value === 'ed25519'
? t('ed25519, Edwards')
: t('sr15519, Schnorrkel'),
value
})),
[keyType, t]
);
return (
<Modal
header={t('Inject Keys')}
onClose={onClose}
size='large'
>
<Modal.Content>
<Modal.Columns>
<MarkWarning content={t('This operation will be performed on the relay chain.')} />
</Modal.Columns>
<Modal.Columns hint={t('The seed and derivation path will be submitted to the validator node. this is an advanced operation, only to be performed when you are sure of the security and connection risks.')}>
<Input
autoFocus
isError={publicKey.length !== 66}
label={t('suri (seed & derivation)')}
onChange={setSuri}
value={suri}
/>
<MarkWarning content={t('This operation will submit the seed via an RPC call. Do not perform this operation on a public RPC node, but ensure that the node is local, connected to your validator and secure.')} />
</Modal.Columns>
<Modal.Columns hint={t('The key type and crypto type to use for this key. Be aware that different keys have different crypto requirements. You should be familiar with the type requirements for the different keys.')}>
<Dropdown
label={t('key type to set')}
onChange={setKeyType}
options={keyTypeOptRef.current}
value={keyType}
/>
<Dropdown
isDisabled={_cryptoOptions.length === 1}
label={t('crypto type to use')}
onChange={setCrypto}
options={_cryptoOptions}
value={crypto}
/>
</Modal.Columns>
<Modal.Columns hint={t('This pubic key is what will be visible in your queued keys list. It is generated based on the seed and the crypto used.')}>
<Input
isDisabled
label={t('generated public key')}
value={publicKey}
/>
</Modal.Columns>
</Modal.Content>
<Modal.Actions>
<Button
icon='sign-in-alt'
label={t('Submit key')}
onClick={_onSubmit}
/>
</Modal.Actions>
</Modal>
);
}
export default React.memo(InjectKeys);

View File

@ -0,0 +1,111 @@
// Copyright 2017-2025 @polkadot/app-staking-async authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { DeriveBalancesAll } from '@polkadot/api-derive/types';
import type { AmountValidateState } from '../types.js';
import React, { useEffect, useState } from 'react';
import { MarkError, MarkWarning } from '@polkadot/react-components';
import { useApi, useCall } from '@polkadot/react-hooks';
import { BN, BN_TEN, BN_THOUSAND, BN_ZERO, formatBalance } from '@polkadot/util';
import { useTranslation } from '../../translate.js';
interface Props {
controllerId: string | null;
currentAmount?: BN | null;
isNominating?: boolean;
minNominated?: BN;
minNominatorBond?: BN;
minValidatorBond?: BN;
onError: (state: AmountValidateState | null) => void;
stashId: string | null;
value?: BN | null;
}
function formatExistential (value: BN): string {
let fmt = (
value
.mul(BN_THOUSAND)
.div(BN_TEN.pow(new BN(formatBalance.getDefaults().decimals)))
.toNumber() / 1000
).toFixed(3);
while (fmt.length !== 1 && ['.', '0'].includes(fmt[fmt.length - 1])) {
const isLast = fmt.endsWith('.');
fmt = fmt.substring(0, fmt.length - 1);
if (isLast) {
break;
}
}
return fmt;
}
function ValidateAmount ({ currentAmount, isNominating, minNominated, minNominatorBond, minValidatorBond, onError, stashId, value }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
const { api } = useApi();
const stashBalance = useCall<DeriveBalancesAll>(api.derive.balances?.all, [stashId]);
const [{ error, warning }, setResult] = useState<AmountValidateState>({ error: null, warning: null });
useEffect((): void => {
if (stashBalance && value) {
// also used in bond extra, take check against total of current bonded and new
const check = value.add(currentAmount || BN_ZERO);
const existentialDeposit = api.consts.balances.existentialDeposit;
const maxBond = stashBalance.freeBalance.sub(existentialDeposit.divn(2));
let newError: string | null = null;
let newWarning: string | null = null;
if (check.gte(maxBond)) {
newWarning = t('The specified value is large and may not allow enough funds to pay future transaction fees.');
} else if (check.lt(existentialDeposit)) {
newError = t('The bonded amount is less than the minimum bond amount of {{existentialDeposit}}', {
replace: { existentialDeposit: formatExistential(existentialDeposit) }
});
} else if (isNominating) {
if (minNominatorBond && check.lt(minNominatorBond)) {
newError = t('The bonded amount is less than the minimum threshold of {{minBond}} for nominators', {
replace: { minBond: formatBalance(minNominatorBond) }
});
} else if (minNominated && check.lt(minNominated)) {
newWarning = t('The bonded amount is less than the current active minimum nominated amount of {{minNomination}} and depending on the network state, may not be selected to participate', {
replace: { minNomination: formatBalance(minNominated) }
});
}
} else {
if (minValidatorBond && check.lt(minValidatorBond)) {
newError = t('The bonded amount is less than the minimum threshold of {{minBond}} for validators', {
replace: { minBond: formatBalance(minValidatorBond) }
});
}
}
setResult((state): AmountValidateState => {
const error = state.error !== newError ? newError : state.error;
const warning = state.warning !== newWarning ? newWarning : state.warning;
onError(
(error || warning)
? { error, warning }
: null
);
return { error, warning };
});
}
}, [api, currentAmount, isNominating, minNominated, minNominatorBond, minValidatorBond, onError, stashBalance, t, value]);
if (error) {
return <MarkError content={error} />;
} else if (warning) {
return <MarkWarning content={warning} />;
}
return null;
}
export default React.memo(ValidateAmount);

View File

@ -0,0 +1,86 @@
// Copyright 2017-2025 @polkadot/app-staking-async authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { DeriveBalancesAll } from '@polkadot/api-derive/types';
import type { Option } from '@polkadot/types';
import type { AccountId } from '@polkadot/types/interfaces';
import type { PalletStakingStakingLedger } from '@polkadot/types/lookup';
import React, { useEffect, useState } from 'react';
import { MarkError, MarkWarning } from '@polkadot/react-components';
import { useApi, useCall } from '@polkadot/react-hooks';
import { useTranslation } from '../../translate.js';
interface Props {
accountId: string | null;
controllerId: string | null;
defaultController?: string;
onError: (error: string | null, isFatal: boolean) => void;
}
interface ErrorState {
error: string | null;
isFatal: boolean;
}
const OPT_BOND = {
transform: (value: Option<AccountId>): string | null =>
value.isSome
? value.unwrap().toString()
: null
};
const OPT_STASH = {
transform: (value: Option<PalletStakingStakingLedger>): string | null =>
value.isSome
? value.unwrap().stash.toString()
: null
};
function ValidateController ({ accountId, controllerId, defaultController, onError }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
const { api } = useApi();
const bondedId = useCall<string | null>(controllerId ? api.query.staking.bonded : null, [controllerId], OPT_BOND);
const stashId = useCall<string | null>(controllerId ? api.query.staking.ledger : null, [controllerId], OPT_STASH);
const allBalances = useCall<DeriveBalancesAll>(controllerId ? api.derive.balances?.all : null, [controllerId]);
const [{ error, isFatal }, setError] = useState<ErrorState>({ error: null, isFatal: false });
useEffect((): void => {
// don't show an error if the selected controller is the default
// this applies when changing controller
if (defaultController !== controllerId) {
let newError: string | null = null;
let isFatal = false;
if (bondedId && (controllerId !== accountId)) {
isFatal = true;
newError = t('A controller account should not map to another stash. This selected controller is a stash, controlled by {{bondedId}}', { replace: { bondedId } });
} else if (stashId) {
isFatal = true;
newError = t('A controller account should not be set to manage multiple stashes. The selected controller is already controlling {{stashId}}', { replace: { stashId } });
} else if (allBalances?.freeBalance.isZero()) {
isFatal = true;
newError = t('The controller does not have sufficient funds available to cover transaction fees. Ensure that a funded controller is used.');
} else if (controllerId === accountId) {
newError = t('Distinct stash and controller accounts are recommended to ensure fund security. You will be allowed to make the transaction, but take care to not tie up all funds, only use a portion of the available funds during this period.');
}
onError(newError, isFatal);
setError((state) => state.error !== newError ? { error: newError, isFatal } : state);
}
}, [accountId, allBalances, bondedId, controllerId, defaultController, onError, stashId, t]);
if (!error || !accountId) {
return null;
}
return (
isFatal
? <MarkError content={error} />
: <MarkWarning content={error} />
);
}
export default React.memo(ValidateController);

View File

@ -0,0 +1,43 @@
// Copyright 2017-2025 @polkadot/app-staking-async authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { I18nProps } from '@polkadot/react-components/types';
import React, { useEffect, useState } from 'react';
import { MarkWarning } from '@polkadot/react-components';
import { useTranslation } from '../../translate.js';
interface Props extends I18nProps {
controllerId: string;
onError: (error: string | null) => void;
sessionId: string | null;
stashId: string;
}
function ValidateSessionEd25519 ({ onError, sessionId, stashId }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
const [error, setError] = useState<string | null>(null);
useEffect((): void => {
let newError: string | null = null;
if (sessionId === stashId) {
newError = t('For fund security, your session key should not match your stash key.');
}
onError(newError);
setError((error) => error !== newError ? newError : error);
}, [onError, sessionId, stashId, t]);
if (!error) {
return null;
}
return (
<MarkWarning content={error} />
);
}
export default React.memo(ValidateSessionEd25519);

View File

@ -0,0 +1,46 @@
// Copyright 2017-2025 @polkadot/app-staking-async authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { BN } from '@polkadot/util';
import React, { useEffect, useState } from 'react';
import { MarkWarning } from '@polkadot/react-components';
import { BN_TEN } from '@polkadot/util';
import { useTranslation } from '../../translate.js';
interface Props {
unstakeThreshold: BN | undefined;
onError: (error: string | null) => void;
}
function InputValidationUnstakeThreshold ({ onError, unstakeThreshold }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
const [error, setError] = useState<string | null>(null);
useEffect((): void => {
if (unstakeThreshold) {
let newError: string | null = null;
if (unstakeThreshold.ltn(0)) {
newError = t('The Threshold must be a positive number');
} else if (unstakeThreshold.gt(BN_TEN)) {
newError = t('The Threshold must lower than 11');
}
onError(newError);
setError((error) => error !== newError ? newError : error);
}
}, [onError, t, unstakeThreshold]);
if (!error) {
return null;
}
return (
<MarkWarning content={error} />
);
}
export default React.memo(InputValidationUnstakeThreshold);

View File

@ -0,0 +1,93 @@
// Copyright 2017-2025 @polkadot/app-staking-async authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { SubmittableExtrinsic } from '@polkadot/api/types';
import type { DeriveStakingQuery } from '@polkadot/api-derive/types';
import React, { useEffect, useMemo, useState } from 'react';
import { InputAddressMulti, Modal, Spinner, TxButton } from '@polkadot/react-components';
import { useApi, useCall } from '@polkadot/react-hooks';
import { useTranslation } from '../../translate.js';
import SenderInfo from '../partials/SenderInfo.js';
interface Props {
className?: string;
controllerId: string;
nominating?: string[];
onClose: () => void;
stashId: string;
}
const MAX_KICK = 128;
const accountOpts = {
withExposure: true
};
function KickNominees ({ className = '', controllerId, nominating, onClose, stashId }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
const { api } = useApi();
const [selected, setSelected] = useState<string[]>([]);
const [{ kickTx }, setTx] = useState<{ kickTx?: null | SubmittableExtrinsic<'promise'> }>({});
const queryInfo = useCall<DeriveStakingQuery>(api.derive.staking.query, [stashId, accountOpts]);
const nominators = useMemo(
() => queryInfo?.exposurePaged.isSome && queryInfo?.exposurePaged.unwrap().others.map(({ who }) => who.toString()),
[queryInfo]
);
useEffect((): void => {
try {
setTx({
kickTx: selected.length
? api.tx.staking.kick(selected)
: null
});
} catch {
setTx({ kickTx: null });
}
}, [api, selected]);
return (
<Modal
className={className}
header={t('Remove nominees')}
onClose={onClose}
size='large'
>
<Modal.Content>
<SenderInfo
controllerId={controllerId}
stashId={stashId}
/>
{nominators
? (
<InputAddressMulti
available={nominators}
availableLabel={t('existing/active nominators')}
defaultValue={nominating}
maxCount={MAX_KICK}
onChange={setSelected}
valueLabel={t('nominators to be removed')}
/>
)
: <Spinner label={t('Retrieving active nominators')} />
}
</Modal.Content>
<Modal.Actions>
<TxButton
accountId={controllerId}
extrinsic={kickTx}
icon='user-slash'
isDisabled={!kickTx}
label={t('Remove')}
onStart={onClose}
/>
</Modal.Actions>
</Modal>
);
}
export default React.memo(KickNominees);

View File

@ -0,0 +1,147 @@
// Copyright 2017-2025 @polkadot/app-staking-async authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { DeriveEraExposure, DeriveSessionIndexes } from '@polkadot/api-derive/types';
import type { BN } from '@polkadot/util';
import React, { useMemo } from 'react';
import { AddressMini, ExpanderScroll, MarkWarning, Spinner } from '@polkadot/react-components';
import { useApi, useCall } from '@polkadot/react-hooks';
import { isToBn } from '@polkadot/util';
import { useTranslation } from '../../translate.js';
import useInactives from '../useInactives.js';
interface Props {
nominating?: string[];
stashId: string;
}
const EMPTY_MAP = {};
function mapExposure (stashId: string, all: string[], eraExposure?: DeriveEraExposure): Record<string, BN> {
if (!eraExposure?.validators) {
return EMPTY_MAP;
}
const nomBalanceMap: Record<string, BN> = {};
// for every active nominee
all.forEach((nom) => {
// cycle through its nominator to find our current stash
eraExposure.validators[nom]?.others.some((o) => {
// NOTE Some chains have non-standard implementations, without value
if (o.who.eq(stashId) && isToBn(o.value)) {
nomBalanceMap[nom] = o.value.toBn();
return true;
}
return false;
});
});
return nomBalanceMap;
}
function renderNominators (stashId: string, all: string[] = [], eraExposure?: DeriveEraExposure): null | [number, () => React.ReactNode[]] {
return all.length
? [
all.length,
(): React.ReactNode[] => {
const nomBalanceMap = mapExposure(stashId, all, eraExposure);
return all.map((nomineeId, index): React.ReactNode => (
<AddressMini
balance={nomBalanceMap[nomineeId]}
key={index}
value={nomineeId}
withBalance={!!eraExposure && !!nomBalanceMap[nomineeId]}
/>
));
}
]
: null;
}
function ListNominees ({ nominating, stashId }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api } = useApi();
// 1. Fetch session info first. It's the primary dependency.
const sessionInfo = useCall<DeriveSessionIndexes>(api.query.staking && api.derive.session?.indexes);
// 2. CORRECTED: Fetch eraExposure only when sessionInfo.activeEra is available.
// Removed the broken check for 'erasStakers'.
const eraExposure = useCall<DeriveEraExposure>(sessionInfo && api.derive.staking.eraExposure, [sessionInfo?.activeEra]);
// 3. The useInactives hook is self-contained and fetches its own data.
const { nomsActive, nomsAtRisk, nomsInactive, nomsOver, nomsWaiting } = useInactives(stashId, nominating);
const [renActive, renAtRisk, renInactive, renOver, renWaiting] = useMemo(
() => [
renderNominators(stashId, nomsActive, eraExposure),
// eraExposure is not needed for at-risk, as it's not about rewards
renderNominators(stashId, nomsAtRisk),
renderNominators(stashId, nomsInactive),
renderNominators(stashId, nomsOver),
renderNominators(stashId, nomsWaiting)
],
[eraExposure, nomsActive, nomsAtRisk, nomsInactive, nomsOver, nomsWaiting, stashId]
);
const isLoading = useMemo(() =>
!eraExposure || !nominating || nomsActive === undefined,
[eraExposure, nominating, nomsActive]);
if (isLoading) {
return (
<Spinner
label='Checking validators'
variant='app'
/>
);
}
return (
<>
{renOver && (
<ExpanderScroll
className='stakeOver'
renderChildren={renOver[1]}
summary={t('Oversubscribed nominations ({{count}})', { replace: { count: renOver[0] } })}
/>
)}
{renActive && (
<ExpanderScroll
renderChildren={renActive[1]}
summary={t('Active nominations ({{count}})', { replace: { count: renActive[0] } })}
/>
)}
{renInactive && (
<ExpanderScroll
renderChildren={renInactive[1]}
summary={t('Inactive nominations ({{count}})', { replace: { count: renInactive[0] } })}
/>
)}
{renAtRisk && (
<ExpanderScroll
renderChildren={renAtRisk[1]}
summary={t('Renomination required ({{count}})', { replace: { count: renAtRisk[0] } })}
/>
)}
{renWaiting && (
<ExpanderScroll
renderChildren={renWaiting[1]}
summary={t('Waiting nominations ({{count}})', { replace: { count: renWaiting[0] } })}
/>
)}
{nomsActive && nomsInactive && (nomsActive.length === 0) && (nomsInactive.length !== 0) && (
<MarkWarning content={t('This could mean your nomination has not been applied to any validator in the active set by the election algorithm or it has been applied against a validator who is either oversubscribed or chilled.')} />
)}
</>
);
}
export default React.memo(ListNominees);

View File

@ -0,0 +1,71 @@
// Copyright 2017-2025 @polkadot/app-staking-async authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { SortedTargets } from '@polkadot/app-staking/types';
import type { BN } from '@polkadot/util';
import type { NominateInfo } from '../partials/types.js';
import React, { useState } from 'react';
import { Modal, styled, TxButton } from '@polkadot/react-components';
import { useTranslation } from '../../translate.js';
import NominatePartial from '../partials/Nominate.js';
interface Props {
className?: string;
controllerId: string;
nominating?: string[];
onClose: () => void;
poolId?: BN;
stashId: string;
targets: SortedTargets;
}
function Nominate ({ className = '', controllerId, nominating, onClose, poolId, stashId, targets }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
const [{ nominateTx }, setTx] = useState<NominateInfo>({});
return (
<StyledModal
className={className}
header={t('Nominate Validators')}
onClose={onClose}
size='large'
>
<Modal.Content>
<NominatePartial
className='nominatePartial'
controllerId={controllerId}
nominating={nominating}
onChange={setTx}
poolId={poolId}
stashId={stashId}
targets={targets}
withSenders
/>
</Modal.Content>
<Modal.Actions>
<TxButton
accountId={controllerId}
extrinsic={nominateTx}
icon='hand-paper'
isDisabled={!nominateTx}
label={t('Nominate')}
onStart={onClose}
/>
</Modal.Actions>
</StyledModal>
);
}
const StyledModal = styled(Modal)`
.nominatePartial {
.ui--Static .ui--AddressMini .ui--AddressMini-info {
max-width: 10rem;
min-width: 10rem;
}
}
`;
export default React.memo(Nominate);

View File

@ -0,0 +1,73 @@
// Copyright 2017-2025 @polkadot/app-staking-async authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { DeriveStakingAccount } from '@polkadot/api-derive/types';
import React, { useMemo, useState } from 'react';
import { InputBalance, Modal, TxButton } from '@polkadot/react-components';
import { useApi } from '@polkadot/react-hooks';
import { BN, BN_ZERO } from '@polkadot/util';
import { useTranslation } from '../../translate.js';
import SenderInfo from '../partials/SenderInfo.js';
interface Props {
controllerId: string | null;
onClose: () => void;
stakingInfo?: DeriveStakingAccount;
stashId: string;
}
// TODO we should check that the bonded amoutn, after the operation is >= ED
function Rebond ({ controllerId, onClose, stakingInfo, stashId }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api } = useApi();
const [maxAdditional, setMaxAdditional] = useState<BN | undefined>();
const startBalance = useMemo(
() => stakingInfo?.unlocking
? stakingInfo.unlocking.reduce((total, { value }) => total.iadd(value), new BN(0))
: BN_ZERO,
[stakingInfo]
);
return (
<Modal
header= {t('Bond more funds')}
onClose={onClose}
size='large'
>
<Modal.Content>
<SenderInfo
controllerId={controllerId}
stashId={stashId}
/>
{startBalance && (
<Modal.Columns hint={t('The amount the is to be rebonded from the value currently unlocking, i.e. previously unbonded')}>
<InputBalance
autoFocus
defaultValue={startBalance}
isError={!maxAdditional || maxAdditional.eqn(0) || maxAdditional.gt(startBalance)}
label={t('rebonded amount')}
onChange={setMaxAdditional}
/>
</Modal.Columns>
)}
</Modal.Content>
<Modal.Actions>
<TxButton
accountId={controllerId}
icon='sign-in-alt'
isDisabled={!maxAdditional || maxAdditional.isZero() || !startBalance || maxAdditional.gt(startBalance)}
label={t('Rebond')}
onStart={onClose}
params={[maxAdditional]}
tx={api.tx.staking.rebond}
/>
</Modal.Actions>
</Modal>
);
}
export default React.memo(Rebond);

View File

@ -0,0 +1,92 @@
// Copyright 2017-2025 @polkadot/app-staking-async authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React, { useCallback, useMemo, useState } from 'react';
import { InputAddress, Modal, TxButton } from '@polkadot/react-components';
import { useApi } from '@polkadot/react-hooks';
import { useTranslation } from '../../translate.js';
import InputValidationController from './InputValidationController.js';
interface Props {
defaultControllerId: string;
onClose: () => void;
stashId: string;
}
function SetControllerAccount ({ defaultControllerId, onClose, stashId }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api } = useApi();
const [isFatal, setIsFatal] = useState(false);
const [controllerId, setControllerId] = useState<string | null>(null);
const _setError = useCallback(
(_: string | null, isFatal: boolean) => setIsFatal(isFatal),
[]
);
const needsController = useMemo(
() => api.tx.staking.setController.meta.args.length === 1,
[api]
);
return (
<Modal
header={t('Change controller account')}
onClose={onClose}
size='large'
>
<Modal.Content>
<Modal.Columns hint={t('The stash account that is used. This will allow the controller to perform all non-funds related operations on behalf of the account.')}>
<InputAddress
isDisabled
label={t('stash account')}
value={stashId}
/>
</Modal.Columns>
{needsController && (
<Modal.Columns hint={t('The selected controller tied to this stash. Once set, this account will be able to control the actions performed by the stash account.')}>
<InputAddress
defaultValue={defaultControllerId}
label={t('controller account')}
onChange={setControllerId}
type='account'
value={controllerId}
/>
<InputValidationController
accountId={stashId}
controllerId={controllerId}
defaultController={defaultControllerId}
onError={_setError}
/>
</Modal.Columns>
)}
</Modal.Content>
<Modal.Actions>
<TxButton
accountId={stashId}
icon='sign-in-alt'
isDisabled={
isFatal ||
(
needsController
? !controllerId
: false
)
}
label={t('Set controller')}
onStart={onClose}
params={
needsController
? [controllerId]
: []
}
tx={api.tx.staking.setController}
/>
</Modal.Actions>
</Modal>
);
}
export default React.memo(SetControllerAccount);

View File

@ -0,0 +1,90 @@
// Copyright 2017-2025 @polkadot/app-staking-async authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { DeriveBalancesAll } from '@polkadot/api-derive/types';
import type { PalletStakingRewardDestination } from '@polkadot/types/lookup';
import type { DestinationType } from '../types.js';
import React, { useMemo, useState } from 'react';
import { Dropdown, InputAddress, MarkError, Modal, TxButton } from '@polkadot/react-components';
import { useApi, useCall } from '@polkadot/react-hooks';
import { useTranslation } from '../../translate.js';
import { createDestCurr } from '../destOptions.js';
import SenderInfo from '../partials/SenderInfo.js';
interface Props {
defaultDestination?: PalletStakingRewardDestination | null;
controllerId: string;
onClose: () => void;
stashId: string;
}
function SetRewardDestination ({ controllerId, defaultDestination, onClose, stashId }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api } = useApi();
const [destination, setDestination] = useState<DestinationType>(() => ((defaultDestination?.isAccount ? 'Account' : defaultDestination?.toString()) || 'Staked') as 'Staked');
const [destAccount, setDestAccount] = useState<string | null>(() => defaultDestination?.isAccount ? defaultDestination.asAccount.toString() : null);
const destBalance = useCall<DeriveBalancesAll>(api.derive.balances?.all, [destAccount]);
const options = useMemo(
() => createDestCurr(t),
[t]
);
const isAccount = destination === 'Account';
const isDestError = isAccount && destBalance && destBalance.accountId.eq(destAccount) && destBalance.freeBalance.isZero();
return (
<Modal
header={t('Bonding Preferences')}
onClose={onClose}
size='large'
>
<Modal.Content>
<SenderInfo
controllerId={controllerId}
stashId={stashId}
/>
<Modal.Columns hint={t('All rewards will go towards the selected output destination when a payout is made.')}>
<Dropdown
defaultValue={defaultDestination?.toString()}
label={t('payment destination')}
onChange={setDestination}
options={options}
value={destination}
/>
{isAccount && (
<InputAddress
label={t('the payment account')}
onChange={setDestAccount}
type='account'
value={destAccount}
/>
)}
{isDestError && (
<MarkError content={t('The selected destination account does not exist and cannot be used to receive rewards')} />
)}
</Modal.Columns>
</Modal.Content>
<Modal.Actions>
<TxButton
accountId={controllerId}
icon='sign-in-alt'
isDisabled={!controllerId || (isAccount && (!destAccount || isDestError))}
label={t('Set reward destination')}
onStart={onClose}
params={[
isAccount
? { Account: destAccount }
: destination
]}
tx={api.tx.staking.setPayee}
/>
</Modal.Actions>
</Modal>
);
}
export default React.memo(SetRewardDestination);

View File

@ -0,0 +1,52 @@
// Copyright 2017-2025 @polkadot/app-staking-async authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { SessionInfo } from '../partials/types.js';
import React, { useState } from 'react';
import { Modal, TxButton } from '@polkadot/react-components';
import { useTranslation } from '../../translate.js';
import SessionKeyPartital from '../partials/SessionKey.js';
interface Props {
controllerId: string;
onClose: () => void;
stashId: string;
}
function SetSessionKey ({ controllerId, onClose, stashId }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
const [{ sessionTx }, setTx] = useState<SessionInfo>({});
return (
<Modal
header={t('Set Session Key')}
onClose={onClose}
size='large'
>
<Modal.Content>
<SessionKeyPartital
controllerId={controllerId}
onChange={setTx}
stashId={stashId}
withFocus
withSenders
/>
</Modal.Content>
<Modal.Actions>
<TxButton
accountId={controllerId}
extrinsic={sessionTx}
icon='sign-in-alt'
isDisabled={!sessionTx}
label={t('Set Session Key')}
onStart={onClose}
/>
</Modal.Actions>
</Modal>
);
}
export default React.memo(SetSessionKey);

View File

@ -0,0 +1,92 @@
// Copyright 2017-2025 @polkadot/app-staking-async authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { PalletStakingStakingLedger } from '@polkadot/types/lookup';
import type { BN } from '@polkadot/util';
import React, { useState } from 'react';
import { InputBalance, Modal, Static, styled, TxButton } from '@polkadot/react-components';
import { useApi } from '@polkadot/react-hooks';
import { BlockToTime, FormatBalance } from '@polkadot/react-query';
import { BN_ZERO } from '@polkadot/util';
import { useTranslation } from '../../translate.js';
import SenderInfo from '../partials/SenderInfo.js';
import useUnbondDuration from '../useUnbondDuration.js';
interface Props {
controllerId?: string | null;
onClose: () => void;
stakingLedger?: PalletStakingStakingLedger;
stashId: string;
}
function Unbond ({ controllerId, onClose, stakingLedger, stashId }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api } = useApi();
const bondedBlocks = useUnbondDuration();
const [maxBalance] = useState<BN | null>(() => stakingLedger?.active?.unwrap() || null);
const [maxUnbond, setMaxUnbond] = useState<BN | undefined>();
return (
<StyledModal
header={t('Unbond funds')}
onClose={onClose}
size='large'
>
<Modal.Content>
<SenderInfo
controllerId={controllerId}
stashId={stashId}
/>
<Modal.Columns hint={t('The funds will only be available for withdrawal after the unbonding period, however will not be part of the staked amount after the next validator election. You can follow the unlock countdown in the UI.')}>
<InputBalance
autoFocus
defaultValue={maxBalance}
label={t('unbond amount')}
labelExtra={
<FormatBalance
label={<span className='label'>{t('bonded')}</span>}
value={maxBalance}
/>
}
maxValue={maxBalance}
onChange={setMaxUnbond}
withMax
/>
{bondedBlocks?.gtn(0) && (
<Static
label={t('on-chain bonding duration')}
>
<BlockToTime value={bondedBlocks} />
</Static>
)}
</Modal.Columns>
</Modal.Content>
<Modal.Actions>
<TxButton
accountId={controllerId}
icon='unlock'
isDisabled={!maxUnbond?.gt(BN_ZERO)}
label={t('Unbond')}
onStart={onClose}
params={[maxUnbond]}
tx={api.tx.staking.unbond}
/>
</Modal.Actions>
</StyledModal>
);
}
const StyledModal = styled(Modal)`
.staking--Unbond--max > div {
justify-content: flex-end;
& .column {
flex: 0;
}
}
`;
export default React.memo(Unbond);

View File

@ -0,0 +1,55 @@
// Copyright 2017-2025 @polkadot/app-staking-async authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { BN } from '@polkadot/util';
import type { ValidateInfo } from '../partials/types.js';
import React, { useState } from 'react';
import { Modal, TxButton } from '@polkadot/react-components';
import { useTranslation } from '../../translate.js';
import ValidatePartial from '../partials/Validate.js';
interface Props {
controllerId: string;
minCommission?: BN;
onClose: () => void;
stashId: string;
}
function Validate ({ controllerId, minCommission, onClose, stashId }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const [{ validateTx }, setTx] = useState<ValidateInfo>({});
return (
<Modal
header={t('Set validator preferences')}
onClose={onClose}
size='large'
>
<Modal.Content>
<ValidatePartial
controllerId={controllerId}
minCommission={minCommission}
onChange={setTx}
stashId={stashId}
withFocus
withSenders
/>
</Modal.Content>
<Modal.Actions>
<TxButton
accountId={controllerId}
extrinsic={validateTx}
icon='certificate'
isDisabled={!validateTx}
label={t('Validate')}
onStart={onClose}
/>
</Modal.Actions>
</Modal>
);
}
export default React.memo(Validate);

View File

@ -0,0 +1,32 @@
// Copyright 2017-2025 @polkadot/app-staking-async authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { DeriveStakingAccount } from '@polkadot/api-derive/types';
import type { BN } from '@polkadot/util';
import React, { useMemo } from 'react';
import { MarkWarning } from '@polkadot/react-components';
import { formatBalance } from '@polkadot/util';
import { useTranslation } from '../../translate.js';
interface Props {
minBond?: BN;
stakingInfo?: DeriveStakingAccount;
}
function WarnBond ({ minBond, stakingInfo }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
const isBelow = useMemo(
() => minBond && stakingInfo && stakingInfo.stakingLedger.active.unwrap().lt(minBond),
[minBond, stakingInfo]
);
return isBelow
? <MarkWarning content={t('Your bonded amount is below the on-chain minimum threshold of {{minBond}} and may be chilled. Bond extra funds to increase the bonded amount.', { replace: { minBond: formatBalance(minBond) } })} />
: null;
}
export default React.memo(WarnBond);

View File

@ -0,0 +1,377 @@
// Copyright 2017-2025 @polkadot/app-staking-async authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ApiPromise } from '@polkadot/api';
import type { DeriveBalancesAll, DeriveStakingAccount } from '@polkadot/api-derive/types';
import type { SortedTargets } from '@polkadot/app-staking/types';
import type { StakerState } from '@polkadot/react-hooks/types';
import type { PalletStakingUnappliedSlash } from '@polkadot/types/lookup';
import type { BN } from '@polkadot/util';
import type { Slash } from '../types.js';
import React, { useCallback, useMemo } from 'react';
import { AddressInfo, AddressMini, AddressSmall, Badge, Button, Menu, Popup, StakingBonded, StakingRedeemable, StakingUnbonding, styled, TxButton } from '@polkadot/react-components';
import { useApi, useCall, useQueue, useToggle } from '@polkadot/react-hooks';
import { formatNumber, isFunction } from '@polkadot/util';
import { useTranslation } from '../../translate.js';
import useSlashingSpans from '../useSlashingSpans.js';
import BondExtra from './BondExtra.js';
import InjectKeys from './InjectKeys.js';
import KickNominees from './KickNominees.js';
import ListNominees from './ListNominees.js';
import Nominate from './Nominate.js';
import Rebond from './Rebond.js';
import SetControllerAccount from './SetControllerAccount.js';
import SetRewardDestination from './SetRewardDestination.js';
import SetSessionKey from './SetSessionKey.js';
import Unbond from './Unbond.js';
import Validate from './Validate.js';
import WarnBond from './WarnBond.js';
interface Props {
allSlashes?: [BN, PalletStakingUnappliedSlash[]][];
className?: string;
isDisabled?: boolean;
info: StakerState;
minCommission?: BN;
next?: string[];
targets: SortedTargets;
validators?: string[];
}
function extractSlashes (stashId: string, allSlashes: [BN, PalletStakingUnappliedSlash[]][] = []): Slash[] {
return allSlashes
.map(([era, all]) => ({
era,
slashes: all.filter(({ others, validator }) =>
validator.eq(stashId) || others.some(([nominatorId]) => nominatorId.eq(stashId))
)
}))
.filter(({ slashes }) => slashes.length);
}
function useStashCalls (api: ApiPromise, stashId: string) {
const params = useMemo(() => [stashId], [stashId]);
const balancesAll = useCall<DeriveBalancesAll>(api.derive.balances?.all, params);
const stakingAccount = useCall<DeriveStakingAccount>(api.derive.staking.account, params);
const spanCount = useSlashingSpans(stashId);
return { balancesAll, spanCount, stakingAccount };
}
function Account ({ allSlashes, className = '', info: { controllerId, destination, hexSessionIdNext, hexSessionIdQueue, isLoading, isOwnController, isOwnStash, isStashNominating, isStashValidating, nominating, sessionIds, stakingLedger, stashId }, isDisabled, minCommission, targets }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api } = useApi();
const { queueExtrinsic } = useQueue();
const [isBondExtraOpen, toggleBondExtra] = useToggle();
const [isInjectOpen, toggleInject] = useToggle();
const [isKickOpen, toggleKick] = useToggle();
const [isNominateOpen, toggleNominate] = useToggle();
const [isRebondOpen, toggleRebond] = useToggle();
const [isRewardDestinationOpen, toggleRewardDestination] = useToggle();
const [isSetControllerOpen, toggleSetController] = useToggle();
const [isSetSessionOpen, toggleSetSession] = useToggle();
const [isUnbondOpen, toggleUnbond] = useToggle();
const [isValidateOpen, toggleValidate] = useToggle();
const { balancesAll, spanCount, stakingAccount } = useStashCalls(api, stashId);
const needsSetController = useMemo(
() => (api.tx.staking.setController.meta.args.length === 1) || (stashId !== controllerId),
[api, controllerId, stashId]
);
const slashes = useMemo(
() => extractSlashes(stashId, allSlashes),
[allSlashes, stashId]
);
const withdrawFunds = useCallback(
() => queueExtrinsic({
accountId: controllerId,
extrinsic: api.tx.staking.withdrawUnbonded.meta.args.length === 1
? api.tx.staking.withdrawUnbonded(spanCount)
// @ts-expect-error Previous generation
: api.tx.staking.withdrawUnbonded()
}),
[api, controllerId, queueExtrinsic, spanCount]
);
const hasBonded = !!stakingAccount?.stakingLedger && !stakingAccount.stakingLedger.active?.isEmpty;
return (
<StyledTr className={className}>
<td className='badge together'>
{slashes.length !== 0 && (
<Badge
color='red'
hover={t('Slashed in era {{eras}}', {
replace: {
eras: slashes.map(({ era }) => formatNumber(era)).join(', ')
}
})}
icon='skull-crossbones'
/>
)}
</td>
<td className='address'>
<AddressSmall value={stashId} />
{isBondExtraOpen && (
<BondExtra
controllerId={controllerId}
onClose={toggleBondExtra}
stakingInfo={stakingAccount}
stashId={stashId}
/>
)}
{isInjectOpen && (
<InjectKeys onClose={toggleInject} />
)}
{isKickOpen && controllerId && (
<KickNominees
controllerId={controllerId}
onClose={toggleKick}
stashId={stashId}
/>
)}
{isNominateOpen && controllerId && (
<Nominate
controllerId={controllerId}
nominating={nominating}
onClose={toggleNominate}
stashId={stashId}
targets={targets}
/>
)}
{isRebondOpen && (
<Rebond
controllerId={controllerId}
onClose={toggleRebond}
stakingInfo={stakingAccount}
stashId={stashId}
/>
)}
{isSetControllerOpen && controllerId && (
<SetControllerAccount
defaultControllerId={controllerId}
onClose={toggleSetController}
stashId={stashId}
/>
)}
{isRewardDestinationOpen && controllerId && (
<SetRewardDestination
controllerId={controllerId}
defaultDestination={destination}
onClose={toggleRewardDestination}
stashId={stashId}
/>
)}
{isSetSessionOpen && controllerId && (
<SetSessionKey
controllerId={controllerId}
onClose={toggleSetSession}
stashId={stashId}
/>
)}
{isUnbondOpen && (
<Unbond
controllerId={controllerId}
onClose={toggleUnbond}
stakingLedger={stakingLedger}
stashId={stashId}
/>
)}
{isValidateOpen && controllerId && (
<Validate
controllerId={controllerId}
minCommission={minCommission}
onClose={toggleValidate}
stashId={stashId}
/>
)}
</td>
<td className='address'>
<AddressMini value={controllerId} />
</td>
<td className='start media--1200'>
{destination?.isAccount
? <AddressMini value={destination.asAccount} />
: destination?.toString()
}
</td>
<td className='number'>
<StakingBonded stakingInfo={stakingAccount} />
<StakingUnbonding stakingInfo={stakingAccount} />
<StakingRedeemable stakingInfo={stakingAccount} />
</td>
{isStashValidating
? (
<td className='all'>
<AddressInfo
address={stashId}
withBalance={false}
withHexSessionId={hexSessionIdNext !== '0x' && [hexSessionIdQueue, hexSessionIdNext]}
withValidatorPrefs
/>
<WarnBond
minBond={targets.minValidatorBond}
stakingInfo={stakingAccount}
/>
</td>
)
: (
<td className='all expand'>
{isStashNominating && (
<>
<ListNominees
nominating={nominating}
stashId={stashId}
/>
<WarnBond
minBond={targets.minNominatorBond}
stakingInfo={stakingAccount}
/>
</>
)}
</td>
)
}
<td className='button'>
{!isLoading && (
<>
{(isStashNominating || isStashValidating)
? (
<TxButton
accountId={controllerId}
icon='stop'
isDisabled={!isOwnController || isDisabled}
key='stop'
label={t('Stop')}
tx={api.tx.staking.chill}
/>
)
: (
<Button.Group>
{(!sessionIds.length || hexSessionIdNext === '0x')
? (
<Button
icon='sign-in-alt'
isDisabled={!isOwnController || isDisabled}
key='set'
label={t('Session Key')}
onClick={toggleSetSession}
/>
)
: (
<Button
icon='certificate'
isDisabled={!isOwnController || isDisabled || !hasBonded}
key='validate'
label={t('Validate')}
onClick={toggleValidate}
/>
)
}
<Button
icon='hand-paper'
isDisabled={!isOwnController || isDisabled || !hasBonded}
key='nominate'
label={t('Nominate')}
onClick={toggleNominate}
/>
</Button.Group>
)
}
<Popup
isDisabled={isDisabled}
key='settings'
value={
<Menu>
<Menu.Item
isDisabled={!isOwnStash || !balancesAll?.freeBalance.gtn(0)}
label={t('Bond more funds')}
onClick={toggleBondExtra}
/>
<Menu.Item
isDisabled={!isOwnController || !stakingAccount?.stakingLedger || stakingAccount.stakingLedger.active?.isEmpty}
label={t('Unbond funds')}
onClick={toggleUnbond}
/>
<Menu.Item
isDisabled={!isOwnController || !stakingAccount?.unlocking?.length}
label={t('Rebond funds')}
onClick={toggleRebond}
/>
<Menu.Item
isDisabled={!isOwnController || !stakingAccount?.redeemable || !stakingAccount.redeemable.gtn(0)}
label={t('Withdraw unbonded funds')}
onClick={withdrawFunds}
/>
<Menu.Divider />
<Menu.Item
isDisabled={!isOwnStash || !needsSetController}
label={t('Change controller account')}
onClick={toggleSetController}
/>
<Menu.Item
isDisabled={!isOwnController}
label={t('Change reward destination')}
onClick={toggleRewardDestination}
/>
{isStashValidating && (
<>
<Menu.Item
isDisabled={!isOwnController}
label={t('Change validator preferences')}
onClick={toggleValidate}
/>
{isFunction(api.tx.staking.kick) && (
<Menu.Item
isDisabled={!isOwnController}
label={t('Remove nominees')}
onClick={toggleKick}
/>
)}
</>
)}
<Menu.Divider />
{!isStashNominating && (
<Menu.Item
isDisabled={!isOwnController}
label={t('Change session keys')}
onClick={toggleSetSession}
/>
)}
{isStashNominating && (
<Menu.Item
isDisabled={!isOwnController || !targets.validators?.length}
label={t('Set nominees')}
onClick={toggleNominate}
/>
)}
{!isStashNominating && (
<Menu.Item
label={t('Inject session keys (advanced)')}
onClick={toggleInject}
/>
)}
</Menu>
}
/>
</>
)}
</td>
</StyledTr>
);
}
const StyledTr = styled.tr`
.ui--Button-Group {
display: inline-block;
margin-right: 0.25rem;
vertical-align: inherit;
}
`;
export default React.memo(Account);

View File

@ -0,0 +1,59 @@
// Copyright 2017-2025 @polkadot/app-staking-async authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { SortedTargets } from '@polkadot/app-staking/types';
import type { StakerState } from '@polkadot/react-hooks/types';
import type { PalletStakingUnappliedSlash } from '@polkadot/types/lookup';
import type { BN } from '@polkadot/util';
import React, { useRef } from 'react';
import { Table } from '@polkadot/react-components';
import { useTranslation } from '../translate.js';
import Account from './Account/index.js';
interface Props {
allSlashes: [BN, PalletStakingUnappliedSlash[]][];
className?: string;
footer: React.ReactNode;
isInElection?: boolean;
list?: StakerState[];
minCommission?: BN;
targets: SortedTargets;
}
function Accounts ({ allSlashes, className, footer, isInElection, list, minCommission, targets }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const hdrRef = useRef<[React.ReactNode?, string?, number?][]>([
[t('stashes'), 'start', 2],
[t('controller'), 'address'],
[t('rewards'), 'start media--1200'],
[t('bonded'), 'number'],
[],
[]
]);
return (
<Table
className={className}
empty={list && t('No funds staked yet. Bond funds to validate or nominate a validator')}
footer={footer}
header={hdrRef.current}
>
{list?.map((info): React.ReactNode => (
<Account
allSlashes={allSlashes}
info={info}
isDisabled={isInElection}
key={info.stashId}
minCommission={minCommission}
targets={targets}
/>
))}
</Table>
);
}
export default React.memo(Accounts);

View File

@ -0,0 +1,135 @@
// Copyright 2017-2025 @polkadot/app-staking-async authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { SortedTargets } from '@polkadot/app-staking/types';
import type { BondInfo, NominateInfo } from './partials/types.js';
import React, { useCallback, useState } from 'react';
import { BatchWarning, Button, Modal, TxButton } from '@polkadot/react-components';
import { useApi, useToggle } from '@polkadot/react-hooks';
import { isFunction } from '@polkadot/util';
import { useTranslation } from '../translate.js';
import BondPartial from './partials/Bond.js';
import NominatePartial from './partials/Nominate.js';
interface Props {
isInElection?: boolean;
targets: SortedTargets;
}
const EMPTY_NOMS: string[] = [];
const NUM_STEPS = 2;
function NewNominator ({ isInElection, targets }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api } = useApi();
const [isVisible, toggleVisible] = useToggle();
const [{ bondTx, controllerId, controllerTx, stashId }, setBondInfo] = useState<BondInfo>({});
const [{ nominateTx }, setNominateInfo] = useState<NominateInfo>({});
const [step, setStep] = useState(1);
const isDisabled = isInElection || !isFunction(api.tx.utility?.batch);
const _nextStep = useCallback(
() => setStep((step) => step + 1),
[]
);
const _prevStep = useCallback(
() => setStep((step) => step - 1),
[]
);
const _toggle = useCallback(
(): void => {
setBondInfo({});
setNominateInfo({});
setStep(1);
toggleVisible();
},
[toggleVisible]
);
return (
<>
<Button
icon='plus'
isDisabled={isDisabled || !targets.validators?.length}
key='new-nominator'
label={t('Nominator')}
onClick={_toggle}
/>
{isVisible && (
<Modal
header={t('Setup Nominator {{step}}/{{NUM_STEPS}}', {
replace: {
NUM_STEPS,
step
}
})}
onClose={_toggle}
size='large'
>
<Modal.Content>
{step === 1 && (
<BondPartial
isNominating
minNominated={targets.minNominated}
minNominatorBond={targets.minNominatorBond}
onChange={setBondInfo}
/>
)}
{controllerId && stashId && step === 2 && (
<NominatePartial
controllerId={controllerId}
nominating={EMPTY_NOMS}
onChange={setNominateInfo}
stashId={stashId}
targets={targets}
/>
)}
<Modal.Columns>
<BatchWarning />
</Modal.Columns>
</Modal.Content>
<Modal.Actions>
<Button
icon='step-backward'
isDisabled={step === 1}
label={t('prev')}
onClick={_prevStep}
/>
{step === NUM_STEPS
? (
<TxButton
accountId={stashId}
icon='sign-in-alt'
isDisabled={!bondTx || !nominateTx || !stashId || !controllerId}
label={t('Bond & Nominate')}
onStart={_toggle}
params={[
stashId === controllerId
? [bondTx, nominateTx]
: [bondTx, nominateTx, controllerTx]
]}
tx={api.tx.utility.batchAll || api.tx.utility.batch}
/>
)
: (
<Button
icon='step-forward'
isDisabled={!bondTx}
label={t('next')}
onClick={_nextStep}
/>
)
}
</Modal.Actions>
</Modal>
)}
</>
);
}
export default React.memo(NewNominator);

View File

@ -0,0 +1,60 @@
// Copyright 2017-2025 @polkadot/app-staking-async authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { BondInfo } from './partials/types.js';
import React, { useCallback, useState } from 'react';
import { Button, Modal, TxButton } from '@polkadot/react-components';
import { useToggle } from '@polkadot/react-hooks';
import { useTranslation } from '../translate.js';
import BondPartial from './partials/Bond.js';
function NewStash (): React.ReactElement {
const { t } = useTranslation();
const [isVisible, toggleVisible] = useToggle();
const [{ bondTx, stashId }, setBondInfo] = useState<BondInfo>({});
const _toggle = useCallback(
(): void => {
setBondInfo({});
toggleVisible();
},
[toggleVisible]
);
return (
<>
<Button
icon='plus'
key='new-stash'
label={t('Stash')}
onClick={_toggle}
/>
{isVisible && (
<Modal
header={t('Bonding Preferences')}
onClose={_toggle}
size='large'
>
<Modal.Content>
<BondPartial onChange={setBondInfo} />
</Modal.Content>
<Modal.Actions>
<TxButton
accountId={stashId}
extrinsic={bondTx}
icon='sign-in-alt'
isDisabled={!bondTx || !stashId}
label={t('Bond')}
onStart={_toggle}
/>
</Modal.Actions>
</Modal>
)}
</>
);
}
export default React.memo(NewStash);

View File

@ -0,0 +1,143 @@
// Copyright 2017-2025 @polkadot/app-staking-async authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { SortedTargets } from '@polkadot/app-staking/types';
import type { BN } from '@polkadot/util';
import type { BondInfo, SessionInfo, ValidateInfo } from './partials/types.js';
import React, { useCallback, useState } from 'react';
import { BatchWarning, Button, Modal, TxButton } from '@polkadot/react-components';
import { useApi, useToggle } from '@polkadot/react-hooks';
import { isFunction } from '@polkadot/util';
import { useTranslation } from '../translate.js';
import BondPartial from './partials/Bond.js';
import SessionKeyPartial from './partials/SessionKey.js';
import ValidatePartial from './partials/Validate.js';
interface Props {
isInElection?: boolean;
minCommission?: BN;
targets: SortedTargets;
}
const NUM_STEPS = 2;
function NewValidator ({ isInElection, minCommission, targets }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api } = useApi();
const [isVisible, toggleVisible] = useToggle();
const [{ bondTx, controllerId, controllerTx, stashId }, setBondInfo] = useState<BondInfo>({});
const [{ sessionTx }, setSessionInfo] = useState<SessionInfo>({});
const [{ validateTx }, setValidateInfo] = useState<ValidateInfo>({});
const [step, setStep] = useState(1);
const isDisabled = isInElection || !isFunction(api.tx.utility?.batch);
const _nextStep = useCallback(
() => setStep((step) => step + 1),
[]
);
const _prevStep = useCallback(
() => setStep((step) => step - 1),
[]
);
const _toggle = useCallback(
(): void => {
setBondInfo({});
setSessionInfo({});
setValidateInfo({});
setStep(1);
toggleVisible();
},
[toggleVisible]
);
return (
<>
<Button
icon='plus'
isDisabled={isDisabled}
key='new-validator'
label={t('Validator')}
onClick={_toggle}
/>
{isVisible && (
<Modal
header={t('Setup Validator {{step}}/{{NUM_STEPS}}', {
replace: {
NUM_STEPS,
step
}
})}
onClose={_toggle}
size='large'
>
<Modal.Content>
{step === 1 && (
<BondPartial
minValidatorBond={targets.minValidatorBond}
onChange={setBondInfo}
/>
)}
{controllerId && stashId && step === 2 && (
<>
<SessionKeyPartial
controllerId={controllerId}
onChange={setSessionInfo}
stashId={stashId}
withFocus
/>
<ValidatePartial
controllerId={controllerId}
minCommission={minCommission}
onChange={setValidateInfo}
stashId={stashId}
/>
</>
)}
<Modal.Columns>
<BatchWarning />
</Modal.Columns>
</Modal.Content>
<Modal.Actions>
<Button
icon='step-backward'
isDisabled={step === 1}
label={t('prev')}
onClick={_prevStep}
/>
{step === NUM_STEPS
? (
<TxButton
accountId={stashId}
icon='sign-in-alt'
isDisabled={!bondTx || !sessionTx || !validateTx}
label={t('Bond & Validate')}
onStart={_toggle}
params={[
controllerId === stashId
? [bondTx, sessionTx, validateTx]
: [bondTx, sessionTx, validateTx, controllerTx]
]}
tx={api.tx.utility.batchAll || api.tx.utility.batch}
/>
)
: (
<Button
icon='step-forward'
isDisabled={!bondTx}
label={t('next')}
onClick={_nextStep}
/>
)}
</Modal.Actions>
</Modal>
)}
</>
);
}
export default React.memo(NewValidator);

View File

@ -0,0 +1,201 @@
// Copyright 2017-2025 @polkadot/app-staking-async authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { DeriveSessionProgress, DeriveUnlocking } from '@polkadot/api-derive/types';
import type { SortedTargets } from '@polkadot/app-staking/types';
import type { PoolInfo } from '@polkadot/app-staking2/Pools/types';
import type { PalletNominationPoolsPoolMember, PalletNominationPoolsPoolRoles } from '@polkadot/types/lookup';
import React, { useCallback, useMemo } from 'react';
import { AddressSmall, Badge, Menu, Popup, StakingRedeemable, StakingUnbonding } from '@polkadot/react-components';
import { useApi, useQueue, useToggle } from '@polkadot/react-hooks';
import { FormatBalance } from '@polkadot/react-query';
import { BN, formatNumber } from '@polkadot/util';
import { useTranslation } from '../../translate.js';
import ListNominees from '../Account/ListNominees.js';
import Nominate from '../Account/Nominate.js';
import useSlashingSpans from '../useSlashingSpans.js';
import BondExtra from './BondExtra.js';
import Unbond from './Unbond.js';
import useAccountInfo from './useAccountInfo.js';
interface Props {
accountId: string;
className?: string;
info: PoolInfo;
isFirst: boolean;
poolId: BN;
sessionProgress?: DeriveSessionProgress;
targets: SortedTargets;
}
interface Roles {
isNominator: boolean;
}
function extractRoles (accountId: string, { nominator, root }: PalletNominationPoolsPoolRoles): Roles {
return {
isNominator: nominator.eq(accountId) || root.eq(accountId)
};
}
function calcUnbonding (accountId: string, stashId: string, { activeEra }: DeriveSessionProgress, { unbondingEras }: PalletNominationPoolsPoolMember): { accountId: string, controllerId: string, redeemable: BN, stashId: string, unlocking: DeriveUnlocking[] } {
const unlocking: DeriveUnlocking[] = [];
const redeemable = new BN(0);
for (const [era, value] of unbondingEras.entries()) {
if (era.lte(activeEra)) {
redeemable.iadd(value);
} else {
unlocking.push({ remainingEras: era.sub(activeEra), value });
}
}
return {
accountId,
controllerId: accountId,
redeemable,
stashId,
unlocking
};
}
function Pool ({ accountId, className, info: { bonded: { roles }, metadata, nominating, stashId }, isFirst, poolId, sessionProgress, targets }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api } = useApi();
const spanCount = useSlashingSpans(stashId);
const { queueExtrinsic } = useQueue();
const [isBondOpen, toggleBond] = useToggle();
const [isNominateOpen, toggleNominate] = useToggle();
const [isUnbondOpen, toggleUnbond] = useToggle();
const accInfo = useAccountInfo(accountId);
const stakingInfo = useMemo(
() => sessionProgress && accInfo?.member.unbondingEras && !accInfo.member.unbondingEras.isEmpty
? calcUnbonding(accountId, stashId, sessionProgress, accInfo.member)
: null,
[accInfo, accountId, stashId, sessionProgress]
);
const claimPayout = useCallback(
() => queueExtrinsic({
accountId,
extrinsic: api.tx.nominationPools.claimPayout()
}),
[api, accountId, queueExtrinsic]
);
const withdrawUnbonded = useCallback(
() => queueExtrinsic({
accountId,
extrinsic: api.tx.nominationPools.withdrawUnbonded(accountId, spanCount)
}),
[api, accountId, spanCount, queueExtrinsic]
);
const { isNominator } = useMemo(
() => extractRoles(accountId, roles),
[accountId, roles]
);
return (
<tr className={className}>
<td className='number'><h1>{isFirst && formatNumber(poolId)}</h1></td>
<td className='start'>{isFirst && metadata}</td>
<td className='address'><AddressSmall value={accountId} /></td>
<td className='number'>
{accInfo && (
<>
{!accInfo.member.points.isZero() && <FormatBalance value={accInfo.member.points} />}
{stakingInfo && (
<>
<StakingUnbonding stakingInfo={stakingInfo} />
<StakingRedeemable
isPool
stakingInfo={stakingInfo}
/>
</>
)}
</>
)}
</td>
<td className='number'>{accInfo && !accInfo.claimable.isZero() && <FormatBalance value={accInfo.claimable} />}</td>
<td className='number'>
{isFirst && nominating && (
<ListNominees
nominating={nominating}
stashId={stashId}
/>
)}
</td>
<td className='button'>
<Badge
color={isNominator ? 'green' : 'transparent'}
icon='hand-paper'
/>
{isBondOpen && (
<BondExtra
controllerId={accountId}
onClose={toggleBond}
poolId={poolId}
/>
)}
{isNominateOpen && (
<Nominate
controllerId={accountId}
nominating={nominating}
onClose={toggleNominate}
poolId={poolId}
stashId={accountId}
targets={targets}
/>
)}
{accInfo && isUnbondOpen && (
<Unbond
controllerId={accountId}
maxUnbond={accInfo.member.points}
onClose={toggleUnbond}
poolId={poolId}
/>
)}
<Popup
key='settings'
value={
<Menu>
<Menu.Item
label={t('Bond more funds')}
onClick={toggleBond}
/>
<Menu.Item
isDisabled={!accInfo || accInfo.member.points.isZero()}
label={t('Unbond funds')}
onClick={toggleUnbond}
/>
<Menu.Divider />
<Menu.Item
isDisabled={!accInfo || accInfo.claimable.isZero()}
label={t('Withdraw claimable')}
onClick={claimPayout}
/>
<Menu.Item
isDisabled={!stakingInfo || stakingInfo.redeemable.isZero()}
label={t('Withdraw unbonded')}
onClick={withdrawUnbonded}
/>
<Menu.Divider />
<Menu.Item
isDisabled={!isNominator}
label={t('Set nominees')}
onClick={toggleNominate}
/>
</Menu>
}
/>
</td>
</tr>
);
}
export default React.memo(Pool);

View File

@ -0,0 +1,92 @@
// Copyright 2017-2025 @polkadot/app-staking-async authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { BN } from '@polkadot/util';
import React, { useRef, useState } from 'react';
import useAmountError from '@polkadot/app-staking2/Pools/useAmountError';
import { Dropdown, InputBalance, Modal, TxButton } from '@polkadot/react-components';
import { useApi } from '@polkadot/react-hooks';
import { BalanceFree } from '@polkadot/react-query';
import { BN_ZERO } from '@polkadot/util';
import { useTranslation } from '../../translate.js';
import PoolInfo from '../partials/PoolInfo.js';
interface Props {
className?: string;
controllerId: string;
onClose: () => void;
poolId: BN;
}
const DEFAULT_TYPE = 'rewards';
function BondExtra ({ className, controllerId, onClose, poolId }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api } = useApi();
const [type, setType] = useState(DEFAULT_TYPE);
const [amount, setAmount] = useState<BN | undefined>();
const isAmountError = useAmountError(controllerId, amount, BN_ZERO);
const typeRef = useRef([
{ text: t('Free balance'), value: 'free' },
{ text: t('Pool rewards'), value: 'rewards' }
]);
return (
<Modal
className={className}
header={t('Bond extra into pool')}
onClose={onClose}
size='large'
>
<Modal.Content>
<PoolInfo
controllerId={controllerId}
poolId={poolId}
/>
<Modal.Columns hint={t('You can either bond a specific amount from your free balance, or all of the accumulated rewards.')}>
<Dropdown
defaultValue={DEFAULT_TYPE}
label={t('type of funds to bond')}
onChange={setType}
options={typeRef.current}
/>
{type === 'free' && (
<InputBalance
autoFocus
isError={isAmountError}
label={t('additional free funds to bond')}
labelExtra={
<BalanceFree
label={<span className='label'>{t('balance')}</span>}
params={controllerId}
/>
}
onChange={setAmount}
/>
)}
</Modal.Columns>
</Modal.Content>
<Modal.Actions>
<TxButton
accountId={controllerId}
icon='sign-in-alt'
isDisabled={type === 'free' && isAmountError}
label={t('Bond Extra')}
onStart={onClose}
params={[
type === 'free'
? { FreeBalance: amount }
: 'Rewards'
]}
tx={api.tx.nominationPools.bondExtra}
/>
</Modal.Actions>
</Modal>
);
}
export default React.memo(BondExtra);

View File

@ -0,0 +1,84 @@
// Copyright 2017-2025 @polkadot/app-staking-async authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { BN } from '@polkadot/util';
import React, { useState } from 'react';
import { InputBalance, Modal, Static, TxButton } from '@polkadot/react-components';
import { useApi } from '@polkadot/react-hooks';
import { BlockToTime, FormatBalance } from '@polkadot/react-query';
import { useTranslation } from '../../translate.js';
import PoolInfo from '../partials/PoolInfo.js';
import useUnbondDuration from '../useUnbondDuration.js';
interface Props {
className?: string;
controllerId: string;
maxUnbond: BN;
onClose: () => void;
poolId: BN;
}
function Unbond ({ className, controllerId, maxUnbond, onClose, poolId }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api } = useApi();
const [amount, setAmount] = useState<BN | undefined>();
const bondedBlocks = useUnbondDuration();
const isAmountError = !amount || !maxUnbond || amount.gt(maxUnbond);
return (
<Modal
className={className}
header={t('Unbond funds from pool')}
onClose={onClose}
size='large'
>
<Modal.Content>
<PoolInfo
controllerId={controllerId}
poolId={poolId}
/>
<Modal.Columns hint={t('The amount to unbond. It should be less or equal to the full bonded amount.')}>
<InputBalance
autoFocus
defaultValue={maxUnbond}
isError={isAmountError}
label={t('amount to unbond')}
labelExtra={
<FormatBalance
label={<span className='label'>{t('bonded')}</span>}
value={maxUnbond}
/>
}
maxValue={maxUnbond}
onChange={setAmount}
withMax
/>
{bondedBlocks?.gtn(0) && (
<Static
label={t('on-chain bonding duration')}
>
<BlockToTime value={bondedBlocks} />
</Static>
)}
</Modal.Columns>
</Modal.Content>
<Modal.Actions>
<TxButton
accountId={controllerId}
icon='unlock'
isDisabled={isAmountError}
label={t('Unbond')}
onStart={onClose}
params={[controllerId, amount]}
tx={api.tx.nominationPools.unbond}
/>
</Modal.Actions>
</Modal>
);
}
export default React.memo(Unbond);

View File

@ -0,0 +1,49 @@
// Copyright 2017-2025 @polkadot/app-staking-async authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { DeriveSessionProgress } from '@polkadot/api-derive/types';
import type { SortedTargets } from '@polkadot/app-staking/types';
import type { u32 } from '@polkadot/types';
import type { PalletNominationPoolsPoolMember } from '@polkadot/types/lookup';
import React from 'react';
import usePoolInfo from '@polkadot/app-staking2/Pools/usePoolInfo';
import Account from './Account.js';
interface Props {
count: number;
className?: string;
members: Record<string, PalletNominationPoolsPoolMember>;
poolId: u32;
sessionProgress?: DeriveSessionProgress;
targets: SortedTargets;
}
function Pool ({ className, count, members, poolId, sessionProgress, targets }: Props): React.ReactElement<Props> | null {
const info = usePoolInfo(poolId);
if (!info) {
return null;
}
return (
<>
{Object.keys(members).map((accountId, index) => (
<Account
accountId={accountId}
className={`${className || ''} ${count % 2 ? 'isEven' : 'isOdd'}`}
info={info}
isFirst={index === 0}
key={`${poolId.toString()}:${accountId}`}
poolId={poolId}
sessionProgress={sessionProgress}
targets={targets}
/>
))}
</>
);
}
export default React.memo(Pool);

View File

@ -0,0 +1,10 @@
// Copyright 2017-2025 @polkadot/app-staking-async authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { PalletNominationPoolsPoolMember } from '@polkadot/types/lookup';
import type { BN } from '@polkadot/util';
export interface AccountInfo {
claimable: BN;
member: PalletNominationPoolsPoolMember;
}

View File

@ -0,0 +1,36 @@
// Copyright 2017-2025 @polkadot/app-staking-async authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { Option } from '@polkadot/types';
import type { PalletNominationPoolsPoolMember } from '@polkadot/types/lookup';
import type { AccountInfo } from './types.js';
import { useEffect, useState } from 'react';
import { createNamedHook, useApi, useCall, useIsMountedRef } from '@polkadot/react-hooks';
const OPT_DEL = {
transform: (opt: Option<PalletNominationPoolsPoolMember>): PalletNominationPoolsPoolMember | null =>
opt.unwrapOr(null)
};
function useAccountInfoImpl (accountId: string): AccountInfo | null {
const { api } = useApi();
const isMountedRef = useIsMountedRef();
const [state, setState] = useState<AccountInfo | null>(null);
const member = useCall(api.query.nominationPools.poolMembers, [accountId], OPT_DEL);
useEffect((): void => {
member &&
api.call.nominationPoolsApi
?.pendingRewards(accountId)
.then((claimable) =>
isMountedRef.current && setState({ claimable, member })
)
.catch(console.error);
}, [accountId, member, api, isMountedRef]);
return state;
}
export default createNamedHook('useAccountInfo', useAccountInfoImpl);

View File

@ -0,0 +1,61 @@
// Copyright 2017-2025 @polkadot/app-staking-async authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { DeriveSessionProgress } from '@polkadot/api-derive/types';
import type { SortedTargets } from '@polkadot/app-staking/types';
import type { OwnPool } from '@polkadot/app-staking2/Pools/types';
import type { PalletStakingUnappliedSlash } from '@polkadot/types/lookup';
import type { BN } from '@polkadot/util';
import React, { useRef } from 'react';
import { Table } from '@polkadot/react-components';
import { useApi, useCall } from '@polkadot/react-hooks';
import { useTranslation } from '../translate.js';
import Pool from './Pool/index.js';
interface Props {
allSlashes: [BN, PalletStakingUnappliedSlash[]][];
className?: string;
isInElection?: boolean;
list?: OwnPool[];
minCommission?: BN;
targets: SortedTargets;
}
function Pools ({ className, list, targets }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api } = useApi();
const sessionProgress = useCall<DeriveSessionProgress>(api.derive.session.progress);
const hdrRef = useRef<[React.ReactNode?, string?, number?][]>([
[t('pools'), 'start', 2],
[t('account'), 'address'],
[t('bonded')],
[t('claimable')],
[],
[]
]);
return (
<Table
className={className}
empty={list && t('Not participating in any pools. Join a pool first.')}
header={hdrRef.current}
>
{list?.map(({ members, poolId }, count): React.ReactNode => (
<Pool
count={count}
key={poolId.toString()}
members={members}
poolId={poolId}
sessionProgress={sessionProgress}
targets={targets}
/>
))}
</Table>
);
}
export default React.memo(Pools);

View File

@ -0,0 +1,19 @@
// Copyright 2017-2025 @polkadot/react-components authors & contributors
// SPDX-License-Identifier: Apache-2.0
interface Option {
text: string;
value: string;
}
export function createDestPrev (t: (key: string, options?: { replace: Record<string, unknown> }) => string): Option[] {
return [
{ text: t('Stash account (increase the amount at stake)'), value: 'Staked' },
{ text: t('Stash account (do not increase the amount at stake)'), value: 'Stash' },
{ text: t('Controller account'), value: 'Controller' }
];
}
export function createDestCurr (t: (key: string, options?: { replace: Record<string, unknown> }) => string): Option[] {
return createDestPrev(t).concat({ text: t('Specified payment account'), value: 'Account' });
}

View File

@ -0,0 +1,210 @@
// Copyright 2017-2025 @polkadot/app-staking-async authors & contributors
// SPDX-License-Identifier: Apache-2.0
import '@polkadot/api-augment';
import type { SortedTargets } from '@polkadot/app-staking/types';
import type { OwnPool } from '@polkadot/app-staking2/Pools/types';
import type { StakerState } from '@polkadot/react-hooks/types';
import React, { useMemo, useRef, useState } from 'react';
import ElectionBanner from '@polkadot/app-staking/ElectionBanner';
import { Button, ToggleGroup } from '@polkadot/react-components';
import { useApi, useAvailableSlashes } from '@polkadot/react-hooks';
import { FormatBalance } from '@polkadot/react-query';
import { BN, BN_ZERO } from '@polkadot/util';
import { useTranslation } from '../translate.js';
import Accounts from './Accounts.js';
import NewNominator from './NewNominator.js';
import NewStash from './NewStash.js';
import NewValidator from './NewValidator.js';
import Pools from './Pools.js';
interface Props {
className?: string;
isInElection?: boolean;
minCommission?: BN;
ownPools?: OwnPool[];
ownStashes?: StakerState[];
next?: string[];
validators?: string[];
targets: SortedTargets;
}
interface State {
bondedNoms?: BN;
bondedNone?: BN;
bondedTotal?: BN;
bondedVals?: BN;
foundStashes?: StakerState[];
}
function assignValue ({ isStashNominating, isStashValidating }: StakerState): number {
return isStashValidating
? 1
: isStashNominating
? 5
: 99;
}
function sortStashes (a: StakerState, b: StakerState): number {
return assignValue(a) - assignValue(b);
}
function extractState (ownStashes?: StakerState[]): State {
if (!ownStashes) {
return {};
}
const bondedNoms = new BN(0);
const bondedNone = new BN(0);
const bondedVals = new BN(0);
const bondedTotal = new BN(0);
ownStashes.forEach(({ isStashNominating, isStashValidating, stakingLedger }): void => {
const value = stakingLedger?.total
? stakingLedger.total.unwrap()
: BN_ZERO;
bondedTotal.iadd(value);
if (isStashNominating) {
bondedNoms.iadd(value);
} else if (isStashValidating) {
bondedVals.iadd(value);
} else {
bondedNone.iadd(value);
}
});
return {
bondedNoms,
bondedNone,
bondedTotal,
bondedVals,
foundStashes: ownStashes.sort(sortStashes)
};
}
function filterStashes (stashTypeIndex: number, stashes: StakerState[]): StakerState[] {
return stashes.filter(({ isStashNominating, isStashValidating }) => {
switch (stashTypeIndex) {
case 1: return isStashNominating;
case 2: return isStashValidating;
case 3: return !isStashNominating && !isStashValidating;
default: return true;
}
});
}
function getValue (stashTypeIndex: number, { bondedNoms, bondedNone, bondedTotal, bondedVals }: State): BN | undefined {
switch (stashTypeIndex) {
case 0: return bondedTotal;
case 1: return bondedNoms;
case 2: return bondedVals;
case 3: return bondedNone;
default: return bondedTotal;
}
}
function formatTotal (stashTypeIndex: number, state: State): React.ReactNode {
const value = getValue(stashTypeIndex, state);
return value && <FormatBalance value={value} />;
}
function Actions ({ className = '', isInElection, minCommission, ownPools, ownStashes, targets }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api } = useApi();
const allSlashes = useAvailableSlashes();
const [accTypeIndex, setAccTypeIndex] = useState(0);
const [stashTypeIndex, setStashTypeIndex] = useState(0);
const accTypes = useRef([
{ text: t('Stashed'), value: 'stash' },
{ text: t('Pooled'), value: 'pool' }
]);
const stashTypes = useRef([
{ text: t('All stashes'), value: 'all' },
{ text: t('Nominators'), value: 'noms' },
{ text: t('Validators'), value: 'vals' },
{ text: t('Inactive'), value: 'chill' }
]);
const state = useMemo(
() => extractState(ownStashes),
[ownStashes]
);
const [filtered, footer] = useMemo(
() => [
state.foundStashes && filterStashes(stashTypeIndex, state.foundStashes),
(
<tr key='footer'>
<td colSpan={4} />
<td className='number'>{formatTotal(stashTypeIndex, state)}</td>
<td colSpan={2} />
</tr>
)
],
[state, stashTypeIndex]
);
return (
<div className={className}>
<Button.Group>
{api.consts.nominationPools && (
<ToggleGroup
onChange={setAccTypeIndex}
options={accTypes.current}
value={accTypeIndex}
/>
)}
{accTypeIndex === 0 && (
<>
<ToggleGroup
onChange={setStashTypeIndex}
options={stashTypes.current}
value={stashTypeIndex}
/>
<NewNominator
isInElection={isInElection}
targets={targets}
/>
<NewValidator
isInElection={isInElection}
minCommission={minCommission}
targets={targets}
/>
<NewStash />
</>
)}
</Button.Group>
<ElectionBanner isInElection={isInElection} />
{accTypeIndex === 0
? (
<Accounts
allSlashes={allSlashes}
footer={footer}
isInElection={isInElection}
list={filtered}
minCommission={minCommission}
targets={targets}
/>
)
: (
<Pools
allSlashes={allSlashes}
list={ownPools}
targets={targets}
/>
)
}
</div>
);
}
export default React.memo(Actions);

View File

@ -0,0 +1,218 @@
// Copyright 2017-2025 @polkadot/app-staking-async authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { DeriveBalancesAll } from '@polkadot/api-derive/types';
import type { BN } from '@polkadot/util';
import type { AmountValidateState, DestinationType } from '../types.js';
import type { BondInfo } from './types.js';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Dropdown, InputAddress, InputBalance, MarkError, Modal, Static } from '@polkadot/react-components';
import { useApi, useCall } from '@polkadot/react-hooks';
import { BalanceFree, BlockToTime } from '@polkadot/react-query';
import { BN_ZERO } from '@polkadot/util';
import { useTranslation } from '../../translate.js';
import InputValidateAmount from '../Account/InputValidateAmount.js';
import InputValidationController from '../Account/InputValidationController.js';
import { createDestCurr } from '../destOptions.js';
import useUnbondDuration from '../useUnbondDuration.js';
interface Props {
className?: string;
isNominating?: boolean;
minNominated?: BN;
minNominatorBond?: BN;
minValidatorBond?: BN;
onChange: (info: BondInfo) => void;
}
const EMPTY_INFO: BondInfo = {
bondTx: null,
controllerId: null,
controllerTx: null,
stashId: null
};
function Bond ({ className = '', isNominating, minNominated, minNominatorBond, minValidatorBond, onChange }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api } = useApi();
const [amount, setAmount] = useState<BN | undefined>();
const [amountError, setAmountError] = useState<AmountValidateState | null>(null);
const [controllerError, setControllerError] = useState<boolean>(false);
const [controllerId, setControllerId] = useState<string | null>(null);
const [destination, setDestination] = useState<DestinationType>('Staked');
const [destAccount, setDestAccount] = useState<string | null>(null);
const [stashId, setStashId] = useState<string | null>(null);
const [startBalance, setStartBalance] = useState<BN | null>(null);
const stashBalance = useCall<DeriveBalancesAll>(api.derive.balances?.all, [stashId]);
const destBalance = useCall<DeriveBalancesAll>(api.derive.balances?.all, [destAccount]);
const bondedBlocks = useUnbondDuration();
const needsController = useMemo(
() => api.tx.staking.bond.meta.args.length === 3,
[api]
);
const options = useMemo(
() => createDestCurr(t),
[t]
);
const _setError = useCallback(
(_: string | null, isFatal: boolean) => setControllerError(isFatal),
[]
);
useEffect((): void => {
stashBalance && setStartBalance(
stashBalance.freeBalance.gt(api.consts.balances.existentialDeposit)
? stashBalance.freeBalance.sub(api.consts.balances.existentialDeposit)
: BN_ZERO
);
}, [api, stashBalance]);
useEffect((): void => {
setStartBalance(null);
}, [stashId]);
useEffect((): void => {
const bondDest = destination === 'Account'
? { Account: destAccount }
: destination;
const [mapControllerId, mapControllerError] = needsController
? [controllerId, controllerError]
: [stashId, null];
onChange(
(amount && amount.gtn(0) && !amountError?.error && !mapControllerError && mapControllerId && stashId)
? {
bondTx: needsController
// The bond always goes through first, if a controller is used
// we have a batch with setController at the end
// @ts-expect-error Previous generation
? api.tx.staking.bond(stashId, amount, bondDest)
: api.tx.staking.bond(amount, bondDest),
controllerId: mapControllerId,
controllerTx: needsController
// @ts-expect-error Previous generation
? api.tx.staking.setController(mapControllerId)
: null,
stashId
}
: EMPTY_INFO
);
}, [api, amount, amountError, controllerError, controllerId, destination, destAccount, needsController, onChange, stashId]);
const hasValue = !!amount?.gtn(0);
const isAccount = destination === 'Account';
const isDestError = isAccount && destBalance && destBalance.accountId.eq(destAccount) && destBalance.freeBalance.isZero();
return (
<div className={className}>
<Modal.Columns
hint={
needsController
? (
<>
<p>{t('Think of the stash as your cold wallet and the controller as your hot wallet. Funding operations are controlled by the stash, any other non-funding actions by the controller itself.')}</p>
<p>{t('To ensure optimal fund security using the same stash/controller is strongly discouraged, but not forbidden.')}</p>
</>
)
: (
<>
<p>{t('The stash should be treated as a cold wallet.')}</p>
<p>{t('As such it is recommended that you setup a proxy to control operations via the stash.')}</p>
</>
)
}
>
<InputAddress
label={t('stash account')}
onChange={setStashId}
type='account'
value={stashId}
/>
{needsController && (
<>
<InputAddress
label={t('controller account')}
onChange={setControllerId}
type='account'
value={controllerId}
/>
<InputValidationController
accountId={stashId}
controllerId={controllerId}
onError={_setError}
/>
</>
)}
</Modal.Columns>
{startBalance && (
<Modal.Columns
hint={
<>
<p>{t('The amount placed at-stake should not be your full available amount to allow for transaction fees.')}</p>
<p>{t('Once bonded, it will need to be unlocked/withdrawn and will be locked for at least the bonding duration.')}</p>
</>
}
>
<InputBalance
autoFocus
defaultValue={startBalance}
isError={!hasValue || !!amountError?.error}
label={t('value bonded')}
labelExtra={
<BalanceFree
label={<span className='label'>{t('balance')}</span>}
params={stashId}
/>
}
onChange={setAmount}
/>
<InputValidateAmount
controllerId={controllerId}
isNominating={isNominating}
minNominated={minNominated}
minNominatorBond={minNominatorBond}
minValidatorBond={minValidatorBond}
onError={setAmountError}
stashId={stashId}
value={amount}
/>
{bondedBlocks?.gtn(0) && (
<Static
label={t('on-chain bonding duration')}
>
<BlockToTime value={bondedBlocks} />
</Static>
)}
</Modal.Columns>
)}
<Modal.Columns hint={t('Rewards (once paid) can be deposited to either the stash or controller, with different effects.')}>
<Dropdown
defaultValue={0}
label={t('payment destination')}
onChange={setDestination}
options={options}
value={destination}
/>
{isAccount && (
<InputAddress
label={t('the payment account')}
onChange={setDestAccount}
type='account'
value={destAccount}
/>
)}
{isDestError && (
<MarkError content={t('The selected destination account does not exist and cannot be used to receive rewards')} />
)}
</Modal.Columns>
</div>
);
}
export default React.memo(Bond);

View File

@ -0,0 +1,175 @@
// Copyright 2017-2025 @polkadot/app-staking-async authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { SortedTargets } from '@polkadot/app-staking/types';
import type { u32 } from '@polkadot/types';
import type { BN } from '@polkadot/util';
import type { NominateInfo } from './types.js';
import React, { useEffect, useState } from 'react';
import { MAX_NOMINATIONS, STORE_FAVS_BASE } from '@polkadot/app-staking/constants';
import { InputAddressMulti, MarkWarning, Modal, styled } from '@polkadot/react-components';
import { useApi, useFavorites } from '@polkadot/react-hooks';
import { useTranslation } from '../../translate.js';
import PoolInfo from './PoolInfo.js';
import SenderInfo from './SenderInfo.js';
interface Props {
className?: string;
controllerId: string;
nominating?: string[];
onChange: (info: NominateInfo) => void;
poolId?: BN;
stashId: string;
targets: SortedTargets;
withSenders?: boolean;
}
function Nominate ({ className = '', controllerId, nominating, onChange, poolId, stashId, targets: { nominateIds = [] }, withSenders }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api } = useApi();
const [favorites] = useFavorites(STORE_FAVS_BASE);
const [selected, setSelected] = useState<string[]>(nominating || []);
const [available] = useState<string[]>((): string[] => {
const shortlist = [
// ensure that the favorite is included in the list of stashes
...favorites.filter((a) => nominateIds.includes(a)),
// make sure the nominee is not in our favorites already
...(nominating || []).filter((a) => !favorites.includes(a))
];
return shortlist.concat(
...(nominateIds.filter((a) => !shortlist.includes(a)))
);
});
useEffect((): void => {
try {
onChange({
nominateTx: selected?.length
? poolId
? api.tx.nominationPools.nominate(poolId, selected)
: api.tx.staking.nominate(selected)
: null
});
} catch {
onChange({ nominateTx: null });
}
}, [api, onChange, poolId, selected]);
const maxNominations = api.consts.staking.maxNominatorRewardedPerValidator
? (api.consts.staking.maxNominatorRewardedPerValidator as u32).toNumber()
: api.consts.staking.maxNominations
? (api.consts.staking.maxNominations as u32).toNumber()
: MAX_NOMINATIONS;
return (
<StyledDiv className={className}>
{withSenders && (
poolId
? (
<PoolInfo
controllerId={controllerId}
poolId={poolId}
/>
)
: (
<SenderInfo
controllerId={controllerId}
stashId={stashId}
/>
)
)}
<Modal.Columns
hint={
<>
<p>{t('Nominators can be selected manually from the list of all currently available validators.')}</p>
<p>{t('Once transmitted the new selection will only take effect in 2 eras taking the new validator election cycle into account. Until then, the nominations will show as inactive.')}</p>
</>
}
>
<InputAddressMulti
available={available}
availableLabel={t('candidate accounts')}
defaultValue={nominating}
maxCount={maxNominations}
onChange={setSelected}
valueLabel={t('nominated accounts')}
/>
<MarkWarning content={t('You should trust your nominations to act competently and honest; basing your decision purely on their current profitability could lead to reduced profits or even loss of funds.')} />
</Modal.Columns>
</StyledDiv>
);
}
const StyledDiv = styled.div`
article.warning {
margin-top: 0;
}
.auto--toggle {
margin: 0.5rem 0 0;
text-align: right;
width: 100%;
}
.ui--Static .ui--AddressMini.padded.addressStatic {
padding-top: 0.5rem;
.ui--AddressMini-info {
min-width: 10rem;
max-width: 10rem;
}
}
.shortlist {
display: flex;
flex-wrap: wrap;
justify-content: center;
.candidate {
border: 1px solid #eee;
border-radius: 0.25rem;
margin: 0.25rem;
padding-bottom: 0.25rem;
padding-right: 0.5rem;
position: relative;
&::after {
content: '';
position: absolute;
top: 0;
right: 0;
border-color: transparent;
border-style: solid;
border-radius: 0.25em;
border-width: 0.25em;
}
&.isAye {
background: #fff;
border-color: #ccc;
}
&.member::after {
border-color: green;
}
&.runnerup::after {
border-color: steelblue;
}
.ui--AddressMini-icon {
z-index: 1;
}
.candidate-right {
text-align: right;
}
}
}
`;
export default React.memo(Nominate);

View File

@ -0,0 +1,44 @@
// Copyright 2017-2025 @polkadot/app-staking-async authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { BN } from '@polkadot/util';
import React from 'react';
import { InputAddress, InputNumber, Modal } from '@polkadot/react-components';
import { useTranslation } from '../../translate.js';
interface Props {
className?: string;
controllerId?: string | null;
poolId?: BN;
}
function PoolInfo ({ className = '', controllerId, poolId }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
if (!poolId || !controllerId) {
return null;
}
return (
<Modal.Columns
className={className}
hint={t('The pool and pool member that is to be affected. The transaction will be sent from the associated pool member account.')}
>
<InputNumber
defaultValue={poolId}
isDisabled
label={t('pool id')}
/>
<InputAddress
defaultValue={controllerId}
isDisabled
label={t('member account')}
/>
</Modal.Columns>
);
}
export default React.memo(PoolInfo);

View File

@ -0,0 +1,50 @@
// Copyright 2017-2025 @polkadot/app-staking-async authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React from 'react';
import { InputAddress, Modal } from '@polkadot/react-components';
import { useTranslation } from '../../translate.js';
interface Props {
className?: string;
controllerId?: string | null;
stashId?: string | null;
}
function SenderInfo ({ className = '', controllerId, stashId }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
if (!stashId || !controllerId) {
return null;
}
const showController = stashId !== controllerId;
return (
<Modal.Columns
className={className}
hint={
showController
? t('The stash that is to be affected. The transaction will be sent from the associated controller account.')
: t('The stash that is to be affected.')
}
>
<InputAddress
defaultValue={stashId}
isDisabled
label={t('stash account')}
/>
{showController && (
<InputAddress
defaultValue={controllerId}
isDisabled
label={t('controller account')}
/>
)}
</Modal.Columns>
);
}
export default React.memo(SenderInfo);

View File

@ -0,0 +1,68 @@
// Copyright 2017-2025 @polkadot/app-staking-async authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { SessionInfo } from './types.js';
import React, { useEffect, useState } from 'react';
import { Input, MarkWarning, Modal } from '@polkadot/react-components';
import { useApi, useStakingAsyncApis } from '@polkadot/react-hooks';
import { isHex } from '@polkadot/util';
import { useTranslation } from '../../translate.js';
import SenderInfo from './SenderInfo.js';
interface Props {
className?: string;
controllerId: string;
onChange: (info: SessionInfo) => void;
stashId: string;
withFocus?: boolean;
withSenders?: boolean;
}
const EMPTY_PROOF = new Uint8Array();
function SessionKey ({ className = '', controllerId, onChange, stashId, withFocus, withSenders }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api } = useApi();
const { isStakingAsync, rcApi } = useStakingAsyncApis();
const [keys, setKeys] = useState<string | null>(null);
useEffect((): void => {
try {
onChange({
sessionTx: isHex(keys)
? (isStakingAsync ? rcApi : api)?.tx.session.setKeys(keys, EMPTY_PROOF)
: null
});
} catch {
onChange({ sessionTx: null });
}
}, [api, isStakingAsync, keys, onChange, rcApi]);
return (
<div className={className}>
<Modal.Columns>
<MarkWarning content={t('This operation will be performed on the relay chain.')} />
</Modal.Columns>
{withSenders && (
<SenderInfo
controllerId={controllerId}
stashId={stashId}
/>
)}
<Modal.Columns hint={t('The hex output from author_rotateKeys, as executed on the validator node. The keys will show as pending until applied at the start of a new session.')}>
<Input
autoFocus={withFocus}
isError={!keys}
label={t('Keys from rotateKeys')}
onChange={setKeys}
placeholder='0x...'
/>
</Modal.Columns>
</div>
);
}
export default React.memo(SessionKey);

View File

@ -0,0 +1,104 @@
// Copyright 2017-2025 @polkadot/app-staking-async authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ValidateInfo } from './types.js';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Dropdown, InputNumber, MarkError, Modal } from '@polkadot/react-components';
import { useApi } from '@polkadot/react-hooks';
import { BN, BN_HUNDRED as MAX_COMM, BN_ONE, bnMax, isFunction } from '@polkadot/util';
import { useTranslation } from '../../translate.js';
import SenderInfo from './SenderInfo.js';
interface Props {
className?: string;
controllerId: string;
minCommission?: BN;
onChange: (info: ValidateInfo) => void;
stashId: string;
withFocus?: boolean;
withSenders?: boolean;
}
const COMM_MUL = new BN(1e7);
function Validate ({ className = '', controllerId, minCommission, onChange, stashId, withFocus, withSenders }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api } = useApi();
const [commission, setCommission] = useState(BN_ONE);
const [allowNoms, setAllowNoms] = useState(true);
const defaultComm = useMemo(
() => minCommission
? bnMax(minCommission.div(COMM_MUL), BN_ONE)
: BN_ONE,
[minCommission]
);
const blockedOptions = useRef([
{ text: t('Yes, allow nominations'), value: true },
{ text: t('No, block all nominations'), value: false }
]);
useEffect((): void => {
try {
onChange({
validateTx: api.tx.staking.validate({
blocked: !allowNoms,
commission
})
});
} catch {
onChange({ validateTx: null });
}
}, [api, allowNoms, commission, onChange]);
const _setCommission = useCallback(
(value?: BN) => value && setCommission(
value.isZero()
? BN_ONE // small non-zero set to avoid isEmpty
: value.mul(COMM_MUL)
),
[]
);
const commErr = !!minCommission && commission.lt(minCommission);
return (
<div className={className}>
{withSenders && (
<SenderInfo
controllerId={controllerId}
stashId={stashId}
/>
)}
<Modal.Columns hint={t('The commission is deducted from all rewards before the remainder is split with nominators.')}>
<InputNumber
autoFocus={withFocus}
defaultValue={defaultComm}
isError={commErr}
isZeroable
label={t('reward commission percentage')}
maxValue={MAX_COMM}
onChange={_setCommission}
/>
{commErr && (
<MarkError content={t('The commission is below the on-chain minimum of {{p}}%', { replace: { p: (minCommission.mul(MAX_COMM).div(COMM_MUL).toNumber() / 100).toFixed(2) } })} />
)}
</Modal.Columns>
{isFunction(api.tx.staking.kick) && (
<Modal.Columns hint={t('The validator can block any new nominations. By default it is set to allow all nominations.')}>
<Dropdown
defaultValue={true}
label={t('allows new nominations')}
onChange={setAllowNoms}
options={blockedOptions.current}
/>
</Modal.Columns>
)}
</div>
);
}
export default React.memo(Validate);

View File

@ -0,0 +1,23 @@
// Copyright 2017-2025 @polkadot/app-staking-async authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { SubmittableExtrinsic } from '@polkadot/api/types';
export interface BondInfo {
bondTx?: SubmittableExtrinsic<'promise'> | null;
controllerId?: string | null;
controllerTx?: SubmittableExtrinsic<'promise'> | null;
stashId?: string | null;
}
export interface NominateInfo {
nominateTx?: SubmittableExtrinsic<'promise'> | null;
}
export interface SessionInfo {
sessionTx?: SubmittableExtrinsic<'promise'> | null;
}
export interface ValidateInfo {
validateTx?: SubmittableExtrinsic<'promise'> | null;
}

View File

@ -0,0 +1,25 @@
// Copyright 2017-2025 @polkadot/app-staking-async authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { AccountId, Balance, UnappliedSlashOther } from '@polkadot/types/interfaces';
import type { BN } from '@polkadot/util';
export interface AmountValidateState {
error: string | null;
warning: string | null;
}
interface Unapplied {
others: UnappliedSlashOther[];
own: Balance;
payout: Balance;
reporters: AccountId[];
validator: AccountId;
}
export interface Slash {
era: BN;
slashes: Unapplied[];
}
export type DestinationType = 'Staked' | 'Stash' | 'Controller' | 'Account';

View File

@ -0,0 +1,206 @@
// Copyright 2017-2025 @polkadot/app-staking-async authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ApiPromise } from '@polkadot/api';
import type { QueryableStorageMultiArg } from '@polkadot/api/types';
import type { DeriveEraExposure, DeriveEraValidatorExposurePaged, DeriveSessionIndexes } from '@polkadot/api-derive/types';
import type { Option, u16, u32, Vec } from '@polkadot/types';
import type { EraIndex, Exposure, Nominations, UnappliedSlash } from '@polkadot/types/interfaces';
import { useEffect, useState } from 'react';
import { createNamedHook, useApi, useCall, useIsMountedRef } from '@polkadot/react-hooks';
import { BN } from '@polkadot/util';
/**
* @interface Inactives
* @description Defines the structure for the categorized lists of nominated validators.
* 'nomsAtRisk' replaces the legacy 'nomsChilled' concept for new runtime (for staking async).
*/
export interface Inactives {
nomsActive?: string[];
nomsAtRisk?: string[];
nomsInactive?: string[];
nomsOver?: string[];
nomsWaiting?: string[];
}
/**
* @interface NominationInfo
* @description A robust data structure to safely link a nominee's address to their exposure data for the current era.
* This prevents index mismatches when some nominees are not in the active validator set.
*/
interface NominationInfo {
nomineeId: string;
exposure?: Exposure;
}
interface ExtractStateParams {
api: ApiPromise;
stashId: string;
unappliedSlashes: string[];
nominationsInfo: NominationInfo[];
activeEra: EraIndex| undefined;
submittedIn: EraIndex;
activeValidators: DeriveEraValidatorExposurePaged;
}
/**
* @name extractState
* @description A pure function that takes all fetched chain data and classifies the nominations into different states.
* This is the core classification logic of the hook.
* @param params An object containing all necessary data from the chain.
* @returns An `Inactives` object with categorized nominee lists.
*/
function extractState (params: ExtractStateParams): Inactives {
const { activeEra, activeValidators, api, nominationsInfo, stashId, submittedIn, unappliedSlashes } = params;
// The maximum number of nominators that can be rewarded for a single validator.
const max = api.consts.staking?.maxNominatorRewardedPerValidator as u32 || new BN(512);
const atRiskSet = new Set(unappliedSlashes);
// We positively identify which nominees are currently active (i.e., our stake is backing them)
// and which are inactive. This is more robust than inferring one from the other.
const nomsActive: string[] = [];
const nomsInactive: string[] = [];
for (const { exposure, nomineeId } of nominationsInfo) {
// A nominator is active for a validator if their stashId appears in the validator's `others` list (their list of nominators).
if (exposure?.others.some(({ who }) => who.eq(stashId))) {
nomsActive.push(nomineeId);
} else {
nomsInactive.push(nomineeId);
}
}
// NOTE: The 'nomsAtRisk' category is the modern equivalent of the legacy 'nomsChilled' status.
// A validator is "at risk" if they have a pending (unapplied) slash against them.
const nomsAtRisk = nominationsInfo.map(({ nomineeId }) => nomineeId).filter((nominee) => atRiskSet.has(nominee));
// A validator is "oversubscribed" for this nominator if they are active, but the nominator's stake
// is not high enough to be in the top 'max' nominators for that validator.
const nomsOver = nominationsInfo
.filter(({ nomineeId }) => nomsActive.includes(nomineeId)) // Only active validators can be oversubscribed.
.filter(({ exposure }) => {
const isStashInTop = exposure?.others
.sort((a, b) => (b.value.unwrap()).cmp(a.value.unwrap()))
.slice(0, max.toNumber())
.some(({ who }) => who.eq(stashId));
return !isStashInTop;
})
.map(({ nomineeId }) => nomineeId);
// Handle the special case where nominations were submitted in the current era.
// In this situation, they cannot be active or inactive yet; they are all "waiting" for the next era.
if (submittedIn.eq(activeEra)) {
const allNominees = nominationsInfo.map(({ nomineeId }) => nomineeId);
return {
nomsActive: [],
nomsAtRisk,
nomsInactive: [],
nomsOver: [],
nomsWaiting: allNominees.filter((n) => !atRiskSet.has(n))
};
}
// Refine the base lists into the final categories, ensuring no validator appears in multiple lists.
const finalNomsActive = nomsActive.filter((n) => !atRiskSet.has(n) && !nomsOver.includes(n));
const nomsWaiting = nomsInactive.filter((n) => !activeValidators[n] && !atRiskSet.has(n));
const finalNomsInactive = nomsInactive.filter((n) => !nomsWaiting.includes(n) && !atRiskSet.has(n));
return {
nomsActive: finalNomsActive,
nomsAtRisk,
nomsInactive: finalNomsInactive,
nomsOver,
nomsWaiting
};
}
function useInactivesImpl (stashId: string, nominees?: string[]): Inactives {
const { api } = useApi();
const mountedRef = useIsMountedRef();
const [state, setState] = useState<Inactives>({});
// The era in which the user last submitted their nominations.
const [submittedIn, setSubmittedIn] = useState<EraIndex>();
// A list of the user's nominees who have pending slashes.
const [unappliedSlashes, setUnappliedSlashes] = useState<string[]>([]);
const indexes = useCall<DeriveSessionIndexes>(api.derive.session?.indexes);
// Fetches the current version of the staking pallet.
const version = useCall<u16>(api.query.staking.palletVersion)?.toNumber();
const eraExposure = useCall<DeriveEraExposure>(indexes && api.derive.staking.eraExposure, [indexes?.activeEra]);
/**
* @description Effect for fetching primary chain data.
* It retrieves the nominator's submission era and all unapplied slashes for the active era.
*/
useEffect((): () => void => {
let unsub: (() => void) | undefined;
if (mountedRef.current && nominees?.length && stashId && indexes) {
api.queryMulti<[Option<Nominations>, Option<Vec<UnappliedSlash>>]>(
[
[api.query.staking.nominators, stashId] as QueryableStorageMultiArg<'promise'>,
[api.query.staking.unappliedSlashes, [indexes.activeEra, null]]
],
([optNominators, eraSlashes]) => {
if (optNominators.isSome) {
setSubmittedIn(optNominators.unwrap().submittedIn);
}
const atRiskSet = new Set<string>();
if (eraSlashes.isSome) {
for (const slash of eraSlashes.unwrap()) {
atRiskSet.add(slash.validator.toString());
}
}
const atRiskNominees = nominees.filter((nominee) => atRiskSet.has(nominee));
setUnappliedSlashes(atRiskNominees);
}
).then((_unsub) => {
unsub = _unsub;
}).catch(console.error);
}
return (): void => {
unsub && unsub();
};
}, [api, mountedRef, nominees, stashId, indexes]);
useEffect(() => {
if (eraExposure && nominees?.length && submittedIn && version && indexes) {
const nominationsInfo: NominationInfo[] = nominees.map((nomineeId) => ({
exposure: eraExposure.validators[nomineeId],
nomineeId
}));
if (mountedRef.current) {
setState(
extractState({
activeEra: indexes.activeEra,
activeValidators: eraExposure.validators,
api,
nominationsInfo,
stashId,
submittedIn,
unappliedSlashes
})
);
}
}
}, [api, stashId, nominees, indexes, submittedIn, eraExposure, version, mountedRef, unappliedSlashes]);
return state;
}
export default createNamedHook('useInactives', useInactivesImpl);

View File

@ -0,0 +1,22 @@
// Copyright 2017-2025 @polkadot/app-staking-async authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { Option } from '@polkadot/types';
import type { PalletStakingSlashingSlashingSpans } from '@polkadot/types/lookup';
import { createNamedHook, useApi, useCall } from '@polkadot/react-hooks';
const OPT_SPAN = {
transform: (optSpans: Option<PalletStakingSlashingSlashingSpans>): number =>
optSpans.isNone
? 0
: optSpans.unwrap().prior.length + 1
};
function useSlashingSpansImpl (stashId: string): number {
const { api } = useApi();
return useCall<number>(api.query.staking.slashingSpans, [stashId], OPT_SPAN) || 0;
}
export default createNamedHook('useSlashingSpans', useSlashingSpansImpl);

View File

@ -0,0 +1,24 @@
// Copyright 2017-2025 @polkadot/app-staking-async authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { DeriveSessionInfo } from '@polkadot/api-derive/types';
import type { BN } from '@polkadot/util';
import { useMemo } from 'react';
import { createNamedHook, useApi, useCall } from '@polkadot/react-hooks';
import { BN_ONE } from '@polkadot/util';
function useUnbondDurationImpl (): BN | undefined {
const { api } = useApi();
const sessionInfo = useCall<DeriveSessionInfo>(api.derive.session.info);
return useMemo(
() => (sessionInfo && sessionInfo.sessionLength.gt(BN_ONE))
? sessionInfo.eraLength.mul(api.consts.staking.bondingDuration)
: undefined,
[api, sessionInfo]
);
}
export default createNamedHook('useUnbondDuration', useUnbondDurationImpl);

View File

@ -10,7 +10,6 @@ import type { BN } from '@polkadot/util';
import React, { useCallback, useMemo, useState } from 'react'; import React, { useCallback, useMemo, useState } from 'react';
import { Route, Routes } from 'react-router'; import { Route, Routes } from 'react-router';
import Actions from '@polkadot/app-staking/Actions';
import Bags from '@polkadot/app-staking/Bags'; import Bags from '@polkadot/app-staking/Bags';
import Payouts from '@polkadot/app-staking/Payouts'; import Payouts from '@polkadot/app-staking/Payouts';
import Query from '@polkadot/app-staking/Query'; import Query from '@polkadot/app-staking/Query';
@ -24,6 +23,7 @@ import { Tabs } from '@polkadot/react-components';
import { useAccounts, useApi, useAvailableSlashes, useCall, useCallMulti, useFavorites, useOwnStashInfos } from '@polkadot/react-hooks'; import { useAccounts, useApi, useAvailableSlashes, useCall, useCallMulti, useFavorites, useOwnStashInfos } from '@polkadot/react-hooks';
import { isFunction } from '@polkadot/util'; import { isFunction } from '@polkadot/util';
import Actions from '../Actions/index.js';
import CommandCenter from '../CommandCenter/index.js'; import CommandCenter from '../CommandCenter/index.js';
import { STORE_FAVS_BASE } from '../constants.js'; import { STORE_FAVS_BASE } from '../constants.js';
import { useTranslation } from '../translate.js'; import { useTranslation } from '../translate.js';

View File

@ -74,10 +74,10 @@
"@polkadot/app-staking/Bags": ["page-staking/src/Bags/index.tsx"], "@polkadot/app-staking/Bags": ["page-staking/src/Bags/index.tsx"],
"@polkadot/app-staking/Filtering": ["page-staking/src/Filtering.tsx"], "@polkadot/app-staking/Filtering": ["page-staking/src/Filtering.tsx"],
"@polkadot/app-staking/MaxBadge": ["page-staking/src/MaxBadge.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/Targets": ["page-staking/src/Targets/index.tsx"],
"@polkadot/app-staking/Slashes": ["page-staking/src/Slashes/index.tsx"], "@polkadot/app-staking/Slashes": ["page-staking/src/Slashes/index.tsx"],
"@polkadot/app-staking/Query": ["page-staking/src/Query/index.tsx"], "@polkadot/app-staking/Query": ["page-staking/src/Query/index.tsx"],
"@polkadot/app-staking/ElectionBanner": ["page-staking/src/ElectionBanner.tsx"],
"@polkadot/app-staking/*": ["page-staking/src/*.ts"], "@polkadot/app-staking/*": ["page-staking/src/*.ts"],
"@polkadot/app-staking-async": ["page-staking-async/src/index.tsx"], "@polkadot/app-staking-async": ["page-staking-async/src/index.tsx"],
"@polkadot/app-staking2": ["page-staking2/src/index.tsx"], "@polkadot/app-staking2": ["page-staking2/src/index.tsx"],