mirror of
https://github.com/pezkuwichain/pezkuwi-apps.git
synced 2026-05-06 06:47:56 +00:00
feat: initial Pezkuwi Apps rebrand from polkadot-apps
Rebranded terminology: - Polkadot → Pezkuwi - Kusama → Dicle - Westend → Zagros - Rococo → PezkuwiChain - Substrate → Bizinikiwi - parachain → teyrchain Custom logos with Kurdistan brand colors (#e6007a → #86e62a): - bizinikiwi-hexagon.svg - sora-bizinikiwi.svg - hezscanner.svg - heztreasury.svg - pezkuwiscan.svg - pezkuwistats.svg - pezkuwiassembly.svg - pezkuwiholic.svg
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { ApiPromise } from '@pezkuwi/api';
|
||||
import type { DeriveBalancesAll, DeriveStakingAccount } from '@pezkuwi/api-derive/types';
|
||||
import type { AmountValidateState } from '../types.js';
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
|
||||
import { InputAddress, InputBalance, Modal, TxButton } from '@pezkuwi/react-components';
|
||||
import { useApi, useCall } from '@pezkuwi/react-hooks';
|
||||
import { BalanceFree } from '@pezkuwi/react-query';
|
||||
import { BN, BN_ZERO } from '@pezkuwi/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);
|
||||
@@ -0,0 +1,137 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { KeypairType } from '@pezkuwi/util-crypto/types';
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { Button, Dropdown, Input, MarkWarning, Modal } from '@pezkuwi/react-components';
|
||||
import { useQueue } from '@pezkuwi/react-hooks';
|
||||
import { keyring } from '@pezkuwi/ui-keyring';
|
||||
import { assert, u8aToHex } from '@pezkuwi/util';
|
||||
import { keyExtractSuri, mnemonicValidate } from '@pezkuwi/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('Teyrchains'), 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);
|
||||
@@ -0,0 +1,111 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { DeriveBalancesAll } from '@pezkuwi/api-derive/types';
|
||||
import type { AmountValidateState } from '../types.js';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { MarkError, MarkWarning } from '@pezkuwi/react-components';
|
||||
import { useApi, useCall } from '@pezkuwi/react-hooks';
|
||||
import { BN, BN_TEN, BN_THOUSAND, BN_ZERO, formatBalance } from '@pezkuwi/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);
|
||||
@@ -0,0 +1,86 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { DeriveBalancesAll } from '@pezkuwi/api-derive/types';
|
||||
import type { Option } from '@pezkuwi/types';
|
||||
import type { AccountId } from '@pezkuwi/types/interfaces';
|
||||
import type { PalletStakingStakingLedger } from '@pezkuwi/types/lookup';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { MarkError, MarkWarning } from '@pezkuwi/react-components';
|
||||
import { useApi, useCall } from '@pezkuwi/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);
|
||||
@@ -0,0 +1,43 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { I18nProps } from '@pezkuwi/react-components/types';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { MarkWarning } from '@pezkuwi/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);
|
||||
@@ -0,0 +1,46 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { MarkWarning } from '@pezkuwi/react-components';
|
||||
import { BN_TEN } from '@pezkuwi/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);
|
||||
@@ -0,0 +1,93 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { SubmittableExtrinsic } from '@pezkuwi/api/types';
|
||||
import type { DeriveStakingQuery } from '@pezkuwi/api-derive/types';
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { InputAddressMulti, Modal, Spinner, TxButton } from '@pezkuwi/react-components';
|
||||
import { useApi, useCall } from '@pezkuwi/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);
|
||||
@@ -0,0 +1,129 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { DeriveEraExposure, DeriveSessionIndexes } from '@pezkuwi/api-derive/types';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { AddressMini, ExpanderScroll, MarkWarning, Spinner } from '@pezkuwi/react-components';
|
||||
import { useApi, useCall } from '@pezkuwi/react-hooks';
|
||||
import { isFunction, isToBn } from '@pezkuwi/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();
|
||||
const sessionInfo = useCall<DeriveSessionIndexes>(api.query.staking && api.derive.session?.indexes);
|
||||
const eraExposure = useCall<DeriveEraExposure>(isFunction(api.query.staking.erasStakers) && api.derive.staking.eraExposure, [sessionInfo?.activeEra]);
|
||||
const { nomsActive, nomsChilled, nomsInactive, nomsOver, nomsWaiting } = useInactives(stashId, nominating, eraExposure);
|
||||
|
||||
const [renActive, renChilled, renInactive, renOver, renWaiting] = useMemo(
|
||||
() => [renderNominators(stashId, nomsActive, eraExposure), renderNominators(stashId, nomsChilled), renderNominators(stashId, nomsInactive), renderNominators(stashId, nomsOver), renderNominators(stashId, nomsWaiting)],
|
||||
[eraExposure, nomsActive, nomsChilled, nomsInactive, nomsOver, nomsWaiting, stashId]
|
||||
);
|
||||
|
||||
if (!nomsInactive && !nomsWaiting) {
|
||||
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] } })}
|
||||
/>
|
||||
)}
|
||||
{renChilled && (
|
||||
<ExpanderScroll
|
||||
renderChildren={renChilled[1]}
|
||||
summary={t('Renomination required ({{count}})', { replace: { count: renChilled[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);
|
||||
@@ -0,0 +1,71 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
import type { SortedTargets } from '../../types.js';
|
||||
import type { NominateInfo } from '../partials/types.js';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { Modal, styled, TxButton } from '@pezkuwi/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);
|
||||
@@ -0,0 +1,73 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { DeriveStakingAccount } from '@pezkuwi/api-derive/types';
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
|
||||
import { InputBalance, Modal, TxButton } from '@pezkuwi/react-components';
|
||||
import { useApi } from '@pezkuwi/react-hooks';
|
||||
import { BN, BN_ZERO } from '@pezkuwi/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);
|
||||
@@ -0,0 +1,92 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { InputAddress, Modal, TxButton } from '@pezkuwi/react-components';
|
||||
import { useApi } from '@pezkuwi/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);
|
||||
@@ -0,0 +1,90 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { DeriveBalancesAll } from '@pezkuwi/api-derive/types';
|
||||
import type { PalletStakingRewardDestination } from '@pezkuwi/types/lookup';
|
||||
import type { DestinationType } from '../types.js';
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
|
||||
import { Dropdown, InputAddress, MarkError, Modal, TxButton } from '@pezkuwi/react-components';
|
||||
import { useApi, useCall } from '@pezkuwi/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);
|
||||
@@ -0,0 +1,52 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { SessionInfo } from '../partials/types.js';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { Modal, TxButton } from '@pezkuwi/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);
|
||||
@@ -0,0 +1,92 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { PalletStakingStakingLedger } from '@pezkuwi/types/lookup';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { InputBalance, Modal, Static, styled, TxButton } from '@pezkuwi/react-components';
|
||||
import { useApi } from '@pezkuwi/react-hooks';
|
||||
import { BlockToTime, FormatBalance } from '@pezkuwi/react-query';
|
||||
import { BN_ZERO } from '@pezkuwi/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);
|
||||
@@ -0,0 +1,55 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
import type { ValidateInfo } from '../partials/types.js';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { Modal, TxButton } from '@pezkuwi/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);
|
||||
@@ -0,0 +1,32 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { DeriveStakingAccount } from '@pezkuwi/api-derive/types';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { MarkWarning } from '@pezkuwi/react-components';
|
||||
import { formatBalance } from '@pezkuwi/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);
|
||||
@@ -0,0 +1,377 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { ApiPromise } from '@pezkuwi/api';
|
||||
import type { DeriveBalancesAll, DeriveStakingAccount } from '@pezkuwi/api-derive/types';
|
||||
import type { StakerState } from '@pezkuwi/react-hooks/types';
|
||||
import type { PalletStakingUnappliedSlash } from '@pezkuwi/types/lookup';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
import type { SortedTargets } from '../../types.js';
|
||||
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 '@pezkuwi/react-components';
|
||||
import { useApi, useCall, useQueue, useToggle } from '@pezkuwi/react-hooks';
|
||||
import { formatNumber, isFunction } from '@pezkuwi/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);
|
||||
@@ -0,0 +1,59 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { StakerState } from '@pezkuwi/react-hooks/types';
|
||||
import type { PalletStakingUnappliedSlash } from '@pezkuwi/types/lookup';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
import type { SortedTargets } from '../types.js';
|
||||
|
||||
import React, { useRef } from 'react';
|
||||
|
||||
import { Table } from '@pezkuwi/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);
|
||||
@@ -0,0 +1,135 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { SortedTargets } from '../types.js';
|
||||
import type { BondInfo, NominateInfo } from './partials/types.js';
|
||||
|
||||
import React, { useCallback, useState } from 'react';
|
||||
|
||||
import { BatchWarning, Button, Modal, TxButton } from '@pezkuwi/react-components';
|
||||
import { useApi, useToggle } from '@pezkuwi/react-hooks';
|
||||
import { isFunction } from '@pezkuwi/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);
|
||||
@@ -0,0 +1,60 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking 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 '@pezkuwi/react-components';
|
||||
import { useToggle } from '@pezkuwi/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);
|
||||
@@ -0,0 +1,143 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
import type { SortedTargets } from '../types.js';
|
||||
import type { BondInfo, SessionInfo, ValidateInfo } from './partials/types.js';
|
||||
|
||||
import React, { useCallback, useState } from 'react';
|
||||
|
||||
import { BatchWarning, Button, Modal, TxButton } from '@pezkuwi/react-components';
|
||||
import { useApi, useToggle } from '@pezkuwi/react-hooks';
|
||||
import { isFunction } from '@pezkuwi/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);
|
||||
@@ -0,0 +1,201 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { DeriveSessionProgress, DeriveUnlocking } from '@pezkuwi/api-derive/types';
|
||||
import type { PoolInfo } from '@pezkuwi/app-staking2/Pools/types';
|
||||
import type { PalletNominationPoolsPoolMember, PalletNominationPoolsPoolRoles } from '@pezkuwi/types/lookup';
|
||||
import type { SortedTargets } from '../../types.js';
|
||||
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
|
||||
import { AddressSmall, Badge, Menu, Popup, StakingRedeemable, StakingUnbonding } from '@pezkuwi/react-components';
|
||||
import { useApi, useQueue, useToggle } from '@pezkuwi/react-hooks';
|
||||
import { FormatBalance } from '@pezkuwi/react-query';
|
||||
import { BN, formatNumber } from '@pezkuwi/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);
|
||||
@@ -0,0 +1,92 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
|
||||
import React, { useRef, useState } from 'react';
|
||||
|
||||
import useAmountError from '@pezkuwi/app-staking2/Pools/useAmountError';
|
||||
import { Dropdown, InputBalance, Modal, TxButton } from '@pezkuwi/react-components';
|
||||
import { useApi } from '@pezkuwi/react-hooks';
|
||||
import { BalanceFree } from '@pezkuwi/react-query';
|
||||
import { BN_ZERO } from '@pezkuwi/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);
|
||||
@@ -0,0 +1,84 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { InputBalance, Modal, Static, TxButton } from '@pezkuwi/react-components';
|
||||
import { useApi } from '@pezkuwi/react-hooks';
|
||||
import { BlockToTime, FormatBalance } from '@pezkuwi/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);
|
||||
@@ -0,0 +1,49 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { DeriveSessionProgress } from '@pezkuwi/api-derive/types';
|
||||
import type { u32 } from '@pezkuwi/types';
|
||||
import type { PalletNominationPoolsPoolMember } from '@pezkuwi/types/lookup';
|
||||
import type { SortedTargets } from '../../types.js';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import usePoolInfo from '@pezkuwi/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);
|
||||
@@ -0,0 +1,10 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { PalletNominationPoolsPoolMember } from '@pezkuwi/types/lookup';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
|
||||
export interface AccountInfo {
|
||||
claimable: BN;
|
||||
member: PalletNominationPoolsPoolMember;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { Option } from '@pezkuwi/types';
|
||||
import type { PalletNominationPoolsPoolMember } from '@pezkuwi/types/lookup';
|
||||
import type { AccountInfo } from './types.js';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { createNamedHook, useApi, useCall, useIsMountedRef } from '@pezkuwi/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);
|
||||
@@ -0,0 +1,61 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { DeriveSessionProgress } from '@pezkuwi/api-derive/types';
|
||||
import type { OwnPool } from '@pezkuwi/app-staking2/Pools/types';
|
||||
import type { PalletStakingUnappliedSlash } from '@pezkuwi/types/lookup';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
import type { SortedTargets } from '../types.js';
|
||||
|
||||
import React, { useRef } from 'react';
|
||||
|
||||
import { Table } from '@pezkuwi/react-components';
|
||||
import { useApi, useCall } from '@pezkuwi/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);
|
||||
@@ -0,0 +1,19 @@
|
||||
// Copyright 2017-2025 @pezkuwi/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' });
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import '@pezkuwi/api-augment';
|
||||
|
||||
import type { OwnPool } from '@pezkuwi/app-staking2/Pools/types';
|
||||
import type { StakerState } from '@pezkuwi/react-hooks/types';
|
||||
import type { SortedTargets } from '../types.js';
|
||||
|
||||
import React, { useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { Button, ToggleGroup } from '@pezkuwi/react-components';
|
||||
import { useApi, useAvailableSlashes } from '@pezkuwi/react-hooks';
|
||||
import { FormatBalance } from '@pezkuwi/react-query';
|
||||
import { BN, BN_ZERO } from '@pezkuwi/util';
|
||||
|
||||
import ElectionBanner from '../ElectionBanner.js';
|
||||
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);
|
||||
@@ -0,0 +1,218 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { DeriveBalancesAll } from '@pezkuwi/api-derive/types';
|
||||
import type { BN } from '@pezkuwi/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 '@pezkuwi/react-components';
|
||||
import { useApi, useCall } from '@pezkuwi/react-hooks';
|
||||
import { BalanceFree, BlockToTime } from '@pezkuwi/react-query';
|
||||
import { BN_ZERO } from '@pezkuwi/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);
|
||||
@@ -0,0 +1,175 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { u32 } from '@pezkuwi/types';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
import type { SortedTargets } from '../../types.js';
|
||||
import type { NominateInfo } from './types.js';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { InputAddressMulti, MarkWarning, Modal, styled } from '@pezkuwi/react-components';
|
||||
import { useApi, useFavorites } from '@pezkuwi/react-hooks';
|
||||
|
||||
import { MAX_NOMINATIONS, STORE_FAVS_BASE } from '../../constants.js';
|
||||
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);
|
||||
@@ -0,0 +1,44 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { InputAddress, InputNumber, Modal } from '@pezkuwi/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);
|
||||
@@ -0,0 +1,50 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { InputAddress, Modal } from '@pezkuwi/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);
|
||||
@@ -0,0 +1,68 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking 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 '@pezkuwi/react-components';
|
||||
import { useApi, useStakingAsyncApis } from '@pezkuwi/react-hooks';
|
||||
import { isHex } from '@pezkuwi/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);
|
||||
@@ -0,0 +1,104 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking 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 '@pezkuwi/react-components';
|
||||
import { useApi } from '@pezkuwi/react-hooks';
|
||||
import { BN, BN_HUNDRED as MAX_COMM, BN_ONE, bnMax, isFunction } from '@pezkuwi/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);
|
||||
@@ -0,0 +1,23 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { SubmittableExtrinsic } from '@pezkuwi/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;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { AccountId, Balance, UnappliedSlashOther } from '@pezkuwi/types/interfaces';
|
||||
import type { BN } from '@pezkuwi/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';
|
||||
@@ -0,0 +1,255 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { ApiPromise } from '@pezkuwi/api';
|
||||
import type { QueryableStorageMultiArg } from '@pezkuwi/api/types';
|
||||
import type { DeriveEraExposure, DeriveEraNominatorExposure, DeriveEraValidatorExposurePaged, DeriveSessionIndexes } from '@pezkuwi/api-derive/types';
|
||||
import type { Option, u16, u32 } from '@pezkuwi/types';
|
||||
import type { EraIndex, Exposure, Nominations, SlashingSpans } from '@pezkuwi/types/interfaces';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { createNamedHook, useApi, useCall, useIsMountedRef } from '@pezkuwi/react-hooks';
|
||||
import { BN, BN_ZERO } from '@pezkuwi/util';
|
||||
|
||||
interface Inactives {
|
||||
nomsActive?: string[];
|
||||
nomsChilled?: string[];
|
||||
nomsInactive?: string[];
|
||||
nomsOver?: string[];
|
||||
nomsWaiting?: string[];
|
||||
}
|
||||
|
||||
interface ExtractStateParams {
|
||||
api: ApiPromise;
|
||||
stashId: string;
|
||||
slashes: Option<SlashingSpans>[];
|
||||
nominees: string[];
|
||||
activeEra: EraIndex| undefined;
|
||||
submittedIn: EraIndex;
|
||||
exposures: Exposure[];
|
||||
version: number | undefined;
|
||||
allNominators?: DeriveEraNominatorExposure;
|
||||
activeValidators?: DeriveEraValidatorExposurePaged;
|
||||
}
|
||||
|
||||
function extractState (params: ExtractStateParams): Inactives {
|
||||
const { activeEra, activeValidators, allNominators, api, exposures, nominees, slashes, stashId, submittedIn, version } = params;
|
||||
|
||||
if (((version && version >= 14) && !allNominators && !activeValidators) || !activeEra || !version) {
|
||||
return { nomsActive: [], nomsChilled: [], nomsInactive: [], nomsOver: [], nomsWaiting: [] };
|
||||
}
|
||||
|
||||
// / For older non-paged exposure, a reward payout was restricted to the top
|
||||
// / `MaxExposurePageSize` nominators. This is to limit the i/o cost for the
|
||||
// / nominator payout.
|
||||
const max = api.consts.staking?.maxNominatorRewardedPerValidator as u32 || new BN(512);
|
||||
|
||||
/**
|
||||
* NOTE With the introduction of the SlashReported event, nominators are not auto-chilled on validator slash
|
||||
*
|
||||
* Chilled validators / nominations
|
||||
* - Chilling is the act of stepping back from any nominating or validating
|
||||
* To be chilled, we have a slash era and it is later than the submission era
|
||||
* (if submitted in the same, the nomination will only take effect after the era)
|
||||
*/
|
||||
const nomsChilled = !api.events.staking.SlashReported
|
||||
? nominees.filter((_, index) => slashes[index].isNone ? false : slashes[index].unwrap().lastNonzeroSlash.gt(submittedIn))
|
||||
: [];
|
||||
|
||||
/**
|
||||
* Oversubscribed validators / nominations
|
||||
* - validators that have been nominated by more than max accounts
|
||||
*/
|
||||
const nomsOver = exposures
|
||||
.map(({ others }) =>
|
||||
others.sort((a, b) => (b.value?.unwrap() || BN_ZERO).cmp(a.value?.unwrap() || BN_ZERO))
|
||||
)
|
||||
.map((others, index) =>
|
||||
!max || max.gtn(others.map(({ who }) => who.toString()).indexOf(stashId))
|
||||
? null
|
||||
: nominees[index]
|
||||
)
|
||||
.filter((nominee): nominee is string => !!nominee && !nomsChilled.includes(nominee));
|
||||
|
||||
// first a blanket find of nominations not in the active set
|
||||
const inactiveValidators = exposures.map((exposure, index) => exposure.others.some(({ who }) => who.eq(stashId)) ? null : nominees[index])
|
||||
.filter((nominee): nominee is string => !!nominee);
|
||||
|
||||
/**
|
||||
* Waiting validator / nomination
|
||||
* - the validator is not active, not producing blocks in this era.
|
||||
*/
|
||||
let nomsWaiting: string[] = [];
|
||||
|
||||
/**
|
||||
* Active validator / nomination
|
||||
* - the validator your funds are bonded to,
|
||||
* - they are earning rewards in the current era (they were selected to be part of the current validators set in the current era)
|
||||
*/
|
||||
let nomsActive: string[] = [];
|
||||
|
||||
/**
|
||||
* Inactive validator / nomination
|
||||
* - A set of nominations will be inactive when none of those nominees are participating in the current validator set
|
||||
* (the set of validators currently elected to validate the network).
|
||||
*/
|
||||
let nomsInactive: string[] = [];
|
||||
|
||||
/**
|
||||
* When you first nominate validators, all of them will be "waiting" in the current era.
|
||||
* The nominations will take effect in the next era. One will only see active validators (and begin earning staking rewards) after two eras,
|
||||
* so on the third day earliest.
|
||||
*/
|
||||
if (submittedIn.eq(activeEra)) {
|
||||
return { nomsActive: [], nomsChilled, nomsInactive: [], nomsOver, nomsWaiting: nominees };
|
||||
}
|
||||
|
||||
if (version >= 14) {
|
||||
nomsWaiting = inactiveValidators.filter((inactive) => !activeValidators?.[inactive] && !nomsChilled.includes(inactive) && !nomsOver.includes(inactive));
|
||||
nomsActive = allNominators?.[stashId] ? [allNominators?.[stashId][0].validatorId] : [];
|
||||
nomsInactive = inactiveValidators.filter((nominee) => !nomsWaiting.includes(nominee) && !nomsChilled.includes(nominee) && !nomsOver.includes(nominee) && !nomsActive.includes(nominee));
|
||||
|
||||
return { nomsActive, nomsChilled, nomsInactive, nomsOver, nomsWaiting };
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeping this for backwards compatibility *
|
||||
* For staking pallet lower than version 14
|
||||
*/
|
||||
nomsWaiting = exposures.map((exposure, index) =>
|
||||
exposure.total?.unwrap().isZero() || (
|
||||
inactiveValidators.includes(nominees[index]) &&
|
||||
// it could be activeEra + 1 (currentEra for last session)
|
||||
submittedIn.gte(activeEra)
|
||||
)
|
||||
? nominees[index]
|
||||
: null
|
||||
)
|
||||
.filter((nominee): nominee is string => !!nominee)
|
||||
.filter((nominee) => !nomsChilled.includes(nominee) && !nomsOver.includes(nominee));
|
||||
|
||||
nomsActive = nominees.filter((nominee) => !nomsInactive.includes(nominee) && !nomsChilled.includes(nominee) && !nomsOver.includes(nominee));
|
||||
// inactive also contains waiting, remove those
|
||||
nomsInactive = inactiveValidators.filter((nominee) => !nomsWaiting.includes(nominee) && !nomsChilled.includes(nominee) && !nomsOver.includes(nominee) && !nomsActive.includes(nominee));
|
||||
|
||||
return { nomsActive, nomsChilled, nomsInactive, nomsOver, nomsWaiting };
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param stashId - address of the account that is performing staking
|
||||
* @param nominees - the validators that the given account has nominated
|
||||
* @returns
|
||||
*/
|
||||
function useInactivesImpl (stashId: string, nominees?: string[], eraExposure?: DeriveEraExposure): Inactives {
|
||||
const { api } = useApi();
|
||||
const mountedRef = useIsMountedRef();
|
||||
const [state, setState] = useState<Inactives>({});
|
||||
const [exposures, setExposures] = useState<Exposure[]>([]);
|
||||
const [slashes, setSlashes] = useState<Option<SlashingSpans>[]>([]);
|
||||
const [submittedIn, setSubmittedIn] = useState<EraIndex>();
|
||||
const indexes = useCall<DeriveSessionIndexes>(api.derive.session.indexes);
|
||||
const version = useCall<u16>(api.query.staking.palletVersion)?.toNumber();
|
||||
|
||||
/**
|
||||
* pallet updates v14 introduces ErasStakersPaged which is used by the derive `staking.eraExposure`
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (version && version >= 14 && !eraExposure) {
|
||||
return;
|
||||
}
|
||||
|
||||
const exposuresData = nominees?.map((id) => eraExposure?.validators?.[id]).filter((val) => val) as Exposure[];
|
||||
|
||||
mountedRef.current && exposuresData?.length && nominees?.length && !!submittedIn && setState(
|
||||
extractState({
|
||||
activeEra: indexes?.activeEra,
|
||||
activeValidators: eraExposure?.validators,
|
||||
allNominators: eraExposure?.nominators,
|
||||
api,
|
||||
exposures: exposuresData,
|
||||
nominees,
|
||||
slashes,
|
||||
stashId,
|
||||
submittedIn,
|
||||
version
|
||||
})
|
||||
);
|
||||
}, [api, stashId, slashes, nominees, indexes, submittedIn, eraExposure, version, mountedRef]);
|
||||
|
||||
/**
|
||||
* These calls are used by both staking pallet before v14 and after
|
||||
*/
|
||||
useEffect((): () => void => {
|
||||
let unsub: (() => void) | undefined;
|
||||
|
||||
if (mountedRef.current && nominees?.length && indexes) {
|
||||
api.queryMulti(
|
||||
[[api.query.staking.nominators, stashId] as QueryableStorageMultiArg<'promise'>]
|
||||
.concat(
|
||||
nominees.map((id) => [api.query.staking.slashingSpans, id]))
|
||||
, ([optNominators, ...slashingSpans]: [Option<Nominations>, ...(Option<SlashingSpans>)[]]): void => {
|
||||
setSubmittedIn(optNominators.unwrapOrDefault().submittedIn);
|
||||
setSlashes(slashingSpans);
|
||||
})
|
||||
.then((_unsub): void => {
|
||||
unsub = _unsub;
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
return (): void => {
|
||||
unsub && unsub();
|
||||
};
|
||||
}, [api, indexes, mountedRef, nominees, stashId]);
|
||||
|
||||
/**
|
||||
* Deprecated calls for exposure
|
||||
* - erasStakers - deprecated in v14
|
||||
* - stakers - deprecated earlier
|
||||
*/
|
||||
useEffect((): () => void => {
|
||||
let unsub: (() => void) | undefined;
|
||||
|
||||
if (version && version < 14 && mountedRef.current && nominees?.length && indexes) {
|
||||
api.queryMulti(
|
||||
api.query.staking.erasStakers
|
||||
? nominees.map((id) => [api.query.staking.erasStakers, [indexes?.activeEra, id]])
|
||||
: nominees.map((id) => [api.query.staking.stakers, id])
|
||||
, (exposures: Exposure[]): void => setExposures(exposures))
|
||||
.then((_unsub): void => {
|
||||
unsub = _unsub;
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
return (): void => {
|
||||
unsub && unsub();
|
||||
};
|
||||
}, [api, indexes, mountedRef, nominees, stashId, version]);
|
||||
|
||||
/**
|
||||
* Extracting state for deprecated calls
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (exposures.length && slashes.length && nominees?.length && !!submittedIn) {
|
||||
mountedRef.current && setState(
|
||||
extractState({
|
||||
activeEra: indexes?.activeEra,
|
||||
api,
|
||||
exposures,
|
||||
nominees,
|
||||
slashes,
|
||||
stashId,
|
||||
submittedIn,
|
||||
version
|
||||
})
|
||||
);
|
||||
}
|
||||
}, [api, stashId, slashes, nominees, indexes, submittedIn, exposures, version, mountedRef]);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
export default createNamedHook('useInactives', useInactivesImpl);
|
||||
@@ -0,0 +1,22 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { Option } from '@pezkuwi/types';
|
||||
import type { PalletStakingSlashingSlashingSpans } from '@pezkuwi/types/lookup';
|
||||
|
||||
import { createNamedHook, useApi, useCall } from '@pezkuwi/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);
|
||||
@@ -0,0 +1,24 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { DeriveSessionInfo } from '@pezkuwi/api-derive/types';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { createNamedHook, useApi, useCall } from '@pezkuwi/react-hooks';
|
||||
import { BN_ONE } from '@pezkuwi/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);
|
||||
@@ -0,0 +1,102 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { AccountId32 } from '@pezkuwi/types/interfaces';
|
||||
import type { PalletBagsListListBag } from '@pezkuwi/types/lookup';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
import type { ListNode, StashNode } from './types.js';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { AddressMini, Table } from '@pezkuwi/react-components';
|
||||
import { formatNumber } from '@pezkuwi/util';
|
||||
|
||||
import Rebag from './Rebag.js';
|
||||
import Stash from './Stash.js';
|
||||
import useBagEntries from './useBagEntries.js';
|
||||
import useBonded from './useBonded.js';
|
||||
|
||||
interface Props {
|
||||
bagLower: BN;
|
||||
bagUpper: BN;
|
||||
index: number;
|
||||
info: PalletBagsListListBag;
|
||||
nodesOwn?: StashNode[];
|
||||
}
|
||||
|
||||
function getRebags (bonded: ListNode[], bagUpper: BN, bagLower: BN): string[] {
|
||||
return bonded
|
||||
.filter(({ bonded }) =>
|
||||
bonded.gt(bagUpper) ||
|
||||
bonded.lt(bagLower)
|
||||
)
|
||||
.map(({ stashId }) => stashId);
|
||||
}
|
||||
|
||||
function Bag ({ bagLower, bagUpper, info, nodesOwn }: Props): React.ReactElement<Props> {
|
||||
const [[headId, trigger], setHeadId] = useState<[AccountId32 | null, number]>([null, 0]);
|
||||
const [rebags, setRebags] = useState<string[]>([]);
|
||||
const [isLoading, setLoading] = useState(true);
|
||||
const [isCompleted, list] = useBagEntries(headId, trigger);
|
||||
const bonded = useBonded(list);
|
||||
|
||||
useEffect((): void => {
|
||||
info && nodesOwn &&
|
||||
setHeadId(([, trigger]) => [info.head.unwrapOr(null), ++trigger]);
|
||||
}, [info, nodesOwn]);
|
||||
|
||||
useEffect((): void => {
|
||||
setLoading(
|
||||
nodesOwn?.length
|
||||
? !isCompleted || !bonded
|
||||
: false
|
||||
);
|
||||
}, [bonded, isCompleted, nodesOwn]);
|
||||
|
||||
useEffect((): void => {
|
||||
!isLoading && bonded && setRebags(
|
||||
getRebags(bonded, bagUpper, bagLower)
|
||||
);
|
||||
}, [bagLower, bagUpper, bonded, isLoading]);
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<td className='number' />
|
||||
<Table.Column.Balance value={bagUpper} />
|
||||
<Table.Column.Balance value={bagLower} />
|
||||
<td className='address'>{info.head.isSome && <AddressMini value={info.head.unwrap()} />}</td>
|
||||
<td className='address'>{info.tail.isSome && <AddressMini value={info.tail.unwrap()} />}</td>
|
||||
<td className='address'>
|
||||
{nodesOwn?.map(({ stashId }) => (
|
||||
<Stash
|
||||
bagLower={bagLower}
|
||||
bagUpper={bagUpper}
|
||||
isLoading={isLoading}
|
||||
key={stashId}
|
||||
list={bonded}
|
||||
stashId={stashId}
|
||||
/>
|
||||
))}
|
||||
</td>
|
||||
<td className='number'>
|
||||
{isLoading
|
||||
? <span className='--tmp'>99</span>
|
||||
: list.length
|
||||
? formatNumber(list.length)
|
||||
: null
|
||||
}
|
||||
</td>
|
||||
<td className='button'>
|
||||
{!isLoading && (
|
||||
<Rebag
|
||||
bagLower={bagLower}
|
||||
bagUpper={bagUpper}
|
||||
stashIds={rebags}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Bag);
|
||||
@@ -0,0 +1,98 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
import type { BagMap } from './types.js';
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
|
||||
import { Button, InputAddress, InputAddressMulti, Modal, TxButton } from '@pezkuwi/react-components';
|
||||
import { useApi, useToggle, useTxBatch } from '@pezkuwi/react-hooks';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
import useBagsNodes from './useBagsNodes.js';
|
||||
|
||||
interface Props {
|
||||
bagLower: BN;
|
||||
bagUpper: BN;
|
||||
stashIds: string[];
|
||||
}
|
||||
|
||||
function getAvailableIds (map: BagMap, bagUpper: BN): string[] {
|
||||
return Object
|
||||
.values(map[bagUpper.toString()] || {})
|
||||
.map(({ stashId }) => stashId);
|
||||
}
|
||||
|
||||
function Rebag ({ bagUpper, stashIds }: Props): React.ReactElement<Props> | null {
|
||||
const { t } = useTranslation();
|
||||
const { api } = useApi();
|
||||
const [accountId, setAccountId] = useState<string | null>(null);
|
||||
const [isVisible, toggleVisible] = useToggle();
|
||||
const map = useBagsNodes(stashIds);
|
||||
const availableIds = useMemo(
|
||||
() => map
|
||||
? getAvailableIds(map, bagUpper)
|
||||
: [],
|
||||
[bagUpper, map]
|
||||
);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const changes = useMemo(
|
||||
() => selectedIds.map((s) => (api.tx.voterBagsList || api.tx.bagsList || api.tx.voterList).rebag(s)),
|
||||
[api, selectedIds]
|
||||
);
|
||||
const tx = useTxBatch(changes);
|
||||
|
||||
if (!availableIds.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
icon='refresh'
|
||||
label={t('Rebag {{count}}', { replace: { count: availableIds.length } })}
|
||||
onClick={toggleVisible}
|
||||
/>
|
||||
{isVisible && (
|
||||
<Modal
|
||||
header={t('Rebag dislocated entries')}
|
||||
onClose={toggleVisible}
|
||||
size='large'
|
||||
>
|
||||
<Modal.Content>
|
||||
<Modal.Columns hint={t('The account that will submit the rebag transaction.')}>
|
||||
<InputAddress
|
||||
label={t('rebag from account')}
|
||||
onChange={setAccountId}
|
||||
type='account'
|
||||
/>
|
||||
</Modal.Columns>
|
||||
<Modal.Columns hint={t('The accounts that will be rebagged as a result of this operation.')}>
|
||||
<InputAddressMulti
|
||||
available={availableIds}
|
||||
availableLabel={t('unselected')}
|
||||
defaultValue={availableIds}
|
||||
maxCount={Number.MAX_SAFE_INTEGER}
|
||||
onChange={setSelectedIds}
|
||||
valueLabel={t('to rebag')}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<TxButton
|
||||
accountId={accountId}
|
||||
extrinsic={tx}
|
||||
icon='refresh'
|
||||
isDisabled={!tx || !selectedIds.length}
|
||||
label={t('Rebag')}
|
||||
onStart={toggleVisible}
|
||||
/>
|
||||
</Modal.Actions>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Rebag);
|
||||
@@ -0,0 +1,80 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
import type { ListNode } from './types.js';
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { AddressMini, styled, TxButton } from '@pezkuwi/react-components';
|
||||
import { useApi } from '@pezkuwi/react-hooks';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
|
||||
interface Props {
|
||||
bagLower: BN;
|
||||
bagUpper: BN;
|
||||
className?: string;
|
||||
isLoading: boolean;
|
||||
list?: ListNode[];
|
||||
stashId: string;
|
||||
}
|
||||
|
||||
interface Entry {
|
||||
canJump: boolean;
|
||||
jumpCount: number;
|
||||
stashInfo: ListNode | null;
|
||||
}
|
||||
|
||||
function findEntry (_upper: BN, _bagLower: BN, stashId: string, list: ListNode[] = []): Entry {
|
||||
const stashInfo = list.find((o) => o.stashId === stashId) || null;
|
||||
const other = (stashInfo?.jump && list.find((o) => o.stashId === stashInfo.jump)) || null;
|
||||
|
||||
return {
|
||||
canJump: !!other,
|
||||
jumpCount: stashInfo && other
|
||||
? (stashInfo.index - other.index)
|
||||
: 0,
|
||||
stashInfo
|
||||
};
|
||||
}
|
||||
|
||||
function Stash ({ bagLower, bagUpper, className, isLoading, list, stashId }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const { api } = useApi();
|
||||
const { canJump, jumpCount, stashInfo } = useMemo(
|
||||
() => findEntry(bagUpper, bagLower, stashId, list),
|
||||
[bagLower, bagUpper, list, stashId]
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledDiv className={className}>
|
||||
<AddressMini
|
||||
value={stashId}
|
||||
withBonded
|
||||
/>
|
||||
{stashInfo && (
|
||||
canJump
|
||||
? (
|
||||
<TxButton
|
||||
accountId={stashInfo.stashId}
|
||||
icon='caret-up'
|
||||
isDisabled={isLoading}
|
||||
label={t('Move up {{jumpCount}}', { replace: { jumpCount } })}
|
||||
params={[stashInfo.jump]}
|
||||
tx={(api.tx.voterBagsList || api.tx.bagsList || api.tx.voterList).putInFrontOf}
|
||||
/>
|
||||
)
|
||||
: null
|
||||
)}
|
||||
</StyledDiv>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledDiv = styled.div`
|
||||
.ui--AddressMini {
|
||||
vertical-align: middle;
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(Stash);
|
||||
@@ -0,0 +1,58 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
import type { BagMap } from './types.js';
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { CardSummary, SummaryBox } from '@pezkuwi/react-components';
|
||||
import { useCall } from '@pezkuwi/react-hooks';
|
||||
import { formatNumber, isNumber } from '@pezkuwi/util';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
import useQueryModule from './useQueryModule.js';
|
||||
|
||||
interface Props {
|
||||
bags?: unknown[];
|
||||
className?: string;
|
||||
mapOwn?: BagMap;
|
||||
}
|
||||
|
||||
function Summary ({ bags, className = '', mapOwn }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const mod = useQueryModule();
|
||||
const total = useCall<BN>(mod.counterForListNodes);
|
||||
|
||||
const myCount = useMemo(
|
||||
() => mapOwn && Object.values(mapOwn).reduce((count, n) => count + n.length, 0),
|
||||
[mapOwn]
|
||||
);
|
||||
|
||||
return (
|
||||
<SummaryBox className={className}>
|
||||
<CardSummary label={t('total bags')}>
|
||||
{bags
|
||||
? formatNumber(bags.length)
|
||||
: <span className='--tmp'>99</span>
|
||||
}
|
||||
</CardSummary>
|
||||
<section>
|
||||
<CardSummary label={t('total nodes')}>
|
||||
{mapOwn
|
||||
? formatNumber(total)
|
||||
: <span className='--tmp'>99</span>
|
||||
}
|
||||
</CardSummary>
|
||||
<CardSummary label={t('my nodes')}>
|
||||
{isNumber(myCount)
|
||||
? formatNumber(myCount)
|
||||
: <span className='--tmp'>99</span>
|
||||
}
|
||||
</CardSummary>
|
||||
</section>
|
||||
</SummaryBox>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Summary);
|
||||
@@ -0,0 +1,103 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { StakerState } from '@pezkuwi/react-hooks/types';
|
||||
import type { BagInfo, BagMap, StashNode } from './types.js';
|
||||
|
||||
import React, { useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { Button, MarkWarning, Table, ToggleGroup } from '@pezkuwi/react-components';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
import Bag from './Bag.js';
|
||||
import Summary from './Summary.js';
|
||||
import useBagsList from './useBagsList.js';
|
||||
import useBagsNodes from './useBagsNodes.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
ownStashes?: StakerState[];
|
||||
}
|
||||
|
||||
function sortNodes (list: BagInfo[], nodes: BagMap, onlyMine: boolean): [BagInfo, StashNode[] | undefined][] {
|
||||
return list
|
||||
.map((b): [BagInfo, StashNode[] | undefined] => [b, nodes[b.key]])
|
||||
.filter(([, n]) => !onlyMine || !!n);
|
||||
}
|
||||
|
||||
function Bags ({ className, ownStashes }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const stashIds = useMemo(
|
||||
() => ownStashes
|
||||
? ownStashes.map(({ stashId }) => stashId)
|
||||
: [],
|
||||
[ownStashes]
|
||||
);
|
||||
const [filterIndex, setFilterIndex] = useState(() => stashIds.length ? 0 : 1);
|
||||
const bags = useBagsList();
|
||||
const mapOwn = useBagsNodes(stashIds);
|
||||
|
||||
const headerRef = useRef<[React.ReactNode?, string?, number?][]>([
|
||||
[t('bags')],
|
||||
[t('max'), 'number'],
|
||||
[t('min'), 'number'],
|
||||
[t('first'), 'address'],
|
||||
[t('last'), 'address'],
|
||||
[t('stashes'), 'address'],
|
||||
[t('nodes'), 'number'],
|
||||
[undefined, 'mini']
|
||||
]);
|
||||
|
||||
const filterOptions = useMemo(
|
||||
() => [
|
||||
{ isDisabled: !stashIds.length, text: t('My bags'), value: 'mine' },
|
||||
{ text: t('All bags'), value: 'all' }
|
||||
],
|
||||
[stashIds, t]
|
||||
);
|
||||
const filtered = useMemo(
|
||||
() => bags && mapOwn && sortNodes(bags, mapOwn, !filterIndex),
|
||||
[bags, filterIndex, mapOwn]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<Summary
|
||||
bags={bags}
|
||||
mapOwn={mapOwn}
|
||||
/>
|
||||
<Button.Group>
|
||||
<ToggleGroup
|
||||
onChange={setFilterIndex}
|
||||
options={filterOptions}
|
||||
value={filterIndex}
|
||||
/>
|
||||
</Button.Group>
|
||||
<MarkWarning
|
||||
className='warning centered'
|
||||
withIcon={false}
|
||||
>
|
||||
<p>{t('The All bags list is composed of bags that each describe a range of active bonded funds of the nominators. In each bag is a list of nodes that correspond to a nominator and their staked funds.')}</p>
|
||||
<p>{t('Within the context of a single bag, nodes are not sorted by their stake, but instead placed in insertion order. In other words, the most recently inserted node will be the last node in the bag, regardless of stake. Events like staking rewards or slashes do not automatically put you in a different bag. The bags-list pallet comes with an important permissionless extrinsic: rebag. This allows anyone to specify another account that is in the wrong bag, and place it in the correct one.')}</p>
|
||||
</MarkWarning>
|
||||
<Table
|
||||
empty={filtered && t('No available bags')}
|
||||
emptySpinner={t('Retrieving all available bags, this will take some time')}
|
||||
header={headerRef.current}
|
||||
>
|
||||
{filtered?.map(([{ bagLower, bagUpper, index, info, key }, nodesOwn]) => (
|
||||
<Bag
|
||||
bagLower={bagLower}
|
||||
bagUpper={bagUpper}
|
||||
index={index}
|
||||
info={info}
|
||||
key={key}
|
||||
nodesOwn={nodesOwn}
|
||||
/>
|
||||
))}
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Bags);
|
||||
@@ -0,0 +1,27 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { PalletBagsListListBag, PalletBagsListListNode } from '@pezkuwi/types/lookup';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
|
||||
export interface StashNode {
|
||||
stashId: string;
|
||||
node: PalletBagsListListNode;
|
||||
}
|
||||
|
||||
export interface ListNode {
|
||||
bonded: BN;
|
||||
index: number;
|
||||
jump: string | null;
|
||||
stashId: string;
|
||||
}
|
||||
|
||||
export interface BagInfo {
|
||||
bagLower: BN;
|
||||
bagUpper: BN;
|
||||
index: number;
|
||||
info: PalletBagsListListBag;
|
||||
key: string;
|
||||
}
|
||||
|
||||
export type BagMap = Record<string, StashNode[]>;
|
||||
@@ -0,0 +1,58 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { Option } from '@pezkuwi/types';
|
||||
import type { AccountId32 } from '@pezkuwi/types/interfaces';
|
||||
import type { PalletBagsListListNode } from '@pezkuwi/types/lookup';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { createNamedHook, useCall } from '@pezkuwi/react-hooks';
|
||||
|
||||
import useQueryModule from './useQueryModule.js';
|
||||
|
||||
interface Result {
|
||||
isCompleted: boolean;
|
||||
list: AccountId32[];
|
||||
}
|
||||
|
||||
const EMPTY: [AccountId32 | null, Result] = [null, { isCompleted: false, list: [] }];
|
||||
const EMPTY_LIST: AccountId32[] = [];
|
||||
|
||||
function useBagEntriesImpl (headId: AccountId32 | null, trigger: number): [boolean, AccountId32[]] {
|
||||
const mod = useQueryModule();
|
||||
const [[currId, { isCompleted, list }], setCurrent] = useState<[AccountId32 | null, Result]>(EMPTY);
|
||||
const node = useCall<Option<PalletBagsListListNode>>(!!currId && mod.listNodes, [currId]);
|
||||
|
||||
useEffect(
|
||||
() => setCurrent(
|
||||
headId && trigger
|
||||
? [headId, { isCompleted: false, list: [headId] }]
|
||||
: [null, { isCompleted: true, list: [] }]
|
||||
),
|
||||
[headId, trigger]
|
||||
);
|
||||
|
||||
useEffect((): void => {
|
||||
if (node && node.isSome) {
|
||||
const { next } = node.unwrap();
|
||||
|
||||
if (next.isSome) {
|
||||
const currId = next.unwrap();
|
||||
|
||||
setCurrent(([, { list }]) => [currId, { isCompleted: false, list: [...list, currId] }]);
|
||||
} else {
|
||||
setCurrent(([currId, { list }]) => [currId, { isCompleted: true, list }]);
|
||||
}
|
||||
}
|
||||
}, [node]);
|
||||
|
||||
return [
|
||||
isCompleted,
|
||||
isCompleted
|
||||
? list
|
||||
: EMPTY_LIST
|
||||
];
|
||||
}
|
||||
|
||||
export default createNamedHook('useBagEntries', useBagEntriesImpl);
|
||||
@@ -0,0 +1,68 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { Option, StorageKey, u64 } from '@pezkuwi/types';
|
||||
import type { PalletBagsListListBag } from '@pezkuwi/types/lookup';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
import type { BagInfo } from './types.js';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { createNamedHook, useCall, useMapKeys } from '@pezkuwi/react-hooks';
|
||||
import { BN_ZERO } from '@pezkuwi/util';
|
||||
|
||||
import useQueryModule from './useQueryModule.js';
|
||||
|
||||
const KEY_OPTS = {
|
||||
transform: (keys: StorageKey<[u64]>[]): BN[] =>
|
||||
keys.map(({ args: [id] }) => id)
|
||||
};
|
||||
|
||||
const MULTI_OPTS = {
|
||||
transform: ([[ids], opts]: [[BN[]], Option<PalletBagsListListBag>[]]): BagInfo[] => {
|
||||
const sorted = ids
|
||||
.map((id, index): [BN, Option<PalletBagsListListBag>] => [id, opts[index]])
|
||||
.filter(([, o]) => o.isSome)
|
||||
.sort(([a], [b]) => b.cmp(a))
|
||||
.map(([bagUpper, o], index): BagInfo => ({
|
||||
bagLower: BN_ZERO,
|
||||
bagUpper,
|
||||
index,
|
||||
info: o.unwrap(),
|
||||
key: bagUpper.toString()
|
||||
}));
|
||||
|
||||
return sorted.map((entry, index) =>
|
||||
(index === (sorted.length - 1))
|
||||
? entry
|
||||
// We could probably use a .add(BN_ONE) here
|
||||
: { ...entry, bagLower: sorted[index + 1].bagUpper }
|
||||
);
|
||||
},
|
||||
withParamsTransform: true
|
||||
};
|
||||
|
||||
function merge (prev: BagInfo[] | undefined, curr: BagInfo[]): BagInfo[] {
|
||||
return !prev || curr.length !== prev.length
|
||||
? curr
|
||||
: curr.map((q, i) =>
|
||||
JSON.stringify(q) === JSON.stringify(prev[i])
|
||||
? prev[i]
|
||||
: q
|
||||
);
|
||||
}
|
||||
|
||||
function useBagsListImpl (): BagInfo[] | undefined {
|
||||
const mod = useQueryModule();
|
||||
const [result, setResult] = useState<BagInfo[] | undefined>();
|
||||
const ids = useMapKeys(mod.listBags, [], KEY_OPTS);
|
||||
const query = useCall(ids && ids.length !== 0 && mod.listBags.multi, [ids], MULTI_OPTS);
|
||||
|
||||
useEffect((): void => {
|
||||
query && setResult((prev) => merge(prev, query));
|
||||
}, [query]);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export default createNamedHook('useBagsList', useBagsListImpl);
|
||||
@@ -0,0 +1,56 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { Option } from '@pezkuwi/types';
|
||||
import type { PalletBagsListListNode } from '@pezkuwi/types/lookup';
|
||||
import type { BagMap } from './types.js';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { createNamedHook, useCall } from '@pezkuwi/react-hooks';
|
||||
|
||||
import useQueryModule from './useQueryModule.js';
|
||||
|
||||
const MULTI_OPTS = {
|
||||
transform: (opts: Option<PalletBagsListListNode>[]): BagMap =>
|
||||
opts
|
||||
.filter((o) => o.isSome)
|
||||
.map((o): PalletBagsListListNode => o.unwrap())
|
||||
.reduce((all: BagMap, node): BagMap => {
|
||||
const id = node.bagUpper.toString();
|
||||
|
||||
if (!all[id]) {
|
||||
all[id] = [];
|
||||
}
|
||||
|
||||
all[id].push({ node, stashId: node.id.toString() });
|
||||
|
||||
return all;
|
||||
}, {})
|
||||
};
|
||||
|
||||
function merge (prev: BagMap | undefined, curr: BagMap): BagMap {
|
||||
return Object
|
||||
.entries(curr)
|
||||
.reduce((all: BagMap, [id, nodes]): BagMap => {
|
||||
all[id] = prev?.[id] && JSON.stringify(nodes) === JSON.stringify(prev[id])
|
||||
? prev[id]
|
||||
: nodes;
|
||||
|
||||
return all;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function useBagsNodesImpl (stashIds: string[]): BagMap | undefined {
|
||||
const mod = useQueryModule();
|
||||
const [result, setResult] = useState<BagMap | undefined>();
|
||||
const query = useCall(mod.listNodes.multi, [stashIds], MULTI_OPTS);
|
||||
|
||||
useEffect((): void => {
|
||||
query && setResult((prev) => merge(prev, query));
|
||||
}, [query]);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export default createNamedHook('useBagsNodes', useBagsNodesImpl);
|
||||
@@ -0,0 +1,40 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { DeriveStakingAccount } from '@pezkuwi/api-derive/types';
|
||||
import type { AccountId32 } from '@pezkuwi/types/interfaces';
|
||||
import type { ListNode } from './types.js';
|
||||
|
||||
import { createNamedHook, useApi, useCall } from '@pezkuwi/react-hooks';
|
||||
|
||||
const DERIVE_OPTS = {
|
||||
transform: (all: DeriveStakingAccount[]): ListNode[] => {
|
||||
const infos = all.map(({ stakingLedger, stashId }, index): ListNode => ({
|
||||
bonded: stakingLedger.active.unwrap(),
|
||||
index,
|
||||
jump: null,
|
||||
stashId: stashId.toString()
|
||||
}));
|
||||
|
||||
return infos.map((info) => {
|
||||
const lower = infos.find(({ bonded, index }) =>
|
||||
index < info.index &&
|
||||
bonded.lt(info.bonded)
|
||||
);
|
||||
|
||||
if (lower) {
|
||||
info.jump = lower.stashId;
|
||||
}
|
||||
|
||||
return info;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
function useBondedImpl (ids?: false | AccountId32[]): ListNode[] | undefined {
|
||||
const { api } = useApi();
|
||||
|
||||
return useCall(ids && ids.length !== 0 && api.derive.staking.accounts, [ids], DERIVE_OPTS);
|
||||
}
|
||||
|
||||
export default createNamedHook('useBonded', useBondedImpl);
|
||||
@@ -0,0 +1,19 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { AugmentedQueries } from '@pezkuwi/api-base/types';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { createNamedHook, useApi } from '@pezkuwi/react-hooks';
|
||||
|
||||
function useModuleImpl (): AugmentedQueries<'promise'>['voterList'] {
|
||||
const { api } = useApi();
|
||||
|
||||
return useMemo(
|
||||
() => api.query.voterList || api.query.voterBagsList || api.query.bagsList,
|
||||
[api]
|
||||
);
|
||||
}
|
||||
|
||||
export default createNamedHook('useQueryModule', useModuleImpl);
|
||||
@@ -0,0 +1,29 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { MarkWarning } from '@pezkuwi/react-components';
|
||||
|
||||
import { useTranslation } from './translate.js';
|
||||
|
||||
interface Props {
|
||||
isInElection?: boolean;
|
||||
}
|
||||
|
||||
function ElectionBanner ({ isInElection }: Props): React.ReactElement<Props> | null {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!isInElection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<MarkWarning
|
||||
className='warning centered'
|
||||
content={t('There is currently an ongoing election for new validator candidates. As such staking operations are not permitted.')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(ElectionBanner);
|
||||
@@ -0,0 +1,67 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import queryString from 'query-string';
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
|
||||
import { Input, Toggle } from '@pezkuwi/react-components';
|
||||
import { useApi } from '@pezkuwi/react-hooks';
|
||||
import { isString } from '@pezkuwi/util';
|
||||
|
||||
import { useTranslation } from './translate.js';
|
||||
|
||||
interface Props {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
nameFilter: string;
|
||||
setNameFilter: (value: string, isQuery: boolean) => void;
|
||||
setWithIdentity?: (value: boolean) => void;
|
||||
withIdentity?: boolean;
|
||||
}
|
||||
|
||||
function Filtering ({ children, className, nameFilter, setNameFilter, setWithIdentity, withIdentity }: Props): React.ReactElement<Props> | null {
|
||||
const { t } = useTranslation();
|
||||
const { apiIdentity } = useApi();
|
||||
|
||||
// on load, parse the query string and extract the filter
|
||||
useEffect((): void => {
|
||||
const queryFilter = queryString.parse(location.href.split('?')[1]).filter;
|
||||
|
||||
if (isString(queryFilter)) {
|
||||
setNameFilter(queryFilter, true);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const _setNameFilter = useCallback(
|
||||
(value: string) => setNameFilter(value, false),
|
||||
[setNameFilter]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<Input
|
||||
autoFocus
|
||||
isFull
|
||||
label={t('filter by name, address or index')}
|
||||
onChange={_setNameFilter}
|
||||
value={nameFilter}
|
||||
/>
|
||||
{(children || setWithIdentity) && (
|
||||
<div className='staking--optionsBar'>
|
||||
{children}
|
||||
{setWithIdentity && apiIdentity.query.identity && (
|
||||
<Toggle
|
||||
className='staking--buttonToggle'
|
||||
label={t('with an identity')}
|
||||
onChange={setWithIdentity}
|
||||
value={withIdentity}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Filtering);
|
||||
@@ -0,0 +1,33 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { u32 } from '@pezkuwi/types';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { Badge } from '@pezkuwi/react-components';
|
||||
import { useApi } from '@pezkuwi/react-hooks';
|
||||
|
||||
interface Props {
|
||||
numNominators?: number;
|
||||
}
|
||||
|
||||
function MaxBadge ({ numNominators }: Props): React.ReactElement<Props> | null {
|
||||
const { api } = useApi();
|
||||
|
||||
const max = api.consts.staking?.maxNominatorRewardedPerValidator as u32;
|
||||
|
||||
if (!numNominators || !max || max.gten(numNominators)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge
|
||||
className='media--1200'
|
||||
color='red'
|
||||
icon='balance-scale-right'
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(MaxBadge);
|
||||
@@ -0,0 +1,184 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { ApiPromise } from '@pezkuwi/api';
|
||||
import type { SubmittableExtrinsic } from '@pezkuwi/api/types';
|
||||
import type { BatchOptions } from '@pezkuwi/react-hooks/types';
|
||||
import type { u32 } from '@pezkuwi/types';
|
||||
import type { EraIndex } from '@pezkuwi/types/interfaces';
|
||||
import type { PayoutValidator } from './types.js';
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { AddressMini, Button, InputAddress, Modal, Static, styled, TxButton } from '@pezkuwi/react-components';
|
||||
import { useApi, useToggle, useTxBatch } from '@pezkuwi/react-hooks';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
isAll?: boolean;
|
||||
isDisabled?: boolean;
|
||||
payout?: PayoutValidator | PayoutValidator[];
|
||||
}
|
||||
|
||||
interface SinglePayout {
|
||||
era: EraIndex;
|
||||
validatorId: string;
|
||||
}
|
||||
|
||||
function createStream (api: ApiPromise, payouts: SinglePayout[]): SubmittableExtrinsic<'promise'>[] {
|
||||
return payouts
|
||||
.sort((a, b) => a.era.cmp(b.era))
|
||||
.map(({ era, validatorId }) =>
|
||||
api.tx.staking.payoutStakers(validatorId, era)
|
||||
);
|
||||
}
|
||||
|
||||
function createExtrinsics (api: ApiPromise, payout: PayoutValidator | PayoutValidator[]): SubmittableExtrinsic<'promise'>[] | null {
|
||||
if (!Array.isArray(payout)) {
|
||||
const { eras, validatorId } = payout;
|
||||
|
||||
if (eras.every((e) => e.isClaimed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return eras.length === 1
|
||||
? [api.tx.staking.payoutStakers(validatorId, eras[0].era)]
|
||||
: createStream(api, eras.filter((era) => !era.isClaimed).map((era): SinglePayout => ({ era: era.era, validatorId })));
|
||||
} else if (payout.length === 1) {
|
||||
if (payout[0].eras.every((e) => e.isClaimed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createExtrinsics(api, payout[0]);
|
||||
}
|
||||
|
||||
if (!payout.some((p) => p.eras.some((e) => !e.isClaimed))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createStream(api, payout.reduce((payouts: SinglePayout[], { eras, validatorId }): SinglePayout[] => {
|
||||
eras.forEach(({ era, isClaimed }): void => {
|
||||
if (!isClaimed) {
|
||||
payouts.push({ era, validatorId });
|
||||
}
|
||||
});
|
||||
|
||||
return payouts;
|
||||
}, []));
|
||||
}
|
||||
|
||||
function PayButton ({ className, isAll, isDisabled, payout }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const { api } = useApi();
|
||||
const [isVisible, togglePayout] = useToggle();
|
||||
const [accountId, setAccount] = useState<string | null>(null);
|
||||
const [txs, setTxs] = useState<SubmittableExtrinsic<'promise'>[] | null>(null);
|
||||
const batchOpts = useMemo<BatchOptions>(
|
||||
() => ({
|
||||
max: 36 * 64 / ((api.consts.staking.maxNominatorRewardedPerValidator as u32)?.toNumber() || 64),
|
||||
type: 'force'
|
||||
}),
|
||||
[api]
|
||||
);
|
||||
const extrinsics = useTxBatch(txs, batchOpts);
|
||||
|
||||
useEffect((): void => {
|
||||
payout && setTxs(
|
||||
() => createExtrinsics(api, payout)
|
||||
);
|
||||
}, [api, payout]);
|
||||
|
||||
const isPayoutEmpty = !payout || (!Array.isArray(payout) && !payout.eras.some((e) => !e.isClaimed)) || (Array.isArray(payout) && payout.some((p) => !p.eras.some((e) => !e.isClaimed))) || (Array.isArray(payout) && payout.length === 0);
|
||||
|
||||
return (
|
||||
<>
|
||||
{payout && isVisible && (
|
||||
<StyledModal
|
||||
className={className}
|
||||
header={t('Payout all stakers')}
|
||||
onClose={togglePayout}
|
||||
size='large'
|
||||
>
|
||||
<Modal.Content>
|
||||
<Modal.Columns hint={t('Any account can request payout for stakers, this is not limited to accounts that will be rewarded.')}>
|
||||
<InputAddress
|
||||
label={t('request payout from')}
|
||||
onChange={setAccount}
|
||||
type='account'
|
||||
value={accountId}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
<Modal.Columns
|
||||
hint={
|
||||
<>
|
||||
<p>{t('All the listed validators and all their nominators will receive their rewards.')}</p>
|
||||
<p>{t('The UI puts a limit of 40 payouts at a time, where each payout is a single validator for a single era.')}</p>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{Array.isArray(payout)
|
||||
? (
|
||||
<Static
|
||||
label={t('payout stakers for (multiple)')}
|
||||
value={
|
||||
payout.map(({ validatorId }) => (
|
||||
<AddressMini
|
||||
className='addressStatic'
|
||||
key={validatorId}
|
||||
value={validatorId}
|
||||
/>
|
||||
))
|
||||
}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<InputAddress
|
||||
defaultValue={payout.validatorId}
|
||||
isDisabled
|
||||
label={t('payout stakers for (single)')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</Modal.Columns>
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<TxButton
|
||||
accountId={accountId}
|
||||
extrinsic={extrinsics}
|
||||
icon='credit-card'
|
||||
isDisabled={!extrinsics?.length || !accountId}
|
||||
label={t('Payout')}
|
||||
onStart={togglePayout}
|
||||
/>
|
||||
</Modal.Actions>
|
||||
</StyledModal>
|
||||
)}
|
||||
<Button
|
||||
icon='credit-card'
|
||||
isDisabled={isDisabled || isPayoutEmpty}
|
||||
label={
|
||||
(isAll || Array.isArray(payout))
|
||||
? t('Payout all')
|
||||
: t('Payout')
|
||||
}
|
||||
onClick={togglePayout}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledModal = styled(Modal)`
|
||||
.ui--AddressMini.padded.addressStatic {
|
||||
display: inline-block;
|
||||
padding-top: 0.5rem;
|
||||
|
||||
.ui--AddressMini-info {
|
||||
min-width: 10rem;
|
||||
max-width: 10rem;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(PayButton);
|
||||
@@ -0,0 +1,64 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
import type { PayoutStash } from './types.js';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { AddressSmall, Table } from '@pezkuwi/react-components';
|
||||
import { BlockToTime } from '@pezkuwi/react-query';
|
||||
import { BN_MILLION } from '@pezkuwi/util';
|
||||
|
||||
import useEraBlocks from './useEraBlocks.js';
|
||||
import { createErasString } from './util.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
historyDepth?: BN;
|
||||
payout: PayoutStash;
|
||||
}
|
||||
|
||||
interface EraInfo {
|
||||
eraStr: React.ReactNode;
|
||||
oldestEra?: BN;
|
||||
}
|
||||
|
||||
function Stash ({ className = '', historyDepth, payout: { available, rewards, stashId } }: Props): React.ReactElement<Props> {
|
||||
const [{ eraStr, oldestEra }, setEraInfo] = useState<EraInfo>({ eraStr: '' });
|
||||
const eraBlocks = useEraBlocks(historyDepth, oldestEra);
|
||||
|
||||
useEffect((): void => {
|
||||
rewards && setEraInfo({
|
||||
eraStr: createErasString(rewards.filter(({ isClaimed }) => !isClaimed).map(({ era }) => era)),
|
||||
oldestEra: rewards[0]?.era
|
||||
});
|
||||
}, [rewards]);
|
||||
|
||||
return (
|
||||
<tr className={className}>
|
||||
<td
|
||||
className='address'
|
||||
colSpan={2}
|
||||
>
|
||||
<AddressSmall value={stashId} />
|
||||
</td>
|
||||
<td className='start'>
|
||||
<span className='payout-eras'>{eraStr}</span>
|
||||
</td>
|
||||
<Table.Column.Balance value={available} />
|
||||
<td className='number'>
|
||||
<BlockToTime
|
||||
className={eraBlocks ? '' : '--tmp'}
|
||||
value={eraBlocks || BN_MILLION}
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
className='button'
|
||||
colSpan={3}
|
||||
/>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Stash);
|
||||
@@ -0,0 +1,96 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
import type { PayoutValidator } from './types.js';
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { AddressMini, AddressSmall, Expander, Table } from '@pezkuwi/react-components';
|
||||
import { BlockToTime } from '@pezkuwi/react-query';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
import PayButton from './PayButton.js';
|
||||
import useEraBlocks from './useEraBlocks.js';
|
||||
import { createErasString } from './util.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
historyDepth?: BN;
|
||||
isDisabled?: boolean;
|
||||
payout: PayoutValidator;
|
||||
}
|
||||
|
||||
interface State {
|
||||
eraStr: React.ReactNode;
|
||||
nominators: Record<string, BN>;
|
||||
numNominators: number;
|
||||
oldestEra?: BN;
|
||||
}
|
||||
|
||||
function extractState (payout: PayoutValidator): State {
|
||||
const eraStr = createErasString(payout.eras.filter(({ isClaimed }) => !isClaimed).map(({ era }) => era));
|
||||
const nominators = payout.eras.reduce((nominators: Record<string, BN>, { stashes }): Record<string, BN> => {
|
||||
Object.entries(stashes).forEach(([stashId, value]): void => {
|
||||
if (nominators[stashId]) {
|
||||
nominators[stashId] = nominators[stashId].add(value);
|
||||
} else {
|
||||
nominators[stashId] = value;
|
||||
}
|
||||
});
|
||||
|
||||
return nominators;
|
||||
}, {});
|
||||
|
||||
return { eraStr, nominators, numNominators: Object.keys(nominators).length, oldestEra: payout.eras[0]?.era };
|
||||
}
|
||||
|
||||
function Validator ({ className = '', historyDepth, isDisabled, payout }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { eraStr, nominators, numNominators, oldestEra } = useMemo(
|
||||
() => extractState(payout),
|
||||
[payout]
|
||||
);
|
||||
|
||||
const eraBlocks = useEraBlocks(historyDepth, oldestEra);
|
||||
|
||||
return (
|
||||
<tr className={className}>
|
||||
<td
|
||||
className='address'
|
||||
colSpan={2}
|
||||
>
|
||||
<AddressSmall value={payout.validatorId} />
|
||||
</td>
|
||||
<td className='start'>
|
||||
<span className='payout-eras'>{eraStr}</span>
|
||||
</td>
|
||||
<Table.Column.Balance value={payout.available} />
|
||||
<td className='number'>{eraBlocks && <BlockToTime value={eraBlocks} />}</td>
|
||||
<td
|
||||
className='expand'
|
||||
colSpan={2}
|
||||
>
|
||||
<Expander summary={t('{{count}} own stashes', { replace: { count: numNominators } })}>
|
||||
{Object.entries(nominators).map(([stashId, balance]) =>
|
||||
<AddressMini
|
||||
balance={balance}
|
||||
key={stashId}
|
||||
value={stashId}
|
||||
withBalance
|
||||
/>
|
||||
)}
|
||||
</Expander>
|
||||
</td>
|
||||
<td className='button'>
|
||||
<PayButton
|
||||
isDisabled={isDisabled}
|
||||
payout={payout}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Validator);
|
||||
@@ -0,0 +1,307 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { DeriveStakerReward } from '@pezkuwi/api-derive/types';
|
||||
import type { OwnPool } from '@pezkuwi/app-staking2/Pools/types';
|
||||
import type { StakerState } from '@pezkuwi/react-hooks/types';
|
||||
import type { PayoutStash, PayoutValidator } from './types.js';
|
||||
|
||||
import React, { useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { Button, MarkWarning, styled, Table, ToggleGroup } from '@pezkuwi/react-components';
|
||||
import { useApi, useBlockInterval, useCall, useOwnEraRewards } from '@pezkuwi/react-hooks';
|
||||
import { BN, BN_THREE } from '@pezkuwi/util';
|
||||
|
||||
import ElectionBanner from '../ElectionBanner.js';
|
||||
import { useTranslation } from '../translate.js';
|
||||
import PayButton from './PayButton.js';
|
||||
import Stash from './Stash.js';
|
||||
import Validator from './Validator.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
historyDepth?: BN;
|
||||
isInElection?: boolean;
|
||||
ownPools?: OwnPool[];
|
||||
ownValidators: StakerState[];
|
||||
}
|
||||
|
||||
interface Available {
|
||||
stashAvail?: BN | null;
|
||||
stashes?: PayoutStash[];
|
||||
valAvail?: BN | null;
|
||||
valTotal?: BN | null;
|
||||
validators?: PayoutValidator[];
|
||||
}
|
||||
|
||||
interface EraSelection {
|
||||
value: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
const DAY_SECS = new BN(1000 * 60 * 60 * 24);
|
||||
|
||||
function groupByValidator (allRewards: Record<string, DeriveStakerReward[]>): PayoutValidator[] {
|
||||
return Object
|
||||
.entries(allRewards)
|
||||
.reduce((grouped: PayoutValidator[], [stashId, rewards]): PayoutValidator[] => {
|
||||
rewards
|
||||
.forEach((reward): void => {
|
||||
Object
|
||||
.entries(reward.validators)
|
||||
.forEach(([validatorId, { total, value }]): void => {
|
||||
const entry = grouped.find((entry) => entry.validatorId === validatorId);
|
||||
|
||||
if (entry) {
|
||||
const eraEntry = entry.eras.find((entry) => entry.era.eq(reward.era));
|
||||
|
||||
if (eraEntry) {
|
||||
eraEntry.stashes[stashId] = value;
|
||||
} else {
|
||||
entry.eras.push({
|
||||
era: reward.era,
|
||||
isClaimed: reward.isClaimed,
|
||||
stashes: { [stashId]: value }
|
||||
});
|
||||
}
|
||||
|
||||
entry.available = entry.available.add(value);
|
||||
entry.total = entry.total.add(total);
|
||||
} else {
|
||||
grouped.push({
|
||||
available: value,
|
||||
eras: [{
|
||||
era: reward.era,
|
||||
isClaimed: reward.isClaimed,
|
||||
stashes: { [stashId]: value }
|
||||
}],
|
||||
total,
|
||||
validatorId
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return grouped;
|
||||
}, [])
|
||||
.sort((a, b) => b.available.cmp(a.available));
|
||||
}
|
||||
|
||||
function extractStashes (allRewards: Record<string, DeriveStakerReward[]>): PayoutStash[] {
|
||||
return Object
|
||||
.entries(allRewards)
|
||||
.map(([stashId, rewards]): PayoutStash => ({
|
||||
available: rewards.reduce((result, { validators }) =>
|
||||
Object.values(validators).reduce((result, { value }) =>
|
||||
result.iadd(value), result), new BN(0)
|
||||
),
|
||||
rewards,
|
||||
stashId
|
||||
}))
|
||||
.filter(({ available }) => !available.isZero())
|
||||
.filter(({ rewards }) => rewards.some((r) => !r.isClaimed))
|
||||
.sort((a, b) => b.available.cmp(a.available));
|
||||
}
|
||||
|
||||
function getAvailable (allRewards: Record<string, DeriveStakerReward[]> | null | undefined): Available {
|
||||
if (allRewards) {
|
||||
const stashes = extractStashes(allRewards);
|
||||
const validators = groupByValidator(allRewards);
|
||||
const stashAvail = stashes.length
|
||||
? stashes.reduce<BN>((a, { available }) => a.iadd(available), new BN(0))
|
||||
: null;
|
||||
const [valAvail, valTotal] = validators.length
|
||||
? validators.reduce<[BN, BN]>(([a, t], { available, total }) => [a.iadd(available), t.iadd(total)], [new BN(0), new BN(0)])
|
||||
: [null, null];
|
||||
|
||||
return {
|
||||
stashAvail,
|
||||
stashes,
|
||||
valAvail,
|
||||
valTotal,
|
||||
validators
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
function getOptions (blockTime: BN, eraLength: BN | undefined, historyDepth: BN | undefined, t: (key: string, options?: { replace: Record<string, unknown> }) => string): EraSelection[] {
|
||||
if (!eraLength || !historyDepth) {
|
||||
return [{ text: '', value: 0 }];
|
||||
}
|
||||
|
||||
const blocksPerDay = DAY_SECS.div(blockTime);
|
||||
const maxBlocks = eraLength.mul(historyDepth);
|
||||
const eraSelection: EraSelection[] = [];
|
||||
const days = new BN(2);
|
||||
|
||||
while (true) {
|
||||
const dayBlocks = blocksPerDay.mul(days);
|
||||
|
||||
if (dayBlocks.gte(maxBlocks)) {
|
||||
break;
|
||||
}
|
||||
|
||||
eraSelection.push({
|
||||
text: t('{{days}} days', { replace: { days: days.toString() } }),
|
||||
value: dayBlocks.div(eraLength).toNumber()
|
||||
});
|
||||
|
||||
days.imul(BN_THREE);
|
||||
}
|
||||
|
||||
eraSelection.push({
|
||||
text: t('Max, {{eras}} eras', { replace: { eras: historyDepth.toNumber() } }),
|
||||
value: historyDepth.toNumber()
|
||||
});
|
||||
|
||||
return eraSelection;
|
||||
}
|
||||
|
||||
function Payouts ({ className = '', historyDepth, isInElection, ownPools, ownValidators }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const { api } = useApi();
|
||||
const [hasOwnValidators] = useState(() => ownValidators.length !== 0);
|
||||
const [myStashesIndex, setMyStashesIndex] = useState(() => hasOwnValidators ? 0 : 1);
|
||||
const [eraSelectionIndex, setEraSelectionIndex] = useState(0);
|
||||
const eraLength = useCall<BN>(api.derive.session.eraLength);
|
||||
const blockTime = useBlockInterval();
|
||||
|
||||
const poolStashes = useMemo(
|
||||
() => ownPools?.map(({ stashId }) => stashId),
|
||||
[ownPools]
|
||||
);
|
||||
|
||||
const eraSelection = useMemo(
|
||||
() => getOptions(blockTime, eraLength, historyDepth, t),
|
||||
[blockTime, eraLength, historyDepth, t]
|
||||
);
|
||||
|
||||
const { allRewards, isLoadingRewards } = useOwnEraRewards(eraSelection[eraSelectionIndex].value, myStashesIndex ? undefined : ownValidators, poolStashes);
|
||||
|
||||
const { stashAvail, stashes, valAvail, validators } = useMemo(
|
||||
() => getAvailable(allRewards),
|
||||
[allRewards]
|
||||
);
|
||||
|
||||
const headerStashes = useMemo<[React.ReactNode?, string?, number?][]>(
|
||||
() => [
|
||||
[myStashesIndex ? t('payout/stash') : t('overall/validator'), 'start', 2],
|
||||
[t('eras'), 'start'],
|
||||
[myStashesIndex ? t('own') : t('total')],
|
||||
[('remaining')],
|
||||
[undefined, undefined, 3]
|
||||
],
|
||||
[myStashesIndex, t]
|
||||
);
|
||||
|
||||
const headerValidatorsRef = useRef<[React.ReactNode?, string?, number?][]>([
|
||||
[t('payout/validator'), 'start', 2],
|
||||
[t('eras'), 'start'],
|
||||
[t('own')],
|
||||
[('remaining')],
|
||||
[undefined, undefined, 3]
|
||||
]);
|
||||
|
||||
const valOptions = useMemo(() => [
|
||||
{ isDisabled: !hasOwnValidators, text: t('Own validators'), value: 'val' },
|
||||
{ text: t('Own stashes'), value: 'all' }
|
||||
], [hasOwnValidators, t]);
|
||||
|
||||
const footerStash = useMemo(() => (
|
||||
<tr>
|
||||
<td colSpan={3} />
|
||||
<Table.Column.Balance value={stashAvail} />
|
||||
<td colSpan={4} />
|
||||
</tr>
|
||||
), [stashAvail]);
|
||||
|
||||
const footerVal = useMemo(() => (
|
||||
<tr>
|
||||
<td colSpan={3} />
|
||||
<Table.Column.Balance value={valAvail} />
|
||||
<td colSpan={4} />
|
||||
</tr>
|
||||
), [valAvail]);
|
||||
|
||||
return (
|
||||
<StyledDiv className={className}>
|
||||
<Button.Group>
|
||||
<ToggleGroup
|
||||
onChange={setMyStashesIndex}
|
||||
options={valOptions}
|
||||
value={myStashesIndex}
|
||||
/>
|
||||
<ToggleGroup
|
||||
onChange={setEraSelectionIndex}
|
||||
options={eraSelection}
|
||||
value={eraSelectionIndex}
|
||||
/>
|
||||
<PayButton
|
||||
isAll
|
||||
isDisabled={isInElection}
|
||||
payout={validators}
|
||||
/>
|
||||
</Button.Group>
|
||||
<ElectionBanner isInElection={isInElection} />
|
||||
{!isLoadingRewards && !stashes?.length && (
|
||||
<MarkWarning
|
||||
className='warning centered'
|
||||
withIcon={false}
|
||||
>
|
||||
<p>{t('Payouts of rewards for a validator can be initiated by any account. This means that as soon as a validator or nominator requests a payout for an era, all the nominators for that validator will be rewarded. Each user does not need to claim individually and the suggestion is that validators should claim rewards for everybody as soon as an era ends.')}</p>
|
||||
<p>{t('If you have not claimed rewards straight after the end of the era, the validator is in the active set and you are seeing no rewards, this would mean that the reward payout transaction was made by another account on your behalf. Always check your favorite explorer to see any historic payouts made to your accounts.')}</p>
|
||||
</MarkWarning>
|
||||
)}
|
||||
<Table
|
||||
empty={!isLoadingRewards && stashes && (
|
||||
myStashesIndex
|
||||
? t('No pending payouts for your stashes')
|
||||
: t('No pending payouts for your validators')
|
||||
)}
|
||||
emptySpinner={t('Retrieving info for the selected eras, this will take some time')}
|
||||
footer={footerStash}
|
||||
header={headerStashes}
|
||||
isFixed
|
||||
>
|
||||
{!isLoadingRewards && stashes?.map((payout): React.ReactNode => (
|
||||
<Stash
|
||||
historyDepth={historyDepth}
|
||||
key={payout.stashId}
|
||||
payout={payout}
|
||||
/>
|
||||
))}
|
||||
</Table>
|
||||
{(myStashesIndex === 1) && !isLoadingRewards && validators && (validators.length !== 0) && validators.filter(({ eras }) => eras.some((e) => !e.isClaimed)).length > 0 && (
|
||||
<Table
|
||||
footer={footerVal}
|
||||
header={headerValidatorsRef.current}
|
||||
isFixed
|
||||
>
|
||||
{!isLoadingRewards && validators.filter(({ available }) => !available.isZero()).map((payout): React.ReactNode => (
|
||||
<Validator
|
||||
historyDepth={historyDepth}
|
||||
isDisabled={isInElection}
|
||||
key={payout.validatorId}
|
||||
payout={payout}
|
||||
/>
|
||||
))}
|
||||
</Table>
|
||||
)}
|
||||
</StyledDiv>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledDiv = styled.div`
|
||||
.payout-eras {
|
||||
padding-left: 0.25rem;
|
||||
vertical-align: middle;
|
||||
|
||||
span {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(Payouts);
|
||||
@@ -0,0 +1,25 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { DeriveStakerReward } from '@pezkuwi/api-derive/types';
|
||||
import type { Balance, EraIndex } from '@pezkuwi/types/interfaces';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
|
||||
export interface PayoutEraValidator {
|
||||
era: EraIndex;
|
||||
stashes: Record<string, Balance>;
|
||||
isClaimed: boolean;
|
||||
}
|
||||
|
||||
export interface PayoutValidator {
|
||||
available: BN;
|
||||
eras: PayoutEraValidator[];
|
||||
validatorId: string;
|
||||
total: BN;
|
||||
}
|
||||
|
||||
export interface PayoutStash {
|
||||
available: BN;
|
||||
rewards: DeriveStakerReward[];
|
||||
stashId: string;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { DeriveSessionProgress } from '@pezkuwi/api-derive/types';
|
||||
import type { Forcing } from '@pezkuwi/types/interfaces';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { createNamedHook, useApi, useCall } from '@pezkuwi/react-hooks';
|
||||
import { BN_ONE } from '@pezkuwi/util';
|
||||
|
||||
function useEraBlocksImpl (historyDepth?: BN, era?: BN): BN | undefined {
|
||||
const { api } = useApi();
|
||||
const progress = useCall<DeriveSessionProgress>(api.derive.session.progress);
|
||||
const forcing = useCall<Forcing>(api.query.staking.forceEra);
|
||||
|
||||
return useMemo(
|
||||
() => (historyDepth && era && forcing && progress && progress.sessionLength.gt(BN_ONE))
|
||||
? (
|
||||
forcing.isForceAlways
|
||||
? progress.sessionLength
|
||||
: progress.eraLength
|
||||
).mul(
|
||||
historyDepth
|
||||
.sub(progress.activeEra)
|
||||
.iadd(era)
|
||||
.iadd(BN_ONE)
|
||||
).isub(
|
||||
forcing.isForceAlways
|
||||
? progress.sessionProgress
|
||||
: progress.eraProgress
|
||||
)
|
||||
: undefined,
|
||||
[era, forcing, historyDepth, progress]
|
||||
);
|
||||
}
|
||||
|
||||
export default createNamedHook('useEraBlocks', useEraBlocksImpl);
|
||||
@@ -0,0 +1,60 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { BN_ONE, formatNumber } from '@pezkuwi/util';
|
||||
|
||||
function isSingle (entry: BN | [BN, BN]): entry is BN {
|
||||
return !Array.isArray(entry);
|
||||
}
|
||||
|
||||
export function createErasString (eras: BN[]): React.ReactNode {
|
||||
if (!eras.length) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const parts = eras
|
||||
.sort((a, b) => a.cmp(b))
|
||||
.reduce((result: (BN | [BN, BN])[], era): (BN | [BN, BN])[] => {
|
||||
if (result.length === 0) {
|
||||
return [era];
|
||||
} else {
|
||||
const last = result[result.length - 1];
|
||||
|
||||
if (isSingle(last)) {
|
||||
if (last.add(BN_ONE).eq(era)) {
|
||||
result[result.length - 1] = [last, era];
|
||||
} else {
|
||||
result.push(era);
|
||||
}
|
||||
} else {
|
||||
if (last[1].add(BN_ONE).eq(era)) {
|
||||
last[1] = era;
|
||||
} else {
|
||||
result.push(era);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [])
|
||||
.map((entry) =>
|
||||
isSingle(entry)
|
||||
? formatNumber(entry)
|
||||
: `${formatNumber(entry[0])}-${formatNumber(entry[1])}`
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{parts.map((section, index) => (
|
||||
<React.Fragment key={section}>
|
||||
{index !== 0 && ', '}
|
||||
<span>{section}</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { LineData } from './types.js';
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { Chart, Spinner, styled } from '@pezkuwi/react-components';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
colors: (string | undefined)[];
|
||||
labels: string[];
|
||||
legends: string[];
|
||||
title: string;
|
||||
values: LineData;
|
||||
}
|
||||
|
||||
function ChartDisplay ({ className = '', colors, labels, legends, title, values }: Props): React.ReactElement<Props> {
|
||||
const isLoading = useMemo(
|
||||
() => !labels || labels.length === 0 || !values || values.length === 0 || !values[0]?.length,
|
||||
[labels, values]
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledDiv className={`${className} staking--Chart ${isLoading ? 'isLoading' : ''}`}>
|
||||
<Chart.Line
|
||||
colors={colors}
|
||||
labels={labels}
|
||||
legends={legends}
|
||||
title={title}
|
||||
values={values}
|
||||
/>
|
||||
{isLoading && (
|
||||
<Spinner />
|
||||
)}
|
||||
</StyledDiv>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledDiv = styled.div`
|
||||
&.isLoading {
|
||||
position: relative;
|
||||
|
||||
canvas, h1 {
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
.ui--Spinner {
|
||||
position: absolute;
|
||||
top: 34%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(ChartDisplay);
|
||||
@@ -0,0 +1,76 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { DeriveStakerPoints } from '@pezkuwi/api-derive/types';
|
||||
import type { LineData, Props } from './types.js';
|
||||
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { useApi, useCall } from '@pezkuwi/react-hooks';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
import Chart from './Chart.js';
|
||||
|
||||
const COLORS_POINTS = [undefined, '#acacac'];
|
||||
|
||||
function extractPoints (labels: string[], points: DeriveStakerPoints[]): LineData {
|
||||
const avgSet = new Array<number>(labels.length);
|
||||
const idxSet = new Array<number>(labels.length);
|
||||
const [total, avgCount] = points.reduce(([total, avgCount], { points }) => {
|
||||
if (points.gtn(0)) {
|
||||
total += points.toNumber();
|
||||
avgCount++;
|
||||
}
|
||||
|
||||
return [total, avgCount];
|
||||
}, [0, 0]);
|
||||
|
||||
points.forEach(({ era, points }): void => {
|
||||
const avg = avgCount > 0
|
||||
? Math.ceil(total * 100 / avgCount) / 100
|
||||
: 0;
|
||||
const index = labels.indexOf(era.toHuman());
|
||||
|
||||
if (index !== -1) {
|
||||
avgSet[index] = avg;
|
||||
idxSet[index] = points.toNumber();
|
||||
}
|
||||
});
|
||||
|
||||
return [idxSet, avgSet];
|
||||
}
|
||||
|
||||
function ChartPoints ({ labels, validatorId }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const { api } = useApi();
|
||||
const params = useMemo(() => [validatorId, false], [validatorId]);
|
||||
const stakerPoints = useCall<DeriveStakerPoints[]>(api.derive.staking.stakerPoints, params);
|
||||
const [values, setValues] = useState<LineData>([]);
|
||||
|
||||
useEffect(
|
||||
() => setValues([]),
|
||||
[validatorId]
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => stakerPoints && setValues(extractPoints(labels, stakerPoints)),
|
||||
[labels, stakerPoints]
|
||||
);
|
||||
|
||||
const legendsRef = useRef([
|
||||
t('points'),
|
||||
t('average')
|
||||
]);
|
||||
|
||||
return (
|
||||
<Chart
|
||||
colors={COLORS_POINTS}
|
||||
labels={labels}
|
||||
legends={legendsRef.current}
|
||||
title={t('era points')}
|
||||
values={values}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(ChartPoints);
|
||||
@@ -0,0 +1,81 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { DeriveStakerPrefs } from '@pezkuwi/api-derive/types';
|
||||
import type { LineData, Props } from './types.js';
|
||||
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { useApi, useCall } from '@pezkuwi/react-hooks';
|
||||
import { BN, BN_BILLION } from '@pezkuwi/util';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
import Chart from './Chart.js';
|
||||
|
||||
const MULT = new BN(100 * 100);
|
||||
const COLORS_POINTS = [undefined, '#acacac'];
|
||||
|
||||
function extractPrefs (labels: string[], prefs: DeriveStakerPrefs[]): LineData {
|
||||
const avgSet = new Array<number>(labels.length);
|
||||
const idxSet = new Array<number>(labels.length);
|
||||
const [total, avgCount] = prefs.reduce(([total, avgCount], { validatorPrefs }) => {
|
||||
const comm = validatorPrefs.commission.unwrap().mul(MULT).div(BN_BILLION).toNumber() / 100;
|
||||
|
||||
if (comm !== 0) {
|
||||
total += comm;
|
||||
avgCount++;
|
||||
}
|
||||
|
||||
return [total, avgCount];
|
||||
}, [0, 0]);
|
||||
|
||||
prefs.forEach(({ era, validatorPrefs }): void => {
|
||||
const comm = validatorPrefs.commission.unwrap().mul(MULT).div(BN_BILLION).toNumber() / 100;
|
||||
const avg = avgCount > 0
|
||||
? Math.ceil(total * 100 / avgCount) / 100
|
||||
: 0;
|
||||
const index = labels.indexOf(era.toHuman());
|
||||
|
||||
if (index !== -1) {
|
||||
avgSet[index] = avg;
|
||||
idxSet[index] = comm;
|
||||
}
|
||||
});
|
||||
|
||||
return [idxSet, avgSet];
|
||||
}
|
||||
|
||||
function ChartPrefs ({ labels, validatorId }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const { api } = useApi();
|
||||
const params = useMemo(() => [validatorId, false], [validatorId]);
|
||||
const stakerPrefs = useCall<DeriveStakerPrefs[]>(api.derive.staking.stakerPrefs, params);
|
||||
const [values, setValues] = useState<LineData>([]);
|
||||
|
||||
useEffect(
|
||||
() => setValues([]),
|
||||
[validatorId]
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => stakerPrefs && setValues(extractPrefs(labels, stakerPrefs)),
|
||||
[labels, stakerPrefs]
|
||||
);
|
||||
|
||||
const legendsRef = useRef([
|
||||
t('commission'),
|
||||
t('average')
|
||||
]);
|
||||
|
||||
return (
|
||||
<Chart
|
||||
colors={COLORS_POINTS}
|
||||
labels={labels}
|
||||
legends={legendsRef.current}
|
||||
title={t('commission')}
|
||||
values={values}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(ChartPrefs);
|
||||
@@ -0,0 +1,104 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { DeriveEraRewards, DeriveOwnSlashes, DeriveStakerPoints } from '@pezkuwi/api-derive/types';
|
||||
import type { LineData, Props } from './types.js';
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { useApi, useCall } from '@pezkuwi/react-hooks';
|
||||
import { BN, formatBalance } from '@pezkuwi/util';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
import Chart from './Chart.js';
|
||||
import { balanceToNumber } from './util.js';
|
||||
|
||||
const COLORS_REWARD = ['#8c2200', '#008c22', '#acacac'];
|
||||
|
||||
function extractRewards (labels: string[], erasRewards: DeriveEraRewards[], ownSlashes: DeriveOwnSlashes[], allPoints: DeriveStakerPoints[], divisor: BN): LineData {
|
||||
const slashSet = new Array<number>(labels.length);
|
||||
const rewardSet = new Array<number>(labels.length);
|
||||
const avgSet = new Array<number>(labels.length);
|
||||
const [total, avgCount] = erasRewards.reduce(([total, avgCount], { era, eraReward }) => {
|
||||
const points = allPoints.find((points) => points.era.eq(era));
|
||||
const reward = points?.eraPoints.gtn(0)
|
||||
? balanceToNumber(points.points.mul(eraReward).div(points.eraPoints), divisor)
|
||||
: 0;
|
||||
|
||||
if (reward > 0) {
|
||||
total += reward;
|
||||
avgCount++;
|
||||
}
|
||||
|
||||
return [total, avgCount];
|
||||
}, [0, 0]);
|
||||
|
||||
erasRewards.forEach(({ era, eraReward }): void => {
|
||||
const points = allPoints.find((points) => points.era.eq(era));
|
||||
const slashed = ownSlashes.find((slash) => slash.era.eq(era));
|
||||
const reward = points?.eraPoints.gtn(0)
|
||||
? balanceToNumber(points.points.mul(eraReward).div(points.eraPoints), divisor)
|
||||
: 0;
|
||||
const slash = slashed
|
||||
? balanceToNumber(slashed.total, divisor)
|
||||
: 0;
|
||||
const avg = avgCount > 0
|
||||
? Math.ceil(total * 100 / avgCount) / 100
|
||||
: 0;
|
||||
const index = labels.indexOf(era.toHuman());
|
||||
|
||||
if (index !== -1) {
|
||||
rewardSet[index] = reward;
|
||||
avgSet[index] = avg;
|
||||
slashSet[index] = slash;
|
||||
}
|
||||
});
|
||||
|
||||
return [slashSet, rewardSet, avgSet];
|
||||
}
|
||||
|
||||
function ChartRewards ({ labels, validatorId }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const { api } = useApi();
|
||||
const params = useMemo(() => [validatorId, false], [validatorId]);
|
||||
const ownSlashes = useCall<DeriveOwnSlashes[]>(api.derive.staking.ownSlashes, params);
|
||||
const erasRewards = useCall<DeriveEraRewards[]>(api.derive.staking.erasRewards);
|
||||
const stakerPoints = useCall<DeriveStakerPoints[]>(api.derive.staking.stakerPoints, params);
|
||||
const [values, setValues] = useState<LineData>([]);
|
||||
|
||||
const { currency, divisor } = useMemo(
|
||||
() => ({
|
||||
currency: formatBalance.getDefaults().unit,
|
||||
divisor: new BN('1'.padEnd(formatBalance.getDefaults().decimals + 1, '0'))
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => setValues([]),
|
||||
[validatorId]
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => erasRewards && ownSlashes && stakerPoints && setValues(extractRewards(labels, erasRewards, ownSlashes, stakerPoints, divisor)),
|
||||
[labels, divisor, erasRewards, ownSlashes, stakerPoints]
|
||||
);
|
||||
|
||||
const legends = useMemo(() => [
|
||||
t('{{currency}} slashed', { replace: { currency } }),
|
||||
t('{{currency}} rewards', { replace: { currency } }),
|
||||
t('{{currency}} average', { replace: { currency } })
|
||||
], [currency, t]);
|
||||
|
||||
return (
|
||||
<Chart
|
||||
colors={COLORS_REWARD}
|
||||
labels={labels}
|
||||
legends={legends}
|
||||
title={t('rewards & slashes')}
|
||||
values={values}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(ChartRewards);
|
||||
@@ -0,0 +1,97 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { DeriveOwnExposure } from '@pezkuwi/api-derive/types';
|
||||
import type { LineData, Props } from './types.js';
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { useApi, useCall } from '@pezkuwi/react-hooks';
|
||||
import { BN, BN_ZERO, formatBalance } from '@pezkuwi/util';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
import Chart from './Chart.js';
|
||||
import { balanceToNumber } from './util.js';
|
||||
|
||||
const COLORS_STAKE = [undefined, '#8c2200', '#acacac'];
|
||||
|
||||
function extractStake (labels: string[], exposures: DeriveOwnExposure[], divisor: BN): LineData {
|
||||
const expPagedSet = new Array<number>(labels.length);
|
||||
const expMetaSet = new Array<number>(labels.length);
|
||||
const avgSet = new Array<number>(labels.length);
|
||||
const [total, avgCount] = exposures.reduce(([total, avgCount], { exposureMeta }) => {
|
||||
const expMeta = exposureMeta.isSome && exposureMeta.unwrap();
|
||||
const expM = balanceToNumber((expMeta && expMeta.total?.unwrap()) || BN_ZERO, divisor);
|
||||
|
||||
if (expM > 0) {
|
||||
total += expM;
|
||||
avgCount++;
|
||||
}
|
||||
|
||||
return [total, avgCount];
|
||||
}, [0, 0]);
|
||||
|
||||
exposures.forEach(({ era, exposureMeta, exposurePaged }): void => {
|
||||
const expPaged = exposurePaged.isSome && exposurePaged.unwrap();
|
||||
const expMeta = exposureMeta.isSome && exposureMeta.unwrap();
|
||||
// Darwinia Crab doesn't have the total field
|
||||
const expP = balanceToNumber((expPaged && expPaged.pageTotal?.unwrap()) || BN_ZERO, divisor);
|
||||
const expM = balanceToNumber((expMeta && expMeta.total?.unwrap()) || BN_ZERO, divisor);
|
||||
const avg = avgCount > 0
|
||||
? Math.ceil(total * 100 / avgCount) / 100
|
||||
: 0;
|
||||
const index = labels.indexOf(era.toHuman());
|
||||
|
||||
if (index !== -1) {
|
||||
avgSet[index] = avg;
|
||||
expPagedSet[index] = expP;
|
||||
expMetaSet[index] = expM;
|
||||
}
|
||||
});
|
||||
|
||||
return [expPagedSet, expMetaSet, avgSet];
|
||||
}
|
||||
|
||||
function ChartStake ({ labels, validatorId }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const { api } = useApi();
|
||||
const params = useMemo(() => [validatorId, false], [validatorId]);
|
||||
const ownExposures = useCall<DeriveOwnExposure[]>(api.derive.staking.ownExposures, params);
|
||||
const [values, setValues] = useState<LineData>([]);
|
||||
|
||||
const { currency, divisor } = useMemo(
|
||||
() => ({
|
||||
currency: formatBalance.getDefaults().unit,
|
||||
divisor: new BN('1'.padEnd(formatBalance.getDefaults().decimals + 1, '0'))
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => setValues([]),
|
||||
[validatorId]
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => ownExposures && setValues(extractStake(labels, ownExposures, divisor)),
|
||||
[labels, divisor, ownExposures]
|
||||
);
|
||||
|
||||
const legends = useMemo(() => [
|
||||
t('{{currency}} paged', { replace: { currency } }),
|
||||
t('{{currency}} total', { replace: { currency } }),
|
||||
t('{{currency}} average', { replace: { currency } })
|
||||
], [currency, t]);
|
||||
|
||||
return (
|
||||
<Chart
|
||||
colors={COLORS_STAKE}
|
||||
labels={labels}
|
||||
legends={legends}
|
||||
title={t('elected stake')}
|
||||
values={values}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(ChartStake);
|
||||
@@ -0,0 +1,51 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { Props } from './types.js';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { Columar, styled } from '@pezkuwi/react-components';
|
||||
|
||||
import ChartPoints from './ChartPoints.js';
|
||||
import ChartPrefs from './ChartPrefs.js';
|
||||
import ChartRewards from './ChartRewards.js';
|
||||
import ChartStake from './ChartStake.js';
|
||||
|
||||
function Validator ({ className = '', labels, validatorId }: Props): React.ReactElement<Props> | null {
|
||||
return (
|
||||
<StyledColumar className={className}>
|
||||
<Columar.Column>
|
||||
<ChartPoints
|
||||
labels={labels}
|
||||
validatorId={validatorId}
|
||||
/>
|
||||
<ChartRewards
|
||||
labels={labels}
|
||||
validatorId={validatorId}
|
||||
/>
|
||||
</Columar.Column>
|
||||
<Columar.Column>
|
||||
<ChartStake
|
||||
labels={labels}
|
||||
validatorId={validatorId}
|
||||
/>
|
||||
<ChartPrefs
|
||||
labels={labels}
|
||||
validatorId={validatorId}
|
||||
/>
|
||||
</Columar.Column>
|
||||
</StyledColumar>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledColumar = styled(Columar)`
|
||||
.staking--Chart {
|
||||
background: var(--bg-table);
|
||||
border: 1px solid var(--border-table);
|
||||
border-radius: 0.25rem;
|
||||
padding: 1rem 1.5rem;
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(Validator);
|
||||
@@ -0,0 +1,72 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { INumber } from '@pezkuwi/types/types';
|
||||
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import { Button, InputAddressSimple, Spinner } from '@pezkuwi/react-components';
|
||||
import { useApi, useCall } from '@pezkuwi/react-hooks';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
import Validator from './Validator.js';
|
||||
|
||||
interface Props {
|
||||
basePath: string,
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function doQuery (basePath: string, validatorId?: string | null): void {
|
||||
if (validatorId) {
|
||||
window.location.hash = `${basePath}/query/${validatorId}`;
|
||||
}
|
||||
}
|
||||
|
||||
function Query ({ basePath, className }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const { api } = useApi();
|
||||
const { value } = useParams<{ value: string }>();
|
||||
const [validatorId, setValidatorId] = useState<string | null>(value || null);
|
||||
const eras = useCall<INumber[]>(api.derive.staking.erasHistoric);
|
||||
|
||||
const labels = useMemo(
|
||||
() => eras?.map((e) => e.toHuman() as string),
|
||||
[eras]
|
||||
);
|
||||
|
||||
const _onQuery = useCallback(
|
||||
() => doQuery(basePath, validatorId),
|
||||
[basePath, validatorId]
|
||||
);
|
||||
|
||||
if (!labels) {
|
||||
return <Spinner />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<InputAddressSimple
|
||||
className='staking--queryInput'
|
||||
defaultValue={value}
|
||||
label={t('validator to query')}
|
||||
onChange={setValidatorId}
|
||||
onEnter={_onQuery}
|
||||
>
|
||||
<Button
|
||||
icon='play'
|
||||
isDisabled={!validatorId}
|
||||
onClick={_onQuery}
|
||||
/>
|
||||
</InputAddressSimple>
|
||||
{value && (
|
||||
<Validator
|
||||
labels={labels}
|
||||
validatorId={value}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Query);
|
||||
@@ -0,0 +1,19 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
|
||||
export interface Props {
|
||||
className?: string;
|
||||
labels: string[];
|
||||
validatorId: string;
|
||||
}
|
||||
|
||||
export type LineDataEntry = (BN | number)[];
|
||||
|
||||
export type LineData = LineDataEntry[];
|
||||
|
||||
export interface ChartInfo {
|
||||
labels: string[];
|
||||
values: LineData;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { DeriveSessionIndexes } from '@pezkuwi/api-derive/types';
|
||||
import type { u32 } from '@pezkuwi/types';
|
||||
import type { SessionRewards } from '../types.js';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { createNamedHook, useApi, useCall, useIsMountedRef } from '@pezkuwi/react-hooks';
|
||||
import { BN_ONE, BN_ZERO, isFunction } from '@pezkuwi/util';
|
||||
|
||||
function useBlockCountsImpl (accountId: string, sessionRewards: SessionRewards[]): u32[] {
|
||||
const { api } = useApi();
|
||||
const mountedRef = useIsMountedRef();
|
||||
const indexes = useCall<DeriveSessionIndexes>(api.derive.session?.indexes);
|
||||
const current = useCall<u32>(api.query.imOnline?.authoredBlocks, [indexes?.currentIndex, accountId]);
|
||||
const [counts, setCounts] = useState<u32[]>([]);
|
||||
const [historic, setHistoric] = useState<u32[]>([]);
|
||||
|
||||
useEffect((): void => {
|
||||
if (isFunction(api.query.imOnline?.authoredBlocks) && sessionRewards?.length) {
|
||||
const filtered = sessionRewards.filter(({ sessionIndex }): boolean => sessionIndex.gt(BN_ZERO));
|
||||
|
||||
if (filtered.length) {
|
||||
Promise
|
||||
.all(filtered.map(({ parentHash, sessionIndex }): Promise<u32> =>
|
||||
// eslint-disable-next-line deprecation/deprecation
|
||||
api.query.imOnline.authoredBlocks.at(parentHash, sessionIndex.sub(BN_ONE), accountId)
|
||||
))
|
||||
.then((historic): void => {
|
||||
mountedRef.current && setHistoric(historic);
|
||||
}).catch(console.error);
|
||||
}
|
||||
}
|
||||
}, [accountId, api, mountedRef, sessionRewards]);
|
||||
|
||||
useEffect((): void => {
|
||||
setCounts([...historic, current || api.createType('u32')].slice(1));
|
||||
}, [api, current, historic]);
|
||||
|
||||
return counts;
|
||||
}
|
||||
|
||||
export default createNamedHook('useBlockCounts', useBlockCountsImpl);
|
||||
@@ -0,0 +1,20 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
|
||||
import { BN_THOUSAND, BN_ZERO, isBn, isFunction } from '@pezkuwi/util';
|
||||
|
||||
interface ToBN {
|
||||
toBn: () => BN;
|
||||
}
|
||||
|
||||
export function balanceToNumber (amount: BN | ToBN = BN_ZERO, divisor: BN): number {
|
||||
const value = isBn(amount)
|
||||
? amount
|
||||
: isFunction(amount.toBn)
|
||||
? amount.toBn()
|
||||
: BN_ZERO;
|
||||
|
||||
return value.mul(BN_THOUSAND).div(divisor).toNumber() / 1000;
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { SubmittableExtrinsic } from '@pezkuwi/api/types';
|
||||
import type { SlashEra } from './types.js';
|
||||
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
|
||||
import { Button, Table, TxButton } from '@pezkuwi/react-components';
|
||||
import { useApi, useCollectiveInstance } from '@pezkuwi/react-hooks';
|
||||
import { BN_ONE, isFunction } from '@pezkuwi/util';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
import Row from './Row.js';
|
||||
import Summary from './Summary.js';
|
||||
|
||||
interface Props {
|
||||
buttons: React.ReactNode;
|
||||
councilId: string | null;
|
||||
councilThreshold: number;
|
||||
slash: SlashEra;
|
||||
}
|
||||
|
||||
interface Proposal {
|
||||
length: number;
|
||||
proposal: SubmittableExtrinsic<'promise'>
|
||||
}
|
||||
|
||||
interface Selected {
|
||||
selected: number[];
|
||||
txAll: Proposal | null;
|
||||
txSome: Proposal | null;
|
||||
}
|
||||
|
||||
function Slashes ({ buttons, councilId, councilThreshold, slash }: Props): React.ReactElement<Props> | null {
|
||||
const { t } = useTranslation();
|
||||
const { api } = useApi();
|
||||
const councilMod = useCollectiveInstance('council');
|
||||
const [{ selected, txAll, txSome }, setSelected] = useState<Selected>((): Selected => {
|
||||
const proposal = api.tx.staking.cancelDeferredSlash(slash.era, slash.slashes.map((_, index) => index));
|
||||
|
||||
return {
|
||||
selected: [],
|
||||
txAll: councilMod
|
||||
? { length: proposal.encodedLength, proposal }
|
||||
: null,
|
||||
txSome: null
|
||||
};
|
||||
});
|
||||
|
||||
const headerRef = useRef<([React.ReactNode?, string?, number?] | false)[]>([
|
||||
[t('era {{era}}/unapplied', {
|
||||
replace: {
|
||||
era: api.query.staking.earliestUnappliedSlash || !api.consts.staking.slashDeferDuration
|
||||
? slash.era.toString()
|
||||
: slash.era.sub(api.consts.staking.slashDeferDuration).sub(BN_ONE).toString()
|
||||
}
|
||||
}), 'start', 3],
|
||||
[t('reporters'), 'address'],
|
||||
[t('own')],
|
||||
[t('other')],
|
||||
[t('total')],
|
||||
[t('payout')],
|
||||
!api.query.staking.earliestUnappliedSlash && !!api.consts.staking.slashDeferDuration &&
|
||||
[t('apply')],
|
||||
[]
|
||||
]);
|
||||
|
||||
const _onSelect = useCallback(
|
||||
(index: number) => setSelected((state): Selected => {
|
||||
const selected = state.selected.includes(index)
|
||||
? state.selected.filter((i) => i !== index)
|
||||
: state.selected.concat(index).sort((a, b) => a - b);
|
||||
const proposal = selected.length
|
||||
? api.tx.staking.cancelDeferredSlash(slash.era, selected)
|
||||
: null;
|
||||
|
||||
return {
|
||||
selected,
|
||||
txAll: state.txAll,
|
||||
txSome: proposal && councilMod && isFunction(api.tx[councilMod].propose)
|
||||
? { length: proposal.encodedLength, proposal }
|
||||
: null
|
||||
};
|
||||
}),
|
||||
[api, councilMod, slash]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Summary slash={slash} />
|
||||
<Button.Group>
|
||||
{buttons}
|
||||
{councilMod && (
|
||||
<>
|
||||
<TxButton
|
||||
accountId={councilId}
|
||||
isDisabled={!txSome}
|
||||
isToplevel
|
||||
label={t('Cancel selected')}
|
||||
params={txSome && (
|
||||
api.tx[councilMod].propose.meta.args.length === 3
|
||||
? [councilThreshold, txSome.proposal, txSome.length]
|
||||
: [councilThreshold, txSome.proposal]
|
||||
)}
|
||||
tx={api.tx[councilMod].propose}
|
||||
/>
|
||||
<TxButton
|
||||
accountId={councilId}
|
||||
isDisabled={!txAll}
|
||||
isToplevel
|
||||
label={t('Cancel all')}
|
||||
params={txAll && (
|
||||
api.tx[councilMod].propose.meta.args.length === 3
|
||||
? [councilThreshold, txAll.proposal, txAll.length]
|
||||
: [councilThreshold, txAll.proposal]
|
||||
)}
|
||||
tx={api.tx[councilMod].propose}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Button.Group>
|
||||
<Table header={headerRef.current}>
|
||||
{slash.slashes.map((slash, index): React.ReactNode => (
|
||||
<Row
|
||||
index={index}
|
||||
isSelected={selected.includes(index)}
|
||||
key={index}
|
||||
onSelect={
|
||||
councilId
|
||||
? _onSelect
|
||||
: undefined
|
||||
}
|
||||
slash={slash}
|
||||
/>
|
||||
))}
|
||||
</Table>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Slashes);
|
||||
@@ -0,0 +1,100 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { Slash } from './types.js';
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
|
||||
import { AddressMini, AddressSmall, Badge, Checkbox, ExpanderScroll } from '@pezkuwi/react-components';
|
||||
import { useApi } from '@pezkuwi/react-hooks';
|
||||
import { FormatBalance } from '@pezkuwi/react-query';
|
||||
import { formatNumber } from '@pezkuwi/util';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
|
||||
interface Props {
|
||||
index: number;
|
||||
isSelected: boolean;
|
||||
onSelect?: (index: number) => void;
|
||||
slash: Slash;
|
||||
}
|
||||
|
||||
function Row ({ index, isSelected, onSelect, slash: { era, isMine, slash: { others, own, payout, reporters, validator }, total, totalOther } }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const { api } = useApi();
|
||||
|
||||
const _onSelect = useCallback(
|
||||
() => onSelect && onSelect(index),
|
||||
[index, onSelect]
|
||||
);
|
||||
|
||||
const renderOthers = useCallback(
|
||||
() => others.map(([accountId, balance], index): React.ReactNode => (
|
||||
<AddressMini
|
||||
balance={balance}
|
||||
key={index}
|
||||
value={accountId}
|
||||
withBalance
|
||||
/>
|
||||
)),
|
||||
[others]
|
||||
);
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<td className='badge'>
|
||||
{isMine && (
|
||||
<Badge
|
||||
color='red'
|
||||
icon='skull-crossbones'
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td className='address'>
|
||||
<AddressSmall value={validator} />
|
||||
</td>
|
||||
<td className='expand all'>
|
||||
{!!others.length && (
|
||||
<ExpanderScroll
|
||||
renderChildren={renderOthers}
|
||||
summary={t('Nominators ({{count}})', { replace: { count: formatNumber(others.length) } })}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td className='address'>
|
||||
{reporters.map((reporter, index): React.ReactNode => (
|
||||
<AddressMini
|
||||
key={index}
|
||||
value={reporter}
|
||||
/>
|
||||
))}
|
||||
</td>
|
||||
<td className='number together'>
|
||||
<FormatBalance value={own} />
|
||||
</td>
|
||||
<td className='number together'>
|
||||
<FormatBalance value={totalOther} />
|
||||
</td>
|
||||
<td className='number together'>
|
||||
<FormatBalance value={total} />
|
||||
</td>
|
||||
<td className='number together'>
|
||||
<FormatBalance value={payout} />
|
||||
</td>
|
||||
{!api.query.staking.earliestUnappliedSlash && !!api.consts.staking.slashDeferDuration && (
|
||||
<td className='number together'>
|
||||
{formatNumber(era)}
|
||||
</td>
|
||||
)}
|
||||
<td>
|
||||
<Checkbox
|
||||
isDisabled={!onSelect}
|
||||
onChange={_onSelect}
|
||||
value={isSelected}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Row);
|
||||
@@ -0,0 +1,65 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { DeriveSessionProgress } from '@pezkuwi/api-derive/types';
|
||||
import type { SlashEra } from './types.js';
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { CardSummary, SummaryBox } from '@pezkuwi/react-components';
|
||||
import { useApi, useCall } from '@pezkuwi/react-hooks';
|
||||
import { FormatBalance } from '@pezkuwi/react-query';
|
||||
import { BN, BN_ONE, formatNumber } from '@pezkuwi/util';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
|
||||
interface Props {
|
||||
slash: SlashEra;
|
||||
}
|
||||
|
||||
function Summary ({ slash: { era, nominators, reporters, total, validators } }: Props): React.ReactElement<Props> | null {
|
||||
const { t } = useTranslation();
|
||||
const { api } = useApi();
|
||||
const sessionInfo = useCall<DeriveSessionProgress>(api.derive.session?.progress);
|
||||
|
||||
const [blockProgress, blockEnd] = useMemo(
|
||||
() => sessionInfo
|
||||
? [
|
||||
sessionInfo.activeEra.sub(era).isub(BN_ONE).imul(sessionInfo.eraLength).iadd(sessionInfo.eraProgress),
|
||||
api.consts.staking.slashDeferDuration.mul(sessionInfo.eraLength)
|
||||
]
|
||||
: [new BN(0), new BN(0)],
|
||||
[api, era, sessionInfo]
|
||||
);
|
||||
|
||||
return (
|
||||
<SummaryBox>
|
||||
<section>
|
||||
<CardSummary label={t('validators')}>
|
||||
{formatNumber(validators.length)}
|
||||
</CardSummary>
|
||||
<CardSummary label={t('nominators')}>
|
||||
{formatNumber(nominators.length)}
|
||||
</CardSummary>
|
||||
<CardSummary label={t('reporters')}>
|
||||
{formatNumber(reporters.length)}
|
||||
</CardSummary>
|
||||
</section>
|
||||
{blockProgress.gtn(0) && (
|
||||
<CardSummary
|
||||
label={t('defer')}
|
||||
progress={{
|
||||
total: blockEnd,
|
||||
value: blockProgress,
|
||||
withTime: true
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<CardSummary label={t('total')}>
|
||||
<FormatBalance value={total} />
|
||||
</CardSummary>
|
||||
</SummaryBox>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Summary);
|
||||
@@ -0,0 +1,151 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { StakerState } from '@pezkuwi/react-hooks/types';
|
||||
import type { UnappliedSlash } from '@pezkuwi/types/interfaces';
|
||||
import type { Slash, SlashEra } from './types.js';
|
||||
|
||||
import React, { useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { getSlashProposalThreshold } from '@pezkuwi/apps-config';
|
||||
import { Table, ToggleGroup } from '@pezkuwi/react-components';
|
||||
import { useAccounts, useApi, useCollectiveMembers } from '@pezkuwi/react-hooks';
|
||||
import { BN, BN_ONE, formatNumber } from '@pezkuwi/util';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
import Era from './Era.js';
|
||||
|
||||
interface Props {
|
||||
ownStashes?: StakerState[];
|
||||
slashes: [BN, UnappliedSlash[]][];
|
||||
}
|
||||
|
||||
function calcSlashEras (slashes: [BN, UnappliedSlash[]][], ownStashes: StakerState[]): SlashEra[] {
|
||||
const slashEras: SlashEra[] = [];
|
||||
|
||||
slashes
|
||||
.reduce((rows: Slash[], [era, slashes]): Slash[] => {
|
||||
return slashes.reduce((rows: Slash[], slash): Slash[] => {
|
||||
const totalOther = slash.others.reduce((total: BN, [, value]): BN => {
|
||||
return total.add(value);
|
||||
}, new BN(0));
|
||||
|
||||
const isMine = ownStashes.some(({ stashId }): boolean => {
|
||||
return slash.validator.eq(stashId) || slash.others.some(([nominatorId]) => nominatorId.eq(stashId));
|
||||
});
|
||||
|
||||
rows.push({ era, isMine, slash, total: slash.own.add(totalOther), totalOther });
|
||||
|
||||
return rows;
|
||||
}, rows);
|
||||
}, [])
|
||||
.forEach((slash): void => {
|
||||
let slashEra = slashEras.find(({ era }) => era.eq(slash.era));
|
||||
|
||||
if (!slashEra) {
|
||||
slashEra = {
|
||||
era: slash.era,
|
||||
nominators: [],
|
||||
payout: new BN(0),
|
||||
reporters: [],
|
||||
slashes: [],
|
||||
total: new BN(0),
|
||||
validators: []
|
||||
};
|
||||
slashEras.push(slashEra);
|
||||
}
|
||||
|
||||
slashEra.payout.iadd(slash.slash.payout);
|
||||
slashEra.total.iadd(slash.total);
|
||||
slashEra.slashes.push(slash);
|
||||
|
||||
const validatorId = slash.slash.validator.toString();
|
||||
|
||||
if (!slashEra.validators.includes(validatorId)) {
|
||||
slashEra.validators.push(validatorId);
|
||||
}
|
||||
|
||||
slash.slash.others.forEach(([accountId]): void => {
|
||||
const nominatorId = accountId.toString();
|
||||
|
||||
if (slashEra && !slashEra.nominators.includes(nominatorId)) {
|
||||
slashEra.nominators.push(nominatorId);
|
||||
}
|
||||
});
|
||||
|
||||
slash.slash.reporters.forEach((accountId): void => {
|
||||
const reporterId = accountId.toString();
|
||||
|
||||
if (slashEra && !slashEra.reporters.includes(reporterId)) {
|
||||
slashEra.reporters.push(reporterId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return slashEras.sort((a, b) => b.era.cmp(a.era));
|
||||
}
|
||||
|
||||
function Slashes ({ ownStashes = [], slashes }: Props): React.ReactElement<Props> | null {
|
||||
const { t } = useTranslation();
|
||||
const { api } = useApi();
|
||||
const { allAccounts } = useAccounts();
|
||||
const { members } = useCollectiveMembers('council');
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
const rows = useMemo(
|
||||
() => calcSlashEras(slashes, ownStashes),
|
||||
[ownStashes, slashes]
|
||||
);
|
||||
|
||||
const eraOpts = useMemo(
|
||||
() => rows
|
||||
.map(({ era }) =>
|
||||
api.query.staking.earliestUnappliedSlash || !api.consts.staking.slashDeferDuration
|
||||
? era
|
||||
: era.sub(api.consts.staking.slashDeferDuration).sub(BN_ONE)
|
||||
)
|
||||
.map((era) => ({
|
||||
text: t('era {{era}}', { replace: { era: formatNumber(era) } }),
|
||||
value: era.toString()
|
||||
})),
|
||||
[api, rows, t]
|
||||
);
|
||||
|
||||
const councilId = useMemo(
|
||||
() => allAccounts.find((accountId) => members.includes(accountId)) || null,
|
||||
[allAccounts, members]
|
||||
);
|
||||
|
||||
const emptyHeader = useRef<[React.ReactNode?, string?, number?][]>([
|
||||
[t('unapplied'), 'start']
|
||||
]);
|
||||
|
||||
if (!rows.length) {
|
||||
return (
|
||||
<Table
|
||||
empty={t('There are no unapplied/pending slashes')}
|
||||
header={emptyHeader.current}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const councilThreshold = Math.ceil((members.length || 0) * getSlashProposalThreshold(api));
|
||||
|
||||
return (
|
||||
<Era
|
||||
buttons={
|
||||
<ToggleGroup
|
||||
onChange={setSelectedIndex}
|
||||
options={eraOpts}
|
||||
value={selectedIndex}
|
||||
/>
|
||||
}
|
||||
councilId={councilId}
|
||||
councilThreshold={councilThreshold}
|
||||
key={rows[selectedIndex].era.toString()}
|
||||
slash={rows[selectedIndex]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Slashes);
|
||||
@@ -0,0 +1,23 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { UnappliedSlash } from '@pezkuwi/types/interfaces';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
|
||||
export interface Slash {
|
||||
era: BN;
|
||||
isMine: boolean;
|
||||
slash: UnappliedSlash;
|
||||
total: BN;
|
||||
totalOther: BN;
|
||||
}
|
||||
|
||||
export interface SlashEra {
|
||||
era: BN;
|
||||
nominators: string[];
|
||||
payout: BN;
|
||||
reporters: string[];
|
||||
slashes: Slash[];
|
||||
validators: string[];
|
||||
total: BN;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { StakerState } from '@pezkuwi/react-hooks/types';
|
||||
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { AddressMini, Button, InputAddress, Modal, Static, styled, TxButton } from '@pezkuwi/react-components';
|
||||
import { useApi, useToggle } from '@pezkuwi/react-hooks';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
isDisabled: boolean;
|
||||
ownNominators?: StakerState[];
|
||||
targets: string[];
|
||||
}
|
||||
|
||||
interface IdState {
|
||||
controllerId?: string | null;
|
||||
stashId: string;
|
||||
}
|
||||
|
||||
function Nominate ({ className = '', isDisabled, ownNominators, targets }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const { api } = useApi();
|
||||
const [ids, setIds] = useState<IdState | null>(null);
|
||||
const [isOpen, toggleOpen] = useToggle();
|
||||
|
||||
const stashes = useMemo(
|
||||
() => (ownNominators || []).map(({ stashId }) => stashId),
|
||||
[ownNominators]
|
||||
);
|
||||
|
||||
const _onChangeStash = useCallback(
|
||||
(accountId?: string | null): void => {
|
||||
const acc = ownNominators?.find(({ stashId }) => stashId === accountId);
|
||||
|
||||
setIds(
|
||||
acc
|
||||
? { controllerId: acc.controllerId, stashId: acc.stashId }
|
||||
: null
|
||||
);
|
||||
},
|
||||
[ownNominators]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
icon='hand-paper'
|
||||
isDisabled={isDisabled || !stashes.length || !targets.length}
|
||||
label={t('Nominate selected')}
|
||||
onClick={toggleOpen}
|
||||
/>
|
||||
{isOpen && (
|
||||
<StyledModal
|
||||
className={className}
|
||||
header={t('Nominate validators')}
|
||||
onClose={toggleOpen}
|
||||
size='large'
|
||||
>
|
||||
<Modal.Content>
|
||||
<Modal.Columns hint={t('One of your available nomination accounts, keyed by the stash. The transaction will be sent from the controller.')}>
|
||||
<InputAddress
|
||||
filter={stashes}
|
||||
label={t('the stash account to nominate with')}
|
||||
onChange={_onChangeStash}
|
||||
value={ids?.stashId}
|
||||
/>
|
||||
<InputAddress
|
||||
isDisabled
|
||||
label={t('the associated controller')}
|
||||
value={ids?.controllerId}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
<Modal.Columns
|
||||
hint={
|
||||
<>
|
||||
<p>{t('The selected validators to nominate, either via the "currently best algorithm" or via a manual selection.')}</p>
|
||||
<p>{t('Once transmitted the new selection will only take effect in 2 eras since the selection criteria for the next era was done at the end of the previous era. Until then, the nominations will show as inactive.')}</p>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Static
|
||||
label={t('selected validators')}
|
||||
value={
|
||||
targets.map((validatorId) => (
|
||||
<AddressMini
|
||||
className='addressStatic'
|
||||
key={validatorId}
|
||||
value={validatorId}
|
||||
/>
|
||||
))
|
||||
}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<TxButton
|
||||
accountId={ids?.controllerId}
|
||||
label={t('Nominate')}
|
||||
onStart={toggleOpen}
|
||||
params={[targets]}
|
||||
tx={api.tx.staking.nominate}
|
||||
/>
|
||||
</Modal.Actions>
|
||||
</StyledModal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledModal = styled(Modal)`
|
||||
.ui--AddressMini.padded.addressStatic {
|
||||
padding-top: 0.5rem;
|
||||
|
||||
.ui--AddressMini-info {
|
||||
min-width: 10rem;
|
||||
max-width: 10rem;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(Nominate);
|
||||
@@ -0,0 +1,158 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { Option } from '@pezkuwi/types';
|
||||
import type { Balance } from '@pezkuwi/types/interfaces';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { CardSummary, styled, SummaryBox } from '@pezkuwi/react-components';
|
||||
import { useApi, useCall } from '@pezkuwi/react-hooks';
|
||||
import { FormatBalance } from '@pezkuwi/react-query';
|
||||
import { BN_THREE, BN_TWO, BN_ZERO } from '@pezkuwi/util';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
|
||||
interface Props {
|
||||
avgStaked?: BN;
|
||||
className?: string;
|
||||
lastEra?: BN;
|
||||
lowStaked?: BN;
|
||||
minNominated?: BN;
|
||||
minNominatorBond?: BN;
|
||||
numNominators?: number;
|
||||
numValidators?: number;
|
||||
stakedReturn: number;
|
||||
totalIssuance?: BN;
|
||||
totalStaked?: BN;
|
||||
}
|
||||
|
||||
interface ProgressInfo {
|
||||
hideValue: true;
|
||||
isBlurred: boolean;
|
||||
total: BN;
|
||||
value: BN;
|
||||
}
|
||||
|
||||
const OPT_REWARD = {
|
||||
transform: (optBalance: Option<Balance>) =>
|
||||
optBalance.unwrapOrDefault()
|
||||
};
|
||||
|
||||
function getProgressInfo (value?: BN, total?: BN): ProgressInfo {
|
||||
return {
|
||||
hideValue: true,
|
||||
isBlurred: !(value && total),
|
||||
total: (value && total) ? total : BN_THREE,
|
||||
value: (value && total) ? value : BN_TWO
|
||||
};
|
||||
}
|
||||
|
||||
function Summary ({ avgStaked, className, lastEra, lowStaked, minNominated, minNominatorBond, stakedReturn, totalIssuance, totalStaked }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const { api } = useApi();
|
||||
const lastReward = useCall<BN>(lastEra && api.query.staking.erasValidatorReward, [lastEra], OPT_REWARD);
|
||||
|
||||
const progressStake = useMemo(
|
||||
() => getProgressInfo(totalStaked, totalIssuance),
|
||||
[totalIssuance, totalStaked]
|
||||
);
|
||||
|
||||
const progressAvg = useMemo(
|
||||
() => getProgressInfo(lowStaked, avgStaked),
|
||||
[avgStaked, lowStaked]
|
||||
);
|
||||
|
||||
const percent = <span className='percent'>%</span>;
|
||||
|
||||
return (
|
||||
<StyledSummaryBox className={className}>
|
||||
<section className='media--800'>
|
||||
<CardSummary
|
||||
label={t('total staked')}
|
||||
progress={progressStake}
|
||||
>
|
||||
<FormatBalance
|
||||
className={progressStake.isBlurred ? '--tmp' : ''}
|
||||
value={progressStake.value}
|
||||
withSi
|
||||
/>
|
||||
</CardSummary>
|
||||
</section>
|
||||
<section className='media--800'>
|
||||
<CardSummary label={t('returns')}>
|
||||
{totalIssuance && (stakedReturn > 0)
|
||||
? Number.isFinite(stakedReturn)
|
||||
? <>{stakedReturn.toFixed(1)}{percent}</>
|
||||
: '-.-%'
|
||||
: <span className='--tmp'>0.0{percent}</span>
|
||||
}
|
||||
</CardSummary>
|
||||
</section>
|
||||
<section className='media--1000'>
|
||||
<CardSummary
|
||||
label={`${t('lowest / avg staked')}`}
|
||||
progress={progressAvg}
|
||||
>
|
||||
<span className={progressAvg.isBlurred ? '--tmp' : ''}>
|
||||
<FormatBalance
|
||||
value={progressAvg.value}
|
||||
withCurrency={false}
|
||||
withSi
|
||||
/>
|
||||
/
|
||||
<FormatBalance
|
||||
className={progressAvg.isBlurred ? '--tmp' : ''}
|
||||
value={progressAvg.total}
|
||||
withSi
|
||||
/>
|
||||
</span>
|
||||
</CardSummary>
|
||||
</section>
|
||||
<section className='media--1600'>
|
||||
{minNominated?.gt(BN_ZERO) && (
|
||||
<CardSummary
|
||||
className='media--1600'
|
||||
label={
|
||||
minNominatorBond
|
||||
? t('min nominated / threshold')
|
||||
: t('min nominated')}
|
||||
>
|
||||
<FormatBalance
|
||||
value={minNominated}
|
||||
withCurrency={!minNominatorBond}
|
||||
withSi
|
||||
/>
|
||||
{minNominatorBond && (
|
||||
<>
|
||||
/
|
||||
<FormatBalance
|
||||
value={minNominatorBond}
|
||||
withSi
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CardSummary>
|
||||
)}
|
||||
</section>
|
||||
<section>
|
||||
<CardSummary label={t('last reward')}>
|
||||
<FormatBalance
|
||||
className={lastReward ? '' : '--tmp'}
|
||||
value={lastReward || 1}
|
||||
withSi
|
||||
/>
|
||||
</CardSummary>
|
||||
</section>
|
||||
</StyledSummaryBox>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledSummaryBox = styled(SummaryBox)`
|
||||
.percent {
|
||||
font-size: var(--font-percent-tiny);
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(Summary);
|
||||
@@ -0,0 +1,156 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { UnappliedSlash } from '@pezkuwi/types/interfaces';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
import type { NominatedBy, ValidatorInfo } from '../types.js';
|
||||
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
|
||||
import { AddressSmall, Badge, Checkbox, Icon, Table } from '@pezkuwi/react-components';
|
||||
import { checkVisibility } from '@pezkuwi/react-components/util';
|
||||
import { useApi, useBlockTime, useDeriveAccountInfo, useStakingAsyncApis } from '@pezkuwi/react-hooks';
|
||||
import { FormatBalance } from '@pezkuwi/react-query';
|
||||
import { formatNumber } from '@pezkuwi/util';
|
||||
|
||||
import MaxBadge from '../MaxBadge.js';
|
||||
import { useTranslation } from '../translate.js';
|
||||
|
||||
interface Props {
|
||||
allSlashes?: [BN, UnappliedSlash[]][];
|
||||
canSelect: boolean;
|
||||
filterName: string;
|
||||
info: ValidatorInfo;
|
||||
isNominated: boolean;
|
||||
isSelected: boolean;
|
||||
nominatedBy?: NominatedBy[];
|
||||
toggleFavorite: (accountId: string) => void;
|
||||
toggleSelected: (accountId: string) => void;
|
||||
}
|
||||
|
||||
function queryAddress (address: string, isStakingAsync: boolean): void {
|
||||
window.location.hash = `/${isStakingAsync ? 'staking-async' : 'staking'}/query/${address}`;
|
||||
}
|
||||
|
||||
function Validator ({ allSlashes, canSelect, filterName, info: { accountId, bondOther, bondOwn, bondTotal, commissionPer, isBlocking, isElected, isFavorite, key, lastPayout, numNominators, rankOverall, stakedReturnCmp }, isNominated, isSelected, nominatedBy = [], toggleFavorite, toggleSelected }: Props): React.ReactElement<Props> | null {
|
||||
const { t } = useTranslation();
|
||||
const { api, apiIdentity } = useApi();
|
||||
const { isStakingAsync } = useStakingAsyncApis();
|
||||
const accountInfo = useDeriveAccountInfo(accountId);
|
||||
const [,, time] = useBlockTime(lastPayout);
|
||||
|
||||
const isVisible = useMemo(
|
||||
() => accountInfo
|
||||
? checkVisibility(apiIdentity, key, accountInfo, filterName)
|
||||
: true,
|
||||
[accountInfo, apiIdentity, filterName, key]
|
||||
);
|
||||
|
||||
const slashes = useMemo(
|
||||
() => (allSlashes || [])
|
||||
.map(([era, all]) => ({ era, slashes: all.filter(({ validator }) => validator.eq(accountId)) }))
|
||||
.filter(({ slashes }) => slashes.length),
|
||||
[allSlashes, accountId]
|
||||
);
|
||||
|
||||
const _onQueryStats = useCallback(
|
||||
() => queryAddress(key, isStakingAsync),
|
||||
[isStakingAsync, key]
|
||||
);
|
||||
|
||||
const _toggleSelected = useCallback(
|
||||
() => toggleSelected(key),
|
||||
[key, toggleSelected]
|
||||
);
|
||||
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<Table.Column.Favorite
|
||||
address={key}
|
||||
isFavorite={isFavorite}
|
||||
toggle={toggleFavorite}
|
||||
/>
|
||||
<td className='badge together'>
|
||||
{isNominated
|
||||
? (
|
||||
<Badge
|
||||
color='green'
|
||||
icon='hand-paper'
|
||||
/>
|
||||
)
|
||||
: <Badge color='transparent' />
|
||||
}
|
||||
{isElected
|
||||
? (
|
||||
<Badge
|
||||
color='blue'
|
||||
icon='chevron-right'
|
||||
/>
|
||||
)
|
||||
: <Badge color='transparent' />
|
||||
}
|
||||
<MaxBadge numNominators={numNominators || nominatedBy.length} />
|
||||
{isBlocking && (
|
||||
<Badge
|
||||
color='red'
|
||||
icon='user-slash'
|
||||
/>
|
||||
)}
|
||||
{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='number'>{rankOverall !== 0 && formatNumber(rankOverall)}</td>
|
||||
<td className='address all'>
|
||||
<AddressSmall value={accountId} />
|
||||
</td>
|
||||
<td className='number media--1400'>
|
||||
{lastPayout && (
|
||||
api.consts.babe
|
||||
? time.days
|
||||
? time.days === 1
|
||||
? t('yesterday')
|
||||
: t('{{days}} days', { replace: { days: time.days } })
|
||||
: t('recently')
|
||||
: formatNumber(lastPayout)
|
||||
)}
|
||||
</td>
|
||||
<td className='number media--1200 no-pad-right'>{numNominators || ''}</td>
|
||||
<td className='number media--1200 no-pad-left'>{nominatedBy.length || ''}</td>
|
||||
<td className='number media--1100'>{commissionPer.toFixed(2)}%</td>
|
||||
<td className='number together'>{!bondTotal.isZero() && <FormatBalance value={bondTotal} />}</td>
|
||||
<td className='number together media--900'>{!bondOwn.isZero() && <FormatBalance value={bondOwn} />}</td>
|
||||
<td className='number together media--1600'>{!bondOther.isZero() && <FormatBalance value={bondOther} />}</td>
|
||||
<td className='number together'>{(stakedReturnCmp > 0) && <>{stakedReturnCmp.toFixed(2)}%</>}</td>
|
||||
<td>
|
||||
{!isBlocking && (canSelect || isSelected) && (
|
||||
<Checkbox
|
||||
onChange={_toggleSelected}
|
||||
value={isSelected}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<Icon
|
||||
className='staking--stats highlight--color'
|
||||
icon='chart-line'
|
||||
onClick={_onQueryStats}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Validator);
|
||||
@@ -0,0 +1,438 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { DeriveHasIdentity } from '@pezkuwi/api-derive/types';
|
||||
import type { StakerState } from '@pezkuwi/react-hooks/types';
|
||||
import type { u32 } from '@pezkuwi/types-codec';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
import type { NominatedByMap, SortedTargets, TargetSortBy, ValidatorInfo } from '../types.js';
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import Legend from '@pezkuwi/app-staking2/Legend';
|
||||
import { Button, Icon, styled, Table, Toggle } from '@pezkuwi/react-components';
|
||||
import { useApi, useAvailableSlashes, useBlocksPerDays, useSavedFlags } from '@pezkuwi/react-hooks';
|
||||
import { BN_HUNDRED } from '@pezkuwi/util';
|
||||
|
||||
import { MAX_NOMINATIONS } from '../constants.js';
|
||||
import ElectionBanner from '../ElectionBanner.js';
|
||||
import Filtering from '../Filtering.js';
|
||||
import { useTranslation } from '../translate.js';
|
||||
import useIdentities from '../useIdentities.js';
|
||||
import Nominate from './Nominate.js';
|
||||
import Summary from './Summary.js';
|
||||
import useOwnNominators from './useOwnNominators.js';
|
||||
import Validator from './Validator.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
isInElection: boolean;
|
||||
nominatedBy?: NominatedByMap;
|
||||
ownStashes?: StakerState[];
|
||||
targets: SortedTargets;
|
||||
toggleFavorite: (address: string) => void;
|
||||
toggleLedger: () => void;
|
||||
toggleNominatedBy: () => void;
|
||||
}
|
||||
|
||||
interface SavedFlags {
|
||||
withElected: boolean;
|
||||
withGroup: boolean;
|
||||
withIdentity: boolean;
|
||||
withPayout: boolean;
|
||||
withoutComm: boolean;
|
||||
withoutOver: boolean;
|
||||
}
|
||||
|
||||
interface Flags extends SavedFlags {
|
||||
daysPayout: BN;
|
||||
isBabe: boolean;
|
||||
maxPaid: BN | undefined;
|
||||
}
|
||||
|
||||
interface SortState {
|
||||
sortBy: TargetSortBy;
|
||||
sortFromMax: boolean;
|
||||
}
|
||||
|
||||
const CLASSES: Record<string, string> = {
|
||||
rankBondOther: 'media--1600',
|
||||
rankBondOwn: 'media--900'
|
||||
};
|
||||
const MAX_CAP_PERCENT = 100; // 75 if only using numNominators
|
||||
const MAX_COMM_PERCENT = 10; // -1 for median
|
||||
const MAX_DAYS = 7;
|
||||
const SORT_KEYS = ['rankBondTotal', 'rankBondOwn', 'rankBondOther', 'rankOverall'];
|
||||
|
||||
function overlapsDisplay (displays: (string[])[], test: string[]): boolean {
|
||||
return displays.some((d) =>
|
||||
d.length === test.length
|
||||
? d.length === 1
|
||||
? d[0] === test[0]
|
||||
: d.reduce((c, p, i) => c + (p === test[i] ? 1 : 0), 0) >= (test.length - 1)
|
||||
: false
|
||||
);
|
||||
}
|
||||
|
||||
function applyFilter (validators: ValidatorInfo[], medianComm: number, allIdentity: Record<string, DeriveHasIdentity>, { daysPayout, isBabe, maxPaid, withElected, withGroup, withIdentity, withPayout, withoutComm, withoutOver }: Flags, nominatedBy?: NominatedByMap): ValidatorInfo[] {
|
||||
const displays: (string[])[] = [];
|
||||
const parentIds: string[] = [];
|
||||
|
||||
return validators.filter(({ accountId, commissionPer, isElected, isFavorite, lastPayout, numNominators }): boolean => {
|
||||
if (isFavorite) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const stashId = accountId.toString();
|
||||
const thisIdentity = allIdentity[stashId];
|
||||
const nomCount = numNominators || nominatedBy?.[stashId]?.length || 0;
|
||||
|
||||
if (
|
||||
(!withElected || isElected) &&
|
||||
(!withIdentity || !!thisIdentity?.hasIdentity) &&
|
||||
(!withPayout || !isBabe || (!!lastPayout && daysPayout.gte(lastPayout))) &&
|
||||
(!withoutComm || (
|
||||
MAX_COMM_PERCENT > 0
|
||||
? (commissionPer <= MAX_COMM_PERCENT)
|
||||
: (!medianComm || (commissionPer <= medianComm)))
|
||||
) &&
|
||||
(!withoutOver || !maxPaid || maxPaid.muln(MAX_CAP_PERCENT).div(BN_HUNDRED).gten(nomCount))
|
||||
) {
|
||||
if (!withGroup) {
|
||||
return true;
|
||||
} else if (!thisIdentity || !thisIdentity.hasIdentity) {
|
||||
parentIds.push(stashId);
|
||||
|
||||
return true;
|
||||
} else if (!thisIdentity.parentId) {
|
||||
if (!parentIds.includes(stashId)) {
|
||||
if (thisIdentity.display) {
|
||||
const sanitized = thisIdentity.display
|
||||
.replace(/[^\x20-\x7E]/g, '')
|
||||
.replace(/-/g, ' ')
|
||||
.replace(/_/g, ' ')
|
||||
.split(' ')
|
||||
.map((p) => p.trim())
|
||||
.filter((v) => !!v);
|
||||
|
||||
if (overlapsDisplay(displays, sanitized)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
displays.push(sanitized);
|
||||
}
|
||||
|
||||
parentIds.push(stashId);
|
||||
|
||||
return true;
|
||||
}
|
||||
} else if (!parentIds.includes(thisIdentity.parentId)) {
|
||||
parentIds.push(thisIdentity.parentId);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
function sort (sortBy: TargetSortBy, sortFromMax: boolean, validators: ValidatorInfo[]): ValidatorInfo[] {
|
||||
// Use slice to create new array, so that sorting triggers component render
|
||||
return validators
|
||||
.slice(0)
|
||||
.sort((a, b) =>
|
||||
sortFromMax
|
||||
? a[sortBy] - b[sortBy]
|
||||
: b[sortBy] - a[sortBy]
|
||||
)
|
||||
.sort((a, b) =>
|
||||
a.isFavorite === b.isFavorite
|
||||
? 0
|
||||
: (a.isFavorite ? -1 : 1)
|
||||
);
|
||||
}
|
||||
|
||||
function extractNominees (ownNominators: StakerState[] = []): string[] {
|
||||
const myNominees: string[] = [];
|
||||
|
||||
ownNominators.forEach(({ nominating = [] }: StakerState): void => {
|
||||
nominating.forEach((nominee: string): void => {
|
||||
!myNominees.includes(nominee) &&
|
||||
myNominees.push(nominee);
|
||||
});
|
||||
});
|
||||
|
||||
return myNominees;
|
||||
}
|
||||
|
||||
function selectProfitable (list: ValidatorInfo[], maxNominations: number): string[] {
|
||||
const result: string[] = [];
|
||||
|
||||
for (let i = 0; i < list.length && result.length < maxNominations; i++) {
|
||||
const { isBlocking, isFavorite, key, stakedReturnCmp } = list[i];
|
||||
|
||||
(!isBlocking && (isFavorite || (stakedReturnCmp > 0))) &&
|
||||
result.push(key);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
const DEFAULT_FLAGS = {
|
||||
withElected: false,
|
||||
withGroup: true,
|
||||
withIdentity: false,
|
||||
withPayout: false,
|
||||
withoutComm: true,
|
||||
withoutOver: true
|
||||
};
|
||||
|
||||
const DEFAULT_NAME = { isQueryFiltered: false, nameFilter: '' };
|
||||
|
||||
const DEFAULT_SORT: SortState = { sortBy: 'rankOverall', sortFromMax: true };
|
||||
|
||||
function Targets ({ className = '', isInElection, nominatedBy, ownStashes, targets: { avgStaked, inflation: { stakedReturn }, lastEra, lowStaked, medianComm, minNominated, minNominatorBond, nominators, totalIssuance, totalStaked, validatorIds, validators }, toggleFavorite, toggleLedger, toggleNominatedBy }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const { api } = useApi();
|
||||
const allSlashes = useAvailableSlashes();
|
||||
const daysPayout = useBlocksPerDays(MAX_DAYS);
|
||||
const ownNominators = useOwnNominators(ownStashes);
|
||||
const allIdentity = useIdentities(validatorIds);
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const [{ isQueryFiltered, nameFilter }, setNameFilter] = useState(DEFAULT_NAME);
|
||||
const [toggles, setToggle] = useSavedFlags('staking:targets', DEFAULT_FLAGS);
|
||||
const [{ sortBy, sortFromMax }, setSortBy] = useState<SortState>(DEFAULT_SORT);
|
||||
const [sorted, setSorted] = useState<ValidatorInfo[] | undefined>();
|
||||
|
||||
const labelsRef = useRef({
|
||||
rankBondOther: t('other stake'),
|
||||
rankBondOwn: t('own stake'),
|
||||
rankBondTotal: t('total stake'),
|
||||
rankOverall: t('return')
|
||||
});
|
||||
|
||||
const flags = useMemo(
|
||||
() => ({
|
||||
...toggles,
|
||||
daysPayout,
|
||||
isBabe: !!api.consts.babe,
|
||||
isQueryFiltered,
|
||||
maxPaid: api.consts.staking?.maxNominatorRewardedPerValidator as u32
|
||||
}),
|
||||
[api, daysPayout, isQueryFiltered, toggles]
|
||||
);
|
||||
|
||||
const filtered = useMemo(
|
||||
() => allIdentity && validators && nominatedBy &&
|
||||
applyFilter(validators, medianComm, allIdentity, flags, nominatedBy),
|
||||
[allIdentity, flags, medianComm, nominatedBy, validators]
|
||||
);
|
||||
|
||||
// We are using an effect here to get this async. Sorting will have a double-render, however it allows
|
||||
// the page to immediately display (with loading), whereas useMemo would have a laggy interface
|
||||
// (the same applies for changing the sort order, state here is more effective)
|
||||
useEffect((): void => {
|
||||
filtered && setSorted(
|
||||
sort(sortBy, sortFromMax, filtered)
|
||||
);
|
||||
}, [filtered, sortBy, sortFromMax]);
|
||||
|
||||
useEffect((): void => {
|
||||
toggleLedger();
|
||||
toggleNominatedBy();
|
||||
}, [toggleLedger, toggleNominatedBy]);
|
||||
|
||||
const maxNominations = useMemo(
|
||||
() => api.consts.staking.maxNominations
|
||||
? (api.consts.staking.maxNominations as u32).toNumber()
|
||||
: MAX_NOMINATIONS,
|
||||
[api]
|
||||
);
|
||||
|
||||
const myNominees = useMemo(
|
||||
() => extractNominees(ownNominators),
|
||||
[ownNominators]
|
||||
);
|
||||
|
||||
const _sort = useCallback(
|
||||
(sortBy: TargetSortBy) => setSortBy((p) => ({
|
||||
sortBy,
|
||||
sortFromMax: sortBy === p.sortBy
|
||||
? !p.sortFromMax
|
||||
: true
|
||||
})),
|
||||
[]
|
||||
);
|
||||
|
||||
const _toggleSelected = useCallback(
|
||||
(address: string) => setSelected(
|
||||
selected.includes(address)
|
||||
? selected.filter((a) => address !== a)
|
||||
: [...selected, address]
|
||||
),
|
||||
[selected]
|
||||
);
|
||||
|
||||
const _selectProfitable = useCallback(
|
||||
() => filtered && setSelected(
|
||||
selectProfitable(filtered, maxNominations)
|
||||
),
|
||||
[filtered, maxNominations]
|
||||
);
|
||||
|
||||
const _setNameFilter = useCallback(
|
||||
(nameFilter: string, isQueryFiltered: boolean) => setNameFilter({ isQueryFiltered, nameFilter }),
|
||||
[]
|
||||
);
|
||||
|
||||
// False positive, this is part of the type...
|
||||
// eslint-disable-next-line func-call-spacing
|
||||
const header = useMemo<[React.ReactNode?, string?, number?, (() => void)?][]>(() => [
|
||||
[t('validators'), 'start', 4],
|
||||
[t('payout'), 'media--1400'],
|
||||
[t('nominators'), 'media--1200', 2],
|
||||
[t('comm.'), 'media--1100'],
|
||||
...(SORT_KEYS as (keyof typeof labelsRef.current)[]).map((header): [React.ReactNode?, string?, number?, (() => void)?] => [
|
||||
<>{labelsRef.current[header]}<Icon icon={sortBy === header ? (sortFromMax ? 'chevron-down' : 'chevron-up') : 'minus'} /></>,
|
||||
`${sorted ? `isClickable ${sortBy === header ? 'highlight--border' : ''} number` : 'number'} ${CLASSES[header] || ''}`,
|
||||
1,
|
||||
() => _sort(header as 'rankOverall')
|
||||
]),
|
||||
[],
|
||||
[]
|
||||
], [_sort, labelsRef, sortBy, sorted, sortFromMax, t]);
|
||||
|
||||
const filter = useMemo(() => (
|
||||
<div>
|
||||
<Filtering
|
||||
nameFilter={nameFilter}
|
||||
setNameFilter={_setNameFilter}
|
||||
setWithIdentity={setToggle.withIdentity}
|
||||
withIdentity={toggles.withIdentity}
|
||||
>
|
||||
<Toggle
|
||||
className='staking--buttonToggle'
|
||||
label={t('one validator per operator')}
|
||||
onChange={setToggle.withGroup}
|
||||
value={toggles.withGroup}
|
||||
/>
|
||||
<Toggle
|
||||
className='staking--buttonToggle'
|
||||
label={
|
||||
MAX_COMM_PERCENT > 0
|
||||
? t('comm. <= {{maxComm}}%', { replace: { maxComm: MAX_COMM_PERCENT } })
|
||||
: t('comm. <= median')
|
||||
}
|
||||
onChange={setToggle.withoutComm}
|
||||
value={toggles.withoutComm}
|
||||
/>
|
||||
<Toggle
|
||||
className='staking--buttonToggle'
|
||||
label={
|
||||
MAX_CAP_PERCENT < 100
|
||||
? t('capacity < {{maxCap}}%', { replace: { maxCap: MAX_CAP_PERCENT } })
|
||||
: t('with capacity')
|
||||
}
|
||||
onChange={setToggle.withoutOver}
|
||||
value={toggles.withoutOver}
|
||||
/>
|
||||
{api.consts.babe && (
|
||||
// FIXME have some sane era defaults for Aura
|
||||
<Toggle
|
||||
className='staking--buttonToggle'
|
||||
label={t('recent payouts')}
|
||||
onChange={setToggle.withPayout}
|
||||
value={toggles.withPayout}
|
||||
/>
|
||||
)}
|
||||
<Toggle
|
||||
className='staking--buttonToggle'
|
||||
label={t('currently elected')}
|
||||
onChange={setToggle.withElected}
|
||||
value={toggles.withElected}
|
||||
/>
|
||||
</Filtering>
|
||||
</div>
|
||||
), [api, nameFilter, _setNameFilter, setToggle, t, toggles]);
|
||||
|
||||
const displayList = isQueryFiltered
|
||||
? validators
|
||||
: sorted;
|
||||
const canSelect = selected.length < maxNominations;
|
||||
|
||||
return (
|
||||
<StyledDiv className={className}>
|
||||
<Summary
|
||||
avgStaked={avgStaked}
|
||||
lastEra={lastEra}
|
||||
lowStaked={lowStaked}
|
||||
minNominated={minNominated}
|
||||
minNominatorBond={minNominatorBond}
|
||||
numNominators={nominators?.length}
|
||||
numValidators={validators?.length}
|
||||
stakedReturn={stakedReturn}
|
||||
totalIssuance={totalIssuance}
|
||||
totalStaked={totalStaked}
|
||||
/>
|
||||
<Button.Group>
|
||||
<Button
|
||||
icon='check'
|
||||
isDisabled={!validators?.length || !ownNominators?.length}
|
||||
label={t('Most profitable')}
|
||||
onClick={_selectProfitable}
|
||||
/>
|
||||
<Nominate
|
||||
isDisabled={isInElection || !validators?.length}
|
||||
ownNominators={ownNominators}
|
||||
targets={selected}
|
||||
/>
|
||||
</Button.Group>
|
||||
<ElectionBanner isInElection={isInElection} />
|
||||
<Table
|
||||
empty={sorted && t('No active validators to check')}
|
||||
emptySpinner={
|
||||
<>
|
||||
{!(validators && allIdentity) && <div>{t('Retrieving validators')}</div>}
|
||||
{!nominatedBy && <div>{t('Retrieving nominators')}</div>}
|
||||
{!displayList && <div>{t('Preparing target display')}</div>}
|
||||
</>
|
||||
}
|
||||
filter={filter}
|
||||
header={header}
|
||||
legend={<Legend />}
|
||||
>
|
||||
{displayList?.map((info): React.ReactNode =>
|
||||
<Validator
|
||||
allSlashes={allSlashes}
|
||||
canSelect={canSelect}
|
||||
filterName={nameFilter}
|
||||
info={info}
|
||||
isNominated={myNominees.includes(info.key)}
|
||||
isSelected={selected.includes(info.key)}
|
||||
key={info.key}
|
||||
nominatedBy={nominatedBy?.[info.key]}
|
||||
toggleFavorite={toggleFavorite}
|
||||
toggleSelected={_toggleSelected}
|
||||
/>
|
||||
)}
|
||||
</Table>
|
||||
</StyledDiv>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledDiv = styled.div`
|
||||
text-align: center;
|
||||
|
||||
th.isClickable {
|
||||
.ui--Icon {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.ui--Table {
|
||||
overflow-x: auto;
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(Targets);
|
||||
@@ -0,0 +1,20 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { StakerState } from '@pezkuwi/react-hooks/types';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { createNamedHook } from '@pezkuwi/react-hooks';
|
||||
|
||||
function useOwnNominatorsImpl (ownStashes?: StakerState[]): StakerState[] | undefined {
|
||||
return useMemo(
|
||||
() => ownStashes?.filter(({ isOwnController, isStashValidating }) =>
|
||||
isOwnController &&
|
||||
!isStashValidating
|
||||
),
|
||||
[ownStashes]
|
||||
);
|
||||
}
|
||||
|
||||
export default createNamedHook('useOwnNominators', useOwnNominatorsImpl);
|
||||
@@ -0,0 +1,21 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { MarkWarning } from '@pezkuwi/react-components';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
|
||||
function ActionsBanner (): React.ReactElement<null> | null {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<MarkWarning
|
||||
className='warning centered'
|
||||
content={t('Use the account actions to create a new validator/nominator stash and bond it to participate in staking. Do not send funds directly via a transfer to a validator.')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(ActionsBanner);
|
||||
@@ -0,0 +1,88 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { ApiPromise } from '@pezkuwi/api';
|
||||
import type { SlashingSpans } from '@pezkuwi/types/interfaces';
|
||||
import type { NominatedBy as NominatedByType } from '../../types.js';
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { AddressMini, ExpanderScroll } from '@pezkuwi/react-components';
|
||||
import { useApi } from '@pezkuwi/react-hooks';
|
||||
import { formatNumber } from '@pezkuwi/util';
|
||||
|
||||
import { useTranslation } from '../../translate.js';
|
||||
|
||||
interface Props {
|
||||
nominators?: NominatedByType[];
|
||||
slashingSpans?: SlashingSpans | null;
|
||||
}
|
||||
|
||||
interface Chilled {
|
||||
active: null | [number, () => React.ReactNode[]];
|
||||
chilled: null | [number, () => React.ReactNode[]];
|
||||
}
|
||||
|
||||
function extractFunction (all: string[]): null | [number, () => React.ReactNode[]] {
|
||||
return all.length
|
||||
? [
|
||||
all.length,
|
||||
() => all.map((value): React.ReactNode =>
|
||||
<AddressMini
|
||||
key={value}
|
||||
value={value}
|
||||
/>
|
||||
)
|
||||
]
|
||||
: null;
|
||||
}
|
||||
|
||||
function extractChilled (api: ApiPromise, nominators: NominatedByType[] = [], slashingSpans?: SlashingSpans | null): Chilled {
|
||||
// NOTE With the introduction of the SlashReported event,
|
||||
// nominators are not auto-chilled on validator slash
|
||||
const chilled = slashingSpans && !api.events.staking.SlashReported
|
||||
? nominators
|
||||
.filter(({ submittedIn }) =>
|
||||
slashingSpans.lastNonzeroSlash.gt(submittedIn)
|
||||
)
|
||||
.map(({ nominatorId }) => nominatorId)
|
||||
: [];
|
||||
|
||||
return {
|
||||
active: extractFunction(
|
||||
nominators
|
||||
.filter(({ nominatorId }) => !chilled.includes(nominatorId))
|
||||
.map(({ nominatorId }) => nominatorId)
|
||||
),
|
||||
chilled: extractFunction(chilled)
|
||||
};
|
||||
}
|
||||
|
||||
function NominatedBy ({ nominators, slashingSpans }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const { api } = useApi();
|
||||
|
||||
const { active, chilled } = useMemo(
|
||||
() => extractChilled(api, nominators, slashingSpans),
|
||||
[api, nominators, slashingSpans]
|
||||
);
|
||||
|
||||
return (
|
||||
<td className='expand all'>
|
||||
{active && (
|
||||
<ExpanderScroll
|
||||
renderChildren={active[1]}
|
||||
summary={t('Nominations ({{count}})', { replace: { count: formatNumber(active[0]) } })}
|
||||
/>
|
||||
)}
|
||||
{chilled && (
|
||||
<ExpanderScroll
|
||||
renderChildren={chilled[1]}
|
||||
summary={t('Renomination required ({{count}})', { replace: { count: formatNumber(chilled[0]) } })}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(NominatedBy);
|
||||
@@ -0,0 +1,101 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { u32 } from '@pezkuwi/types';
|
||||
import type { NominatorValue } from './types.js';
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { AddressMini, ExpanderScroll } from '@pezkuwi/react-components';
|
||||
import { useApi } from '@pezkuwi/react-hooks';
|
||||
import { FormatBalance } from '@pezkuwi/react-query';
|
||||
import { BN, BN_ZERO } from '@pezkuwi/util';
|
||||
|
||||
interface Props {
|
||||
stakeOther?: BN;
|
||||
nominators?: NominatorValue[];
|
||||
}
|
||||
|
||||
function extractFunction (all: NominatorValue[]): null | [number, () => React.ReactNode[]] {
|
||||
return [
|
||||
all.length,
|
||||
() => all.map(({ nominatorId, value }): React.ReactNode =>
|
||||
<AddressMini
|
||||
bonded={value}
|
||||
key={nominatorId}
|
||||
value={nominatorId}
|
||||
withBonded
|
||||
/>
|
||||
)
|
||||
];
|
||||
}
|
||||
|
||||
function sumValue (all: { value: BN }[]): BN {
|
||||
const total = new BN(0);
|
||||
|
||||
for (let i = 0, count = all.length; i < count; i++) {
|
||||
total.iadd(all[i].value);
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
function extractTotals (maxPaid: BN | undefined, nominators?: NominatorValue[], stakeOther?: BN): [null | [number, () => React.ReactNode[]], BN, null | [number, () => React.ReactNode[]], BN] {
|
||||
if (!nominators) {
|
||||
return [null, BN_ZERO, null, BN_ZERO];
|
||||
}
|
||||
|
||||
const sorted = nominators.sort((a, b) => b.value.cmp(a.value));
|
||||
|
||||
if (!maxPaid || maxPaid.gtn(sorted.length)) {
|
||||
return [extractFunction(sorted), stakeOther || BN_ZERO, null, BN_ZERO];
|
||||
}
|
||||
|
||||
const max = maxPaid.toNumber();
|
||||
const rewarded = sorted.slice(0, max);
|
||||
const rewardedTotal = sumValue(rewarded);
|
||||
const unrewarded = sorted.slice(max);
|
||||
const unrewardedTotal = sumValue(unrewarded);
|
||||
|
||||
return [extractFunction(rewarded), rewardedTotal, extractFunction(unrewarded), unrewardedTotal];
|
||||
}
|
||||
|
||||
function StakeOther ({ nominators, stakeOther }: Props): React.ReactElement<Props> {
|
||||
const { api } = useApi();
|
||||
|
||||
const [rewarded, rewardedTotal, unrewarded, unrewardedTotal] = useMemo(
|
||||
() => extractTotals(api.consts.staking?.maxNominatorRewardedPerValidator as u32, nominators, stakeOther),
|
||||
[api, nominators, stakeOther]
|
||||
);
|
||||
|
||||
return (
|
||||
<td className='expand all'>
|
||||
{(!rewarded || rewarded[0] !== 0) && (
|
||||
<ExpanderScroll
|
||||
className={rewarded ? '' : '--tmp'}
|
||||
renderChildren={rewarded?.[1]}
|
||||
summary={
|
||||
<FormatBalance
|
||||
labelPost={` (${rewarded ? rewarded[0] : '0'})`}
|
||||
value={rewardedTotal}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{unrewarded && (
|
||||
<ExpanderScroll
|
||||
className='stakeOver'
|
||||
renderChildren={unrewarded[1]}
|
||||
summary={
|
||||
<FormatBalance
|
||||
labelPost={` (${unrewarded[0]})`}
|
||||
value={unrewardedTotal}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(StakeOther);
|
||||
@@ -0,0 +1,120 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { Badge } from '@pezkuwi/react-components';
|
||||
import { useAccounts } from '@pezkuwi/react-hooks';
|
||||
|
||||
import MaxBadge from '../../MaxBadge.js';
|
||||
|
||||
interface Props {
|
||||
isChilled?: boolean;
|
||||
isElected: boolean;
|
||||
isMain?: boolean;
|
||||
isPara?: boolean;
|
||||
isRelay?: boolean;
|
||||
nominators?: { nominatorId: string }[];
|
||||
onlineCount?: false | BN;
|
||||
onlineMessage?: boolean;
|
||||
}
|
||||
|
||||
const NO_NOMS: { nominatorId: string }[] = [];
|
||||
|
||||
function Status ({ isChilled, isElected, isMain, isPara, isRelay, nominators = NO_NOMS, onlineCount, onlineMessage }: Props): React.ReactElement<Props> {
|
||||
const { allAccounts } = useAccounts();
|
||||
const blockCount = onlineCount && onlineCount.toNumber();
|
||||
|
||||
const isNominating = useMemo(
|
||||
() => nominators.some(({ nominatorId }) => allAccounts.includes(nominatorId)),
|
||||
[allAccounts, nominators]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isNominating
|
||||
? (
|
||||
<Badge
|
||||
className='media--1100'
|
||||
color='green'
|
||||
icon='hand-paper'
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<Badge
|
||||
className='media--1100'
|
||||
color='transparent'
|
||||
/>
|
||||
)
|
||||
}
|
||||
{isRelay && (
|
||||
isPara
|
||||
? (
|
||||
<Badge
|
||||
className='media--1100'
|
||||
color='purple'
|
||||
icon='vector-square'
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<Badge
|
||||
className='media--1100'
|
||||
color='transparent'
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{isChilled
|
||||
? (
|
||||
<Badge
|
||||
className='media--1000'
|
||||
color='red'
|
||||
icon='cancel'
|
||||
/>
|
||||
)
|
||||
: isElected
|
||||
? (
|
||||
<Badge
|
||||
className='media--1000'
|
||||
color='blue'
|
||||
icon='chevron-right'
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<Badge
|
||||
className='media--1000'
|
||||
color='transparent'
|
||||
/>
|
||||
)
|
||||
}
|
||||
{isMain && (
|
||||
blockCount
|
||||
? (
|
||||
<Badge
|
||||
className='media--900'
|
||||
color='green'
|
||||
info={blockCount}
|
||||
/>
|
||||
)
|
||||
: onlineMessage
|
||||
? (
|
||||
<Badge
|
||||
className='media--900'
|
||||
color='green'
|
||||
icon='envelope'
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<Badge
|
||||
className='media--900'
|
||||
color='transparent'
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<MaxBadge numNominators={nominators.length} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Status);
|
||||
@@ -0,0 +1,236 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { ApiPromise } from '@pezkuwi/api';
|
||||
import type { DeriveHeartbeatAuthor } from '@pezkuwi/api-derive/types';
|
||||
import type { Option } from '@pezkuwi/types';
|
||||
import type { SlashingSpans, ValidatorPrefs } from '@pezkuwi/types/interfaces';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
import type { NominatedBy as NominatedByType, ValidatorInfo } from '../../types.js';
|
||||
import type { NominatorValue } from './types.js';
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { AddressSmall, Columar, Icon, LinkExternal, Table, Tag } from '@pezkuwi/react-components';
|
||||
import { checkVisibility } from '@pezkuwi/react-components/util';
|
||||
import { useApi, useCall, useDeriveAccountInfo, useToggle } from '@pezkuwi/react-hooks';
|
||||
import { FormatBalance } from '@pezkuwi/react-query';
|
||||
import { BN_ZERO } from '@pezkuwi/util';
|
||||
|
||||
import { useTranslation } from '../../translate.js';
|
||||
import NominatedBy from './NominatedBy.js';
|
||||
import StakeOther from './StakeOther.js';
|
||||
import Status from './Status.js';
|
||||
|
||||
interface Props {
|
||||
address: string;
|
||||
className?: string;
|
||||
filterName: string;
|
||||
hasQueries: boolean;
|
||||
isElected: boolean;
|
||||
isFavorite: boolean;
|
||||
isMain?: boolean;
|
||||
isPara?: boolean;
|
||||
lastBlock?: string;
|
||||
minCommission?: BN;
|
||||
nominatedBy?: NominatedByType[];
|
||||
points?: string;
|
||||
recentlyOnline?: DeriveHeartbeatAuthor;
|
||||
toggleFavorite: (accountId: string) => void;
|
||||
validatorInfo?: ValidatorInfo;
|
||||
withIdentity?: boolean;
|
||||
}
|
||||
|
||||
interface StakingState {
|
||||
isChilled?: boolean;
|
||||
commission?: string;
|
||||
nominators?: NominatorValue[];
|
||||
stakeTotal?: BN;
|
||||
stakeOther?: BN;
|
||||
stakeOwn?: BN;
|
||||
}
|
||||
|
||||
function expandInfo ({ exposureMeta, exposurePaged, validatorPrefs }: ValidatorInfo, minCommission?: BN): StakingState {
|
||||
let nominators: NominatorValue[] | undefined;
|
||||
let stakeTotal: BN | undefined;
|
||||
let stakeOther: BN | undefined;
|
||||
let stakeOwn: BN | undefined;
|
||||
|
||||
if (exposureMeta?.total) {
|
||||
nominators = exposurePaged.others.map(({ value, who }) => ({
|
||||
nominatorId: who.toString(),
|
||||
value: value.unwrap()
|
||||
}));
|
||||
stakeTotal = exposureMeta.total?.unwrap() || BN_ZERO;
|
||||
stakeOwn = exposureMeta.own.unwrap();
|
||||
stakeOther = stakeTotal.sub(stakeOwn);
|
||||
}
|
||||
|
||||
const commission = (validatorPrefs as ValidatorPrefs)?.commission?.unwrap();
|
||||
|
||||
return {
|
||||
commission: commission?.toHuman(),
|
||||
isChilled: commission && minCommission && commission.isZero() && commission.lt(minCommission),
|
||||
nominators,
|
||||
stakeOther,
|
||||
stakeOwn,
|
||||
stakeTotal
|
||||
};
|
||||
}
|
||||
|
||||
const transformSlashes = {
|
||||
transform: (opt: Option<SlashingSpans>) => opt.unwrapOr(null)
|
||||
};
|
||||
|
||||
function useAddressCalls (api: ApiPromise, address: string, isMain?: boolean) {
|
||||
const params = useMemo(() => [address], [address]);
|
||||
const accountInfo = useDeriveAccountInfo(address);
|
||||
const slashingSpans = useCall<SlashingSpans | null>(!isMain && api.query.staking.slashingSpans, params, transformSlashes);
|
||||
|
||||
return { accountInfo, slashingSpans };
|
||||
}
|
||||
|
||||
function Address ({ address, className = '', filterName, hasQueries, isElected, isFavorite, isMain, isPara, lastBlock, minCommission, nominatedBy, points, recentlyOnline, toggleFavorite, validatorInfo, withIdentity }: Props): React.ReactElement<Props> | null {
|
||||
const { t } = useTranslation();
|
||||
const { api, apiIdentity } = useApi();
|
||||
const [isExpanded, toggleIsExpanded] = useToggle(false);
|
||||
const { accountInfo, slashingSpans } = useAddressCalls(api, address, isMain);
|
||||
|
||||
const { commission, isChilled, nominators, stakeOther, stakeOwn } = useMemo(
|
||||
() => validatorInfo
|
||||
? expandInfo(validatorInfo, minCommission)
|
||||
: {},
|
||||
[minCommission, validatorInfo]
|
||||
);
|
||||
|
||||
const isVisible = useMemo(
|
||||
() => accountInfo ? checkVisibility(apiIdentity, address, accountInfo, filterName, withIdentity) : true,
|
||||
[accountInfo, address, filterName, apiIdentity, withIdentity]
|
||||
);
|
||||
|
||||
const statsLink = useMemo(
|
||||
() => `#/staking/query/${address}`,
|
||||
[address]
|
||||
);
|
||||
|
||||
const pointsAnimClass = useMemo(
|
||||
() => points && `greyAnim-${Date.now() % 25}`,
|
||||
[points]
|
||||
);
|
||||
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr className={`${className} isExpanded isFirst ${isExpanded ? 'packedBottom' : 'isLast'}`}>
|
||||
<Table.Column.Favorite
|
||||
address={address}
|
||||
isFavorite={isFavorite}
|
||||
toggle={toggleFavorite}
|
||||
/>
|
||||
<td className='badge together'>
|
||||
<Status
|
||||
isChilled={isChilled}
|
||||
isElected={isElected}
|
||||
isMain={isMain}
|
||||
isPara={isPara}
|
||||
isRelay={!!(api.query.parasShared || api.query.shared)?.activeValidatorIndices}
|
||||
nominators={isMain ? nominators : nominatedBy}
|
||||
onlineCount={recentlyOnline?.blockCount}
|
||||
onlineMessage={recentlyOnline?.hasMessage}
|
||||
/>
|
||||
</td>
|
||||
<td className='address all relative'>
|
||||
<AddressSmall value={address} />
|
||||
{isMain && pointsAnimClass && (
|
||||
<Tag
|
||||
className={`${pointsAnimClass} absolute`}
|
||||
color='lightgrey'
|
||||
label={points}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
{isMain
|
||||
? (
|
||||
<StakeOther
|
||||
nominators={nominators}
|
||||
stakeOther={stakeOther}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<NominatedBy
|
||||
nominators={nominatedBy}
|
||||
slashingSpans={slashingSpans}
|
||||
/>
|
||||
)
|
||||
}
|
||||
<td className='number'>
|
||||
{commission || <span className='--tmp'>50.00%</span>}
|
||||
</td>
|
||||
{isMain && (
|
||||
<td className='number'>
|
||||
{lastBlock}
|
||||
</td>
|
||||
)}
|
||||
<Table.Column.Expand
|
||||
isExpanded={isExpanded}
|
||||
toggle={toggleIsExpanded}
|
||||
/>
|
||||
</tr>
|
||||
{isExpanded && (
|
||||
<tr className={`${className} ${isExpanded ? 'isExpanded isLast' : 'isCollapsed'} packedTop`}>
|
||||
<td colSpan={2} />
|
||||
<td
|
||||
className='columar'
|
||||
colSpan={
|
||||
isMain
|
||||
? 4
|
||||
: 3
|
||||
}
|
||||
>
|
||||
<Columar size='small'>
|
||||
<Columar.Column>
|
||||
{isMain && stakeOwn?.gtn(0) && (
|
||||
<>
|
||||
<h5>{t('own stake')}</h5>
|
||||
<FormatBalance
|
||||
value={stakeOwn}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Columar.Column>
|
||||
<Columar.Column>
|
||||
{hasQueries && (
|
||||
<>
|
||||
<h5>{t('graphs')}</h5>
|
||||
<a href={statsLink}>
|
||||
<Icon
|
||||
className='highlight--color'
|
||||
icon='chart-line'
|
||||
/>
|
||||
{t('historic results')}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</Columar.Column>
|
||||
</Columar>
|
||||
<Columar is100>
|
||||
<Columar.Column>
|
||||
<LinkExternal
|
||||
data={address}
|
||||
type='validator' // {isMain ? 'validator' : 'intention'}
|
||||
withTitle
|
||||
/>
|
||||
</Columar.Column>
|
||||
</Columar>
|
||||
</td>
|
||||
<td />
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Address);
|
||||
@@ -0,0 +1,9 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { Balance } from '@pezkuwi/types/interfaces';
|
||||
|
||||
export interface NominatorValue {
|
||||
nominatorId: string;
|
||||
value: Balance;
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { DeriveHeartbeats, DeriveStakingOverview } from '@pezkuwi/api-derive/types';
|
||||
import type { AccountId } from '@pezkuwi/types/interfaces';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
import type { NominatedByMap, SortedTargets, ValidatorInfo } from '../types.js';
|
||||
|
||||
import React, { useMemo, useRef, useState } from 'react';
|
||||
|
||||
import Legend from '@pezkuwi/app-staking2/Legend';
|
||||
import { Table } from '@pezkuwi/react-components';
|
||||
import { useApi, useBlockAuthors, useNextTick } from '@pezkuwi/react-hooks';
|
||||
|
||||
import Filtering from '../Filtering.js';
|
||||
import { useTranslation } from '../translate.js';
|
||||
import Address from './Address/index.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
byAuthor: Record<string, string>;
|
||||
eraPoints: Record<string, string>;
|
||||
favorites: string[];
|
||||
hasQueries: boolean;
|
||||
isIntentions?: boolean;
|
||||
isIntentionsTrigger?: boolean;
|
||||
isOwn: boolean;
|
||||
minCommission?: BN;
|
||||
nominatedBy?: NominatedByMap;
|
||||
ownStashIds?: string[];
|
||||
paraValidators: Record<string, boolean>;
|
||||
recentlyOnline?: DeriveHeartbeats;
|
||||
setNominators?: (nominators: string[]) => void;
|
||||
stakingOverview?: DeriveStakingOverview;
|
||||
targets: SortedTargets;
|
||||
toggleFavorite: (address: string) => void;
|
||||
}
|
||||
|
||||
type AccountExtend = [string, boolean, boolean];
|
||||
|
||||
interface Filtered {
|
||||
validators?: AccountExtend[];
|
||||
waiting?: AccountExtend[];
|
||||
}
|
||||
|
||||
function filterAccounts (isOwn: boolean, accounts: string[] = [], ownStashIds: string[] = [], elected: string[], favorites: string[], without: string[]): AccountExtend[] {
|
||||
return accounts
|
||||
.filter((accountId) =>
|
||||
!without.includes(accountId) && (
|
||||
!isOwn ||
|
||||
ownStashIds.includes(accountId)
|
||||
)
|
||||
)
|
||||
.map((accountId): AccountExtend => [
|
||||
accountId,
|
||||
elected.includes(accountId),
|
||||
favorites.includes(accountId)
|
||||
])
|
||||
.sort(([accA,, isFavA]: AccountExtend, [accB,, isFavB]: AccountExtend): number => {
|
||||
const isStashA = ownStashIds.includes(accA);
|
||||
const isStashB = ownStashIds.includes(accB);
|
||||
|
||||
return isFavA === isFavB
|
||||
? isStashA === isStashB
|
||||
? 0
|
||||
: (isStashA ? -1 : 1)
|
||||
: (isFavA ? -1 : 1);
|
||||
});
|
||||
}
|
||||
|
||||
function accountsToString (accounts: AccountId[]): string[] {
|
||||
const result = new Array<string>(accounts.length);
|
||||
|
||||
for (let i = 0; i < accounts.length; i++) {
|
||||
result[i] = accounts[i].toString();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function getFiltered (isOwn: boolean, stakingOverview: DeriveStakingOverview | undefined, favorites: string[], next?: string[], ownStashIds?: string[]): Filtered {
|
||||
if (!stakingOverview) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const allElected = accountsToString(stakingOverview.nextElected);
|
||||
const validatorIds = accountsToString(stakingOverview.validators);
|
||||
|
||||
return {
|
||||
validators: filterAccounts(isOwn, validatorIds, ownStashIds, allElected, favorites, []),
|
||||
waiting: filterAccounts(isOwn, allElected, ownStashIds, allElected, favorites, validatorIds).concat(
|
||||
filterAccounts(isOwn, next, ownStashIds, [], favorites, allElected)
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
function mapValidators (infos: ValidatorInfo[]): Record<string, ValidatorInfo> {
|
||||
const result: Record<string, ValidatorInfo> = {};
|
||||
|
||||
for (let i = 0, count = infos.length; i < count; i++) {
|
||||
const info = infos[i];
|
||||
|
||||
result[info.key] = info;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
const DEFAULT_PARAS = {};
|
||||
|
||||
function CurrentList ({ className, favorites, hasQueries, isIntentions, isOwn, minCommission, nominatedBy, ownStashIds, paraValidators = DEFAULT_PARAS, recentlyOnline, stakingOverview, targets, toggleFavorite }: Props): React.ReactElement<Props> | null {
|
||||
const { t } = useTranslation();
|
||||
const { api } = useApi();
|
||||
const { byAuthor, eraPoints } = useBlockAuthors();
|
||||
const [nameFilter, setNameFilter] = useState<string>('');
|
||||
const isNextTick = useNextTick();
|
||||
|
||||
const { validators, waiting } = useMemo(
|
||||
() => getFiltered(isOwn, stakingOverview, favorites, targets.waitingIds, ownStashIds),
|
||||
[favorites, isOwn, ownStashIds, stakingOverview, targets]
|
||||
);
|
||||
|
||||
const list = useMemo(
|
||||
() => isNextTick
|
||||
? isIntentions
|
||||
? nominatedBy && waiting
|
||||
: validators
|
||||
: undefined,
|
||||
[isIntentions, isNextTick, nominatedBy, validators, waiting]
|
||||
);
|
||||
|
||||
const infoMap = useMemo(
|
||||
() => targets.validators && mapValidators(targets.validators),
|
||||
[targets]
|
||||
);
|
||||
|
||||
const headerRef = useRef<([React.ReactNode?, string?, number?] | false)[]>(
|
||||
isIntentions
|
||||
? [
|
||||
[t('intentions'), 'start', 3],
|
||||
[t('nominators'), 'expand'],
|
||||
[t('commission'), 'number'],
|
||||
[]
|
||||
]
|
||||
: [
|
||||
[t('validators'), 'start', 3],
|
||||
[t('other stake'), 'expand'],
|
||||
[t('commission')],
|
||||
[t('last #')],
|
||||
[]
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
<Table
|
||||
className={className}
|
||||
empty={
|
||||
isIntentions
|
||||
? list && t('No waiting validators found')
|
||||
: list && recentlyOnline && infoMap && t('No active validators found')
|
||||
}
|
||||
emptySpinner={
|
||||
<>
|
||||
{!waiting && <div>{t('Retrieving validators')}</div>}
|
||||
{!infoMap && <div>{t('Retrieving validator info')}</div>}
|
||||
{isIntentions
|
||||
? !nominatedBy && <div>{t('Retrieving nominators')}</div>
|
||||
: !recentlyOnline && <div>{t('Retrieving online status')}</div>
|
||||
}
|
||||
{!list && <div>{t('Preparing validator list')}</div>}
|
||||
</>
|
||||
}
|
||||
filter={
|
||||
<Filtering
|
||||
nameFilter={nameFilter}
|
||||
setNameFilter={setNameFilter}
|
||||
/>
|
||||
}
|
||||
header={headerRef.current}
|
||||
legend={
|
||||
<Legend
|
||||
isRelay={!isIntentions && !!(api.query.parasShared || api.query.shared)?.activeValidatorIndices}
|
||||
minCommission={minCommission}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{list?.map(([address, isElected, isFavorite]): React.ReactNode => (
|
||||
<Address
|
||||
address={address}
|
||||
filterName={nameFilter}
|
||||
hasQueries={hasQueries}
|
||||
isElected={isElected}
|
||||
isFavorite={isFavorite}
|
||||
isMain={!isIntentions}
|
||||
isPara={paraValidators[address]}
|
||||
key={address}
|
||||
lastBlock={byAuthor[address]}
|
||||
minCommission={minCommission}
|
||||
nominatedBy={nominatedBy?.[address]}
|
||||
points={eraPoints[address]}
|
||||
recentlyOnline={recentlyOnline?.[address]}
|
||||
toggleFavorite={toggleFavorite}
|
||||
validatorInfo={infoMap?.[address]}
|
||||
/>
|
||||
))}
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(CurrentList);
|
||||
@@ -0,0 +1,118 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { DeriveStakingOverview } from '@pezkuwi/api-derive/types';
|
||||
import type { SortedTargets } from '../types.js';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import SummarySession from '@pezkuwi/app-explorer/SummarySession';
|
||||
import { CardSummary, styled, SummaryBox } from '@pezkuwi/react-components';
|
||||
import { formatNumber } from '@pezkuwi/util';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
nominators?: string[];
|
||||
stakingOverview?: DeriveStakingOverview;
|
||||
targets: SortedTargets;
|
||||
}
|
||||
|
||||
function Summary ({ className = '', stakingOverview, targets: { counterForNominators, inflation: { idealStake, inflation, stakedFraction }, nominators, waitingIds } }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const percent = <span className='percent'>%</span>;
|
||||
|
||||
return (
|
||||
<StyledSummaryBox className={className}>
|
||||
<section>
|
||||
<CardSummary label={t('validators')}>
|
||||
{stakingOverview
|
||||
? <>{formatNumber(stakingOverview.validators.length)} / {formatNumber(stakingOverview.validatorCount)}</>
|
||||
: <span className='--tmp'>999 / 999</span>
|
||||
}
|
||||
</CardSummary>
|
||||
<CardSummary
|
||||
className='media--900'
|
||||
label={t('waiting')}
|
||||
>
|
||||
{waitingIds
|
||||
? formatNumber(waitingIds.length)
|
||||
: <span className='--tmp'>99</span>
|
||||
}
|
||||
</CardSummary>
|
||||
<CardSummary
|
||||
className='media--1000'
|
||||
label={
|
||||
counterForNominators
|
||||
? t('active / nominators')
|
||||
: t('nominators')
|
||||
}
|
||||
>
|
||||
{nominators
|
||||
? (
|
||||
<>
|
||||
{formatNumber(nominators.length)}
|
||||
{counterForNominators && (
|
||||
<> / {formatNumber(counterForNominators)}</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
: <span className='--tmp'>999 / 999</span>
|
||||
}
|
||||
</CardSummary>
|
||||
</section>
|
||||
<section>
|
||||
{(idealStake > 0) && Number.isFinite(idealStake) && (
|
||||
<CardSummary
|
||||
className='media--1400'
|
||||
label={t('ideal staked')}
|
||||
>
|
||||
<>{(idealStake * 100).toFixed(1)}{percent}</>
|
||||
</CardSummary>
|
||||
)}
|
||||
{(stakedFraction > 0) && (
|
||||
<CardSummary
|
||||
className='media--1300'
|
||||
label={t('staked')}
|
||||
>
|
||||
<>{(stakedFraction * 100).toFixed(1)}{percent}</>
|
||||
</CardSummary>
|
||||
)}
|
||||
{(inflation > 0) && Number.isFinite(inflation) && (
|
||||
<CardSummary
|
||||
className='media--1200'
|
||||
label={t('inflation')}
|
||||
>
|
||||
<>{inflation.toFixed(1)}{percent}</>
|
||||
</CardSummary>
|
||||
)}
|
||||
</section>
|
||||
<section>
|
||||
<SummarySession />
|
||||
</section>
|
||||
</StyledSummaryBox>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledSummaryBox = styled(SummaryBox)`
|
||||
.validator--Account-block-icon {
|
||||
display: inline-block;
|
||||
margin-right: 0.75rem;
|
||||
margin-top: -0.25rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.validator--Summary-authors {
|
||||
.validator--Account-block-icon+.validator--Account-block-icon {
|
||||
margin-left: -1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.percent {
|
||||
font-size: var(--font-percent-tiny);
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(Summary);
|
||||
@@ -0,0 +1,118 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { DeriveHeartbeats, DeriveStakingOverview } from '@pezkuwi/api-derive/types';
|
||||
import type { StakerState } from '@pezkuwi/react-hooks/types';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
import type { NominatedByMap, SortedTargets } from '../types.js';
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { Button, ToggleGroup } from '@pezkuwi/react-components';
|
||||
import { useApi, useBlockAuthors, useCall } from '@pezkuwi/react-hooks';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
import ActionsBanner from './ActionsBanner.js';
|
||||
import CurrentList from './CurrentList.js';
|
||||
import Summary from './Summary.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
favorites: string[];
|
||||
hasAccounts: boolean;
|
||||
hasQueries: boolean;
|
||||
minCommission?: BN;
|
||||
nominatedBy?: NominatedByMap;
|
||||
ownStashes?: StakerState[];
|
||||
paraValidators?: Record<string, boolean>;
|
||||
stakingOverview?: DeriveStakingOverview;
|
||||
targets: SortedTargets;
|
||||
toggleFavorite: (address: string) => void;
|
||||
toggleLedger?: () => void;
|
||||
toggleNominatedBy: () => void;
|
||||
}
|
||||
|
||||
const EMPTY_PARA_VALS: Record<string, boolean> = {};
|
||||
const EMPTY_BY_AUTHOR: Record<string, string> = {};
|
||||
const EMPTY_ERA_POINTS: Record<string, string> = {};
|
||||
|
||||
function Overview ({ className = '', favorites, hasAccounts, hasQueries, minCommission, nominatedBy, ownStashes, paraValidators, stakingOverview, targets, toggleFavorite, toggleLedger, toggleNominatedBy }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const { api } = useApi();
|
||||
const { byAuthor, eraPoints } = useBlockAuthors();
|
||||
const [intentIndex, _setIntentIndex] = useState(0);
|
||||
const [typeIndex, setTypeIndex] = useState(1);
|
||||
const recentlyOnline = useCall<DeriveHeartbeats>(api.derive.imOnline?.receivedHeartbeats);
|
||||
|
||||
const setIntentIndex = useCallback(
|
||||
(index: number): void => {
|
||||
index && toggleNominatedBy();
|
||||
_setIntentIndex(index);
|
||||
},
|
||||
[toggleNominatedBy]
|
||||
);
|
||||
|
||||
const filterOptions = useRef([
|
||||
{ text: t('Own validators'), value: 'mine' },
|
||||
{ text: t('All validators'), value: 'all' }
|
||||
]);
|
||||
|
||||
const intentOptions = useRef([
|
||||
{ text: t('Active'), value: 'active' },
|
||||
{ text: t('Waiting'), value: 'waiting' }
|
||||
]);
|
||||
|
||||
const ownStashIds = useMemo(
|
||||
() => ownStashes?.map(({ stashId }) => stashId),
|
||||
[ownStashes]
|
||||
);
|
||||
|
||||
useEffect((): void => {
|
||||
toggleLedger && toggleLedger();
|
||||
}, [toggleLedger]);
|
||||
|
||||
const isOwn = typeIndex === 0;
|
||||
|
||||
return (
|
||||
<div className={`${className} staking--Overview`}>
|
||||
<Summary
|
||||
stakingOverview={stakingOverview}
|
||||
targets={targets}
|
||||
/>
|
||||
{hasAccounts && (ownStashes?.length === 0) && (
|
||||
<ActionsBanner />
|
||||
)}
|
||||
<Button.Group>
|
||||
<ToggleGroup
|
||||
onChange={setTypeIndex}
|
||||
options={filterOptions.current}
|
||||
value={typeIndex}
|
||||
/>
|
||||
<ToggleGroup
|
||||
onChange={setIntentIndex}
|
||||
options={intentOptions.current}
|
||||
value={intentIndex}
|
||||
/>
|
||||
</Button.Group>
|
||||
<CurrentList
|
||||
byAuthor={intentIndex === 0 ? byAuthor : EMPTY_BY_AUTHOR}
|
||||
eraPoints={intentIndex === 0 ? eraPoints : EMPTY_ERA_POINTS}
|
||||
favorites={favorites}
|
||||
hasQueries={hasQueries}
|
||||
isIntentions={intentIndex === 1}
|
||||
isOwn={isOwn}
|
||||
key={intentIndex}
|
||||
minCommission={intentIndex === 0 ? minCommission : undefined}
|
||||
nominatedBy={intentIndex === 1 ? nominatedBy : undefined}
|
||||
ownStashIds={ownStashIds}
|
||||
paraValidators={(intentIndex === 0 && paraValidators) || EMPTY_PARA_VALS}
|
||||
recentlyOnline={intentIndex === 0 ? recentlyOnline : undefined}
|
||||
stakingOverview={stakingOverview}
|
||||
targets={targets}
|
||||
toggleFavorite={toggleFavorite}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Overview);
|
||||
@@ -0,0 +1,7 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
export interface AddressDetails {
|
||||
address: string;
|
||||
points?: number;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
export const MAX_NOMINATIONS = 16;
|
||||
|
||||
export const STORE_FAVS_BASE = 'staking:favorites';
|
||||
@@ -0,0 +1,256 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { DeriveStakingOverview } from '@pezkuwi/api-derive/types';
|
||||
import type { AppProps as Props } from '@pezkuwi/react-components/types';
|
||||
import type { ElectionStatus, ParaValidatorIndex, ValidatorId } from '@pezkuwi/types/interfaces';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { Route, Routes } from 'react-router';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import Pools from '@pezkuwi/app-staking2/Pools';
|
||||
import useOwnPools from '@pezkuwi/app-staking2/Pools/useOwnPools';
|
||||
import { styled, Tabs } from '@pezkuwi/react-components';
|
||||
import { useAccounts, useApi, useAvailableSlashes, useCall, useCallMulti, useFavorites, useOwnStashInfos } from '@pezkuwi/react-hooks';
|
||||
import { isFunction } from '@pezkuwi/util';
|
||||
|
||||
import Actions from './Actions/index.js';
|
||||
import Bags from './Bags/index.js';
|
||||
import Payouts from './Payouts/index.js';
|
||||
import Query from './Query/index.js';
|
||||
import Slashes from './Slashes/index.js';
|
||||
import Targets from './Targets/index.js';
|
||||
import Validators from './Validators/index.js';
|
||||
import { STORE_FAVS_BASE } from './constants.js';
|
||||
import { useTranslation } from './translate.js';
|
||||
import useNominations from './useNominations.js';
|
||||
import useSortedTargets from './useSortedTargets.js';
|
||||
|
||||
const HIDDEN_ACC = ['actions', 'payout'];
|
||||
|
||||
const OPT_MULTI = {
|
||||
defaultValue: [false, undefined, {}] as [boolean, BN | undefined, Record<string, boolean>],
|
||||
transform: ([eraElectionStatus, minValidatorBond, validators, activeValidatorIndices]: [ElectionStatus | null, BN | undefined, ValidatorId[] | null, ParaValidatorIndex[] | null]): [boolean, BN | undefined, Record<string, boolean>] => [
|
||||
!!eraElectionStatus && eraElectionStatus.isOpen,
|
||||
minValidatorBond && !minValidatorBond.isZero()
|
||||
? minValidatorBond
|
||||
: undefined,
|
||||
validators && activeValidatorIndices
|
||||
? activeValidatorIndices.reduce((all, index) => ({ ...all, [validators[index.toNumber()].toString()]: true }), {})
|
||||
: {}
|
||||
]
|
||||
};
|
||||
|
||||
function StakingApp ({ basePath, className = '' }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const { api } = useApi();
|
||||
const { areAccountsLoaded, hasAccounts } = useAccounts();
|
||||
const { pathname } = useLocation();
|
||||
const [withLedger, setWithLedger] = useState(false);
|
||||
const [favorites, toggleFavorite] = useFavorites(STORE_FAVS_BASE);
|
||||
const [loadNominations, setLoadNominations] = useState(false);
|
||||
const nominatedBy = useNominations(loadNominations);
|
||||
const stakingOverview = useCall<DeriveStakingOverview>(api.derive.staking.overview);
|
||||
const [isInElection, minCommission, paraValidators] = useCallMulti<[boolean, BN | undefined, Record<string, boolean>]>([
|
||||
api.query.staking.eraElectionStatus,
|
||||
api.query.staking.minCommission,
|
||||
api.query.session.validators,
|
||||
(api.query.parasShared || api.query.shared)?.activeValidatorIndices
|
||||
], OPT_MULTI);
|
||||
const ownPools = useOwnPools();
|
||||
const ownStashes = useOwnStashInfos();
|
||||
const slashes = useAvailableSlashes();
|
||||
const targets = useSortedTargets(favorites, withLedger);
|
||||
|
||||
const hasQueries = useMemo(
|
||||
() => hasAccounts && !!(api.query.imOnline?.authoredBlocks) && !!(api.query.staking.activeEra),
|
||||
[api, hasAccounts]
|
||||
);
|
||||
|
||||
const hasStashes = useMemo(
|
||||
() => hasAccounts && !!ownStashes && (ownStashes.length !== 0),
|
||||
[hasAccounts, ownStashes]
|
||||
);
|
||||
|
||||
const ownValidators = useMemo(
|
||||
() => (ownStashes || []).filter(({ isStashValidating }) => isStashValidating),
|
||||
[ownStashes]
|
||||
);
|
||||
|
||||
const toggleLedger = useCallback(
|
||||
() => setWithLedger(true),
|
||||
[]
|
||||
);
|
||||
|
||||
const toggleNominatedBy = useCallback(
|
||||
() => setLoadNominations(true),
|
||||
[]
|
||||
);
|
||||
|
||||
const items = useMemo(() => [
|
||||
{
|
||||
isRoot: true,
|
||||
name: 'overview',
|
||||
text: t('Overview')
|
||||
},
|
||||
{
|
||||
name: 'actions',
|
||||
text: t('Accounts')
|
||||
},
|
||||
hasStashes && isFunction(api.query.staking.activeEra) && {
|
||||
name: 'payout',
|
||||
text: t('Payouts')
|
||||
},
|
||||
isFunction(api.query.nominationPools?.minCreateBond) && {
|
||||
name: 'pools',
|
||||
text: t('Pools')
|
||||
},
|
||||
{
|
||||
alias: 'returns',
|
||||
name: 'targets',
|
||||
text: t('Targets')
|
||||
},
|
||||
hasStashes && isFunction((api.query.voterBagsList || api.query.bagsList || api.query.voterList)?.counterForListNodes) && {
|
||||
name: 'bags',
|
||||
text: t('Bags')
|
||||
},
|
||||
{
|
||||
count: slashes.reduce((count, [, unapplied]) => count + unapplied.length, 0),
|
||||
name: 'slashes',
|
||||
text: t('Slashes')
|
||||
},
|
||||
{
|
||||
hasParams: true,
|
||||
name: 'query',
|
||||
text: t('Validator stats')
|
||||
}
|
||||
].filter((q): q is { name: string; text: string } => !!q), [api, hasStashes, slashes, t]);
|
||||
|
||||
return (
|
||||
<StyledMain className={`${className} staking--App`}>
|
||||
<Tabs
|
||||
basePath={basePath}
|
||||
hidden={
|
||||
areAccountsLoaded && !hasAccounts
|
||||
? HIDDEN_ACC
|
||||
: undefined
|
||||
}
|
||||
items={items}
|
||||
/>
|
||||
<Routes>
|
||||
<Route path={basePath}>
|
||||
<Route
|
||||
element={
|
||||
<Bags ownStashes={ownStashes} />
|
||||
}
|
||||
path='bags'
|
||||
/>
|
||||
<Route
|
||||
element={
|
||||
<Payouts
|
||||
historyDepth={targets.historyDepth}
|
||||
isInElection={isInElection}
|
||||
ownPools={ownPools}
|
||||
ownValidators={ownValidators}
|
||||
/>
|
||||
}
|
||||
path='payout'
|
||||
/>
|
||||
<Route
|
||||
element={
|
||||
<Pools ownPools={ownPools} />
|
||||
}
|
||||
path='pools'
|
||||
/>
|
||||
<Route
|
||||
element={
|
||||
<Query basePath={basePath} />
|
||||
}
|
||||
path='query/:value?'
|
||||
/>
|
||||
<Route
|
||||
element={
|
||||
<Slashes
|
||||
ownStashes={ownStashes}
|
||||
slashes={slashes}
|
||||
/>
|
||||
}
|
||||
path='slashes'
|
||||
/>
|
||||
<Route
|
||||
element={
|
||||
<Targets
|
||||
isInElection={isInElection}
|
||||
nominatedBy={nominatedBy}
|
||||
ownStashes={ownStashes}
|
||||
targets={targets}
|
||||
toggleFavorite={toggleFavorite}
|
||||
toggleLedger={toggleLedger}
|
||||
toggleNominatedBy={toggleNominatedBy}
|
||||
/>
|
||||
}
|
||||
path='targets'
|
||||
/>
|
||||
</Route>
|
||||
</Routes>
|
||||
<Actions
|
||||
className={pathname === `${basePath}/actions` ? '' : '--hidden'}
|
||||
isInElection={isInElection}
|
||||
minCommission={minCommission}
|
||||
ownPools={ownPools}
|
||||
ownStashes={ownStashes}
|
||||
targets={targets}
|
||||
/>
|
||||
<Validators
|
||||
className={basePath === pathname ? '' : '--hidden'}
|
||||
favorites={favorites}
|
||||
hasAccounts={hasAccounts}
|
||||
hasQueries={hasQueries}
|
||||
minCommission={minCommission}
|
||||
nominatedBy={nominatedBy}
|
||||
ownStashes={ownStashes}
|
||||
paraValidators={paraValidators}
|
||||
stakingOverview={stakingOverview}
|
||||
targets={targets}
|
||||
toggleFavorite={toggleFavorite}
|
||||
toggleNominatedBy={toggleNominatedBy}
|
||||
/>
|
||||
</StyledMain>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledMain = styled.main`
|
||||
.staking--Chart {
|
||||
margin-top: 1.5rem;
|
||||
|
||||
h1 {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.ui--Spinner {
|
||||
margin: 2.5rem auto;
|
||||
}
|
||||
}
|
||||
|
||||
.staking--optionsBar {
|
||||
margin: 0.5rem 0 1rem;
|
||||
text-align: center;
|
||||
white-space: normal;
|
||||
|
||||
.staking--buttonToggle {
|
||||
display: inline-block;
|
||||
margin-right: 1rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.ui--Expander.stakeOver {
|
||||
.ui--Expander-summary {
|
||||
color: var(--color-error);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(StakingApp);
|
||||
@@ -0,0 +1,8 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { useTranslation as useTranslationBase } from 'react-i18next';
|
||||
|
||||
export function useTranslation (): { t: (key: string, options?: { replace: Record<string, unknown> }) => string } {
|
||||
return useTranslationBase('app-staking');
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { Inflation } from '@pezkuwi/react-hooks/types';
|
||||
import type { AccountId, Balance, BlockNumber, EraIndex, Hash, SessionIndex, ValidatorPrefs, ValidatorPrefsTo196 } from '@pezkuwi/types/interfaces';
|
||||
import type { SpStakingExposurePage, SpStakingPagedExposureMetadata } from '@pezkuwi/types/lookup';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
|
||||
export type Nominators = Record<string, string[]>;
|
||||
|
||||
export type AccountFilter = 'all' | 'controller' | 'session' | 'stash' | 'unbonded';
|
||||
|
||||
export type ValidatorFilter = 'all' | 'hasNominators' | 'noNominators' | 'hasWarnings' | 'noWarnings' | 'iNominated' | 'nextSet';
|
||||
|
||||
export interface NominatedBy {
|
||||
index: number;
|
||||
nominatorId: string;
|
||||
submittedIn: EraIndex;
|
||||
}
|
||||
|
||||
export type NominatedByMap = Record<string, NominatedBy[]>;
|
||||
|
||||
export interface Slash {
|
||||
accountId: AccountId;
|
||||
amount: Balance;
|
||||
}
|
||||
|
||||
export interface SessionRewards {
|
||||
blockHash: Hash;
|
||||
blockNumber: BlockNumber;
|
||||
isEventsEmpty: boolean;
|
||||
parentHash: Hash;
|
||||
reward: Balance;
|
||||
sessionIndex: SessionIndex;
|
||||
slashes: Slash[];
|
||||
treasury: Balance;
|
||||
}
|
||||
|
||||
interface ValidatorInfoRank {
|
||||
rankBondOther: number;
|
||||
rankBondOwn: number;
|
||||
rankBondTotal: number;
|
||||
rankNumNominators: number;
|
||||
rankOverall: number;
|
||||
rankReward: number;
|
||||
}
|
||||
|
||||
export interface ValidatorInfo extends ValidatorInfoRank {
|
||||
accountId: AccountId;
|
||||
bondOther: BN;
|
||||
bondOwn: BN;
|
||||
bondShare: number;
|
||||
bondTotal: BN;
|
||||
commissionPer: number;
|
||||
exposurePaged: SpStakingExposurePage;
|
||||
exposureMeta: SpStakingPagedExposureMetadata
|
||||
isActive: boolean;
|
||||
isBlocking: boolean;
|
||||
isElected: boolean;
|
||||
isFavorite: boolean;
|
||||
isNominating: boolean;
|
||||
key: string;
|
||||
knownLength: BN;
|
||||
lastPayout?: BN;
|
||||
minNominated: BN;
|
||||
numNominators: number;
|
||||
numRecentPayouts: number;
|
||||
skipRewards: boolean;
|
||||
stakedReturn: number;
|
||||
stakedReturnCmp: number;
|
||||
validatorPrefs?: ValidatorPrefs | ValidatorPrefsTo196;
|
||||
withReturns?: boolean;
|
||||
}
|
||||
|
||||
export type TargetSortBy = keyof ValidatorInfoRank;
|
||||
|
||||
export interface SortedTargets {
|
||||
avgStaked?: BN;
|
||||
counterForNominators?: BN;
|
||||
counterForValidators?: BN;
|
||||
electedIds?: string[];
|
||||
historyDepth?: BN;
|
||||
inflation: Inflation;
|
||||
lastEra?: BN;
|
||||
lowStaked?: BN;
|
||||
medianComm: number;
|
||||
maxNominatorsCount?: BN;
|
||||
maxValidatorsCount?: BN;
|
||||
minNominated: BN;
|
||||
minNominatorBond?: BN;
|
||||
minValidatorBond?: BN;
|
||||
nominators?: string[];
|
||||
nominateIds?: string[];
|
||||
totalStaked?: BN;
|
||||
totalIssuance?: BN;
|
||||
validators?: ValidatorInfo[];
|
||||
validatorIds?: string[];
|
||||
waitingIds?: string[];
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { DeriveHasIdentity } from '@pezkuwi/api-derive/types';
|
||||
|
||||
import { createNamedHook, useApi, useCall } from '@pezkuwi/react-hooks';
|
||||
|
||||
type Result = Record<string, DeriveHasIdentity>;
|
||||
|
||||
const OPT_CALL = {
|
||||
transform: ([[validatorIds], hasIdentities]: [[string[]], DeriveHasIdentity[]]): Record<string, DeriveHasIdentity> => {
|
||||
const result: Record<string, DeriveHasIdentity> = {};
|
||||
|
||||
for (let i = 0; i < validatorIds.length; i++) {
|
||||
result[validatorIds[i]] = hasIdentities[i];
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
withParamsTransform: true
|
||||
};
|
||||
|
||||
function useIdentitiesImpl (validatorIds: string[] = []): Result | undefined {
|
||||
const { api } = useApi();
|
||||
const allIdentity = useCall<Result>(api.derive.accounts.hasIdentityMulti, [validatorIds], OPT_CALL);
|
||||
|
||||
return allIdentity;
|
||||
}
|
||||
|
||||
export default createNamedHook('useIdentities', useIdentitiesImpl);
|
||||
@@ -0,0 +1,47 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { Option, StorageKey } from '@pezkuwi/types';
|
||||
import type { Nominations } from '@pezkuwi/types/interfaces';
|
||||
import type { NominatedByMap } from './types.js';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { createNamedHook, useApi, useCall } from '@pezkuwi/react-hooks';
|
||||
|
||||
function extractNominators (nominations: [StorageKey, Option<Nominations>][]): NominatedByMap {
|
||||
const mapped: NominatedByMap = {};
|
||||
|
||||
for (let i = 0, nomCount = nominations.length; i < nomCount; i++) {
|
||||
const [key, optNoms] = nominations[i];
|
||||
|
||||
if (optNoms.isSome && key.args.length) {
|
||||
const nominatorId = key.args[0].toString();
|
||||
const { submittedIn, targets } = optNoms.unwrap();
|
||||
|
||||
for (let j = 0, tarCount = targets.length; j < tarCount; j++) {
|
||||
const validatorId = targets[j].toString();
|
||||
|
||||
if (!mapped[validatorId]) {
|
||||
mapped[validatorId] = [];
|
||||
}
|
||||
|
||||
mapped[validatorId].push({ index: j + 1, nominatorId, submittedIn });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mapped;
|
||||
}
|
||||
|
||||
function useNominationsImpl (isActive = true): NominatedByMap | undefined {
|
||||
const { api } = useApi();
|
||||
const nominators = useCall<[StorageKey, Option<Nominations>][]>(isActive && api.query.staking.nominators.entries);
|
||||
|
||||
return useMemo(
|
||||
() => nominators && extractNominators(nominators),
|
||||
[nominators]
|
||||
);
|
||||
}
|
||||
|
||||
export default createNamedHook('useNominations', useNominationsImpl);
|
||||
@@ -0,0 +1,339 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { ApiPromise } from '@pezkuwi/api';
|
||||
import type { DeriveSessionInfo, DeriveStakingElected, DeriveStakingWaiting } from '@pezkuwi/api-derive/types';
|
||||
import type { Inflation } from '@pezkuwi/react-hooks/types';
|
||||
import type { Option, u32, Vec } from '@pezkuwi/types';
|
||||
import type { PalletStakingStakingLedger } from '@pezkuwi/types/lookup';
|
||||
import type { SortedTargets, TargetSortBy, ValidatorInfo } from './types.js';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { createNamedHook, useAccounts, useApi, useCall, useCallMulti, useInflation } from '@pezkuwi/react-hooks';
|
||||
import { arrayFlatten, BN, BN_HUNDRED, BN_MAX_INTEGER, BN_ONE, BN_ZERO } from '@pezkuwi/util';
|
||||
|
||||
interface LastEra {
|
||||
activeEra: BN;
|
||||
eraLength: BN;
|
||||
lastEra: BN;
|
||||
sessionLength: BN;
|
||||
}
|
||||
|
||||
interface MultiResult {
|
||||
counterForNominators?: BN;
|
||||
counterForValidators?: BN;
|
||||
historyDepth?: BN;
|
||||
maxNominatorsCount?: BN;
|
||||
maxValidatorsCount?: BN;
|
||||
minNominatorBond?: BN;
|
||||
minValidatorBond?: BN;
|
||||
totalIssuance?: BN;
|
||||
}
|
||||
|
||||
interface OldLedger {
|
||||
claimedRewards: Vec<u32>;
|
||||
}
|
||||
|
||||
const EMPTY_PARTIAL: Partial<SortedTargets> = {};
|
||||
const DEFAULT_FLAGS_ELECTED = { withController: true, withExposure: true, withExposureMeta: true, withPrefs: true };
|
||||
const DEFAULT_FLAGS_WAITING = { withController: true, withPrefs: true };
|
||||
|
||||
const OPT_ERA = {
|
||||
transform: ({ activeEra, eraLength, sessionLength }: DeriveSessionInfo): LastEra => ({
|
||||
activeEra,
|
||||
eraLength,
|
||||
lastEra: activeEra.isZero()
|
||||
? BN_ZERO
|
||||
: activeEra.sub(BN_ONE),
|
||||
sessionLength
|
||||
})
|
||||
};
|
||||
|
||||
const OPT_MULTI = {
|
||||
defaultValue: {},
|
||||
transform: ([historyDepth, counterForNominators, counterForValidators, optMaxNominatorsCount, optMaxValidatorsCount, minNominatorBond, minValidatorBond, totalIssuance]: [BN, BN?, BN?, Option<u32>?, Option<u32>?, BN?, BN?, BN?]): MultiResult => ({
|
||||
counterForNominators,
|
||||
counterForValidators,
|
||||
historyDepth,
|
||||
maxNominatorsCount: optMaxNominatorsCount && optMaxNominatorsCount.isSome
|
||||
? optMaxNominatorsCount.unwrap()
|
||||
: undefined,
|
||||
maxValidatorsCount: optMaxValidatorsCount && optMaxValidatorsCount.isSome
|
||||
? optMaxValidatorsCount.unwrap()
|
||||
: undefined,
|
||||
minNominatorBond,
|
||||
minValidatorBond,
|
||||
totalIssuance
|
||||
})
|
||||
};
|
||||
|
||||
function getLegacyRewards (ledger: PalletStakingStakingLedger, claimedRewardsEras: Vec<u32>): u32[] {
|
||||
const legacyRewards = ledger.legacyClaimedRewards || (ledger as unknown as OldLedger).claimedRewards || [];
|
||||
|
||||
return legacyRewards.concat(claimedRewardsEras.toArray());
|
||||
}
|
||||
|
||||
function mapIndex (mapBy: TargetSortBy): (info: ValidatorInfo, index: number) => ValidatorInfo {
|
||||
return (info, index): ValidatorInfo => {
|
||||
info[mapBy] = index + 1;
|
||||
|
||||
return info;
|
||||
};
|
||||
}
|
||||
|
||||
function isWaitingDerive (derive: DeriveStakingElected | DeriveStakingWaiting): derive is DeriveStakingWaiting {
|
||||
return !(derive as DeriveStakingElected).nextElected;
|
||||
}
|
||||
|
||||
function sortValidators (list: ValidatorInfo[]): ValidatorInfo[] {
|
||||
const existing: string[] = [];
|
||||
|
||||
return list
|
||||
.filter(({ accountId }): boolean => {
|
||||
const key = accountId.toString();
|
||||
|
||||
if (existing.includes(key)) {
|
||||
return false;
|
||||
} else {
|
||||
existing.push(key);
|
||||
|
||||
return true;
|
||||
}
|
||||
})
|
||||
// .sort((a, b) => b.commissionPer - a.commissionPer)
|
||||
// .map(mapIndex('rankComm'))
|
||||
.sort((a, b) => b.bondOther.cmp(a.bondOther))
|
||||
.map(mapIndex('rankBondOther'))
|
||||
.sort((a, b) => b.bondOwn.cmp(a.bondOwn))
|
||||
.map(mapIndex('rankBondOwn'))
|
||||
.sort((a, b) => b.bondTotal.cmp(a.bondTotal))
|
||||
.map(mapIndex('rankBondTotal'))
|
||||
// .sort((a, b) => b.validatorPayment.cmp(a.validatorPayment))
|
||||
// .map(mapIndex('rankPayment'))
|
||||
.sort((a, b) => a.stakedReturnCmp - b.stakedReturnCmp)
|
||||
.map(mapIndex('rankReward'))
|
||||
// .sort((a, b) => b.numNominators - a.numNominators)
|
||||
// .map(mapIndex('rankNumNominators'))
|
||||
.sort((a, b) =>
|
||||
(b.stakedReturnCmp - a.stakedReturnCmp) ||
|
||||
(a.commissionPer - b.commissionPer) ||
|
||||
(b.rankBondTotal - a.rankBondTotal)
|
||||
)
|
||||
.map(mapIndex('rankOverall'))
|
||||
.sort((a, b) =>
|
||||
a.isFavorite === b.isFavorite
|
||||
? 0
|
||||
: (a.isFavorite ? -1 : 1)
|
||||
);
|
||||
}
|
||||
|
||||
function extractSingle (api: ApiPromise, allAccounts: string[], derive: DeriveStakingElected | DeriveStakingWaiting, favorites: string[], { activeEra, eraLength, lastEra, sessionLength }: LastEra, historyDepth?: BN, withReturns?: boolean): [ValidatorInfo[], Record<string, BN>] {
|
||||
const nominators: Record<string, BN> = {};
|
||||
const emptyExposure = api.createType('SpStakingExposurePage');
|
||||
const emptyExposureMeta = api.createType('SpStakingPagedExposureMetadata');
|
||||
const earliestEra = historyDepth && lastEra.sub(historyDepth).iadd(BN_ONE);
|
||||
const list = new Array<ValidatorInfo>(derive.info.length);
|
||||
|
||||
for (let i = 0; i < derive.info.length; i++) {
|
||||
const { accountId, claimedRewardsEras, exposureMeta, exposurePaged, stakingLedger, validatorPrefs } = derive.info[i];
|
||||
const exp = exposurePaged.isSome && exposurePaged.unwrap();
|
||||
const expMeta = exposureMeta.isSome && exposureMeta.unwrap();
|
||||
// some overrides (e.g. Darwinia Crab) does not have the own/total field in Exposure
|
||||
let [bondOwn, bondTotal] = exp && expMeta
|
||||
? [expMeta.own.unwrap(), expMeta.total.unwrap()]
|
||||
: [BN_ZERO, BN_ZERO];
|
||||
|
||||
const skipRewards = bondTotal.isZero();
|
||||
|
||||
if (skipRewards) {
|
||||
bondTotal = bondOwn = stakingLedger.total?.unwrap() || BN_ZERO;
|
||||
}
|
||||
|
||||
// some overrides (e.g. Darwinia Crab) does not have the value field in IndividualExposure
|
||||
const minNominated = ((exp && exp.others) || []).reduce((min: BN, { value = api.createType('Compact<Balance>') }): BN => {
|
||||
const actual = value.unwrap();
|
||||
|
||||
return min.isZero() || actual.lt(min)
|
||||
? actual
|
||||
: min;
|
||||
}, BN_ZERO);
|
||||
|
||||
const key = accountId.toString();
|
||||
const rewards = getLegacyRewards(stakingLedger, claimedRewardsEras);
|
||||
|
||||
const lastEraPayout = !lastEra.isZero()
|
||||
? rewards[rewards.length - 1]
|
||||
: undefined;
|
||||
|
||||
list[i] = {
|
||||
accountId,
|
||||
bondOther: bondTotal.sub(bondOwn),
|
||||
bondOwn,
|
||||
bondShare: 0,
|
||||
bondTotal,
|
||||
commissionPer: validatorPrefs.commission.unwrap().toNumber() / 10_000_000,
|
||||
exposureMeta: expMeta || emptyExposureMeta,
|
||||
exposurePaged: exp || emptyExposure,
|
||||
isActive: !skipRewards,
|
||||
isBlocking: !!(validatorPrefs.blocked && validatorPrefs.blocked.isTrue),
|
||||
isElected: !isWaitingDerive(derive) && derive.nextElected.some((e) => e.eq(accountId)),
|
||||
isFavorite: favorites.includes(key),
|
||||
isNominating: ((exp && exp.others) || []).reduce((isNominating, indv): boolean => {
|
||||
const nominator = indv.who.toString();
|
||||
|
||||
nominators[nominator] = (nominators[nominator] || BN_ZERO).add(indv.value?.toBn() || BN_ZERO);
|
||||
|
||||
return isNominating || allAccounts.includes(nominator);
|
||||
}, allAccounts.includes(key)),
|
||||
key,
|
||||
knownLength: activeEra.sub(rewards[0] || activeEra),
|
||||
// only use if it is more recent than historyDepth
|
||||
lastPayout: earliestEra && lastEraPayout && lastEraPayout.gt(earliestEra) && !sessionLength.eq(BN_ONE)
|
||||
? lastEra.sub(lastEraPayout).mul(eraLength)
|
||||
: undefined,
|
||||
minNominated,
|
||||
numNominators: ((exp && exp.others) || []).length,
|
||||
numRecentPayouts: earliestEra
|
||||
? rewards.filter((era) => era.gte(earliestEra)).length
|
||||
: 0,
|
||||
rankBondOther: 0,
|
||||
rankBondOwn: 0,
|
||||
rankBondTotal: 0,
|
||||
rankNumNominators: 0,
|
||||
rankOverall: 0,
|
||||
rankReward: 0,
|
||||
skipRewards,
|
||||
stakedReturn: 0,
|
||||
stakedReturnCmp: 0,
|
||||
validatorPrefs,
|
||||
withReturns
|
||||
};
|
||||
}
|
||||
|
||||
return [list, nominators];
|
||||
}
|
||||
|
||||
function addReturns (inflation: Inflation, baseInfo: Partial<SortedTargets>): Partial<SortedTargets> {
|
||||
const avgStaked = baseInfo.avgStaked;
|
||||
const validators = baseInfo.validators;
|
||||
|
||||
if (!validators) {
|
||||
return baseInfo;
|
||||
}
|
||||
|
||||
avgStaked && !avgStaked.isZero() && validators.forEach((v): void => {
|
||||
if (!v.skipRewards && v.withReturns) {
|
||||
const adjusted = avgStaked.mul(BN_HUNDRED).imuln(inflation.stakedReturn).div(v.bondTotal);
|
||||
|
||||
// in some cases, we may have overflows... protect against those
|
||||
v.stakedReturn = (adjusted.gt(BN_MAX_INTEGER) ? BN_MAX_INTEGER : adjusted).toNumber() / BN_HUNDRED.toNumber();
|
||||
v.stakedReturnCmp = v.stakedReturn * (100 - v.commissionPer) / 100;
|
||||
}
|
||||
});
|
||||
|
||||
return { ...baseInfo, validators: sortValidators(validators) };
|
||||
}
|
||||
|
||||
function extractBaseInfo (api: ApiPromise, allAccounts: string[], electedDerive: DeriveStakingElected, waitingDerive: DeriveStakingWaiting, favorites: string[], totalIssuance: BN, lastEraInfo: LastEra, historyDepth?: BN): Partial<SortedTargets> {
|
||||
const [elected, nominators] = extractSingle(api, allAccounts, electedDerive, favorites, lastEraInfo, historyDepth, true);
|
||||
const [waiting] = extractSingle(api, allAccounts, waitingDerive, favorites, lastEraInfo);
|
||||
const activeTotals = elected
|
||||
.filter(({ isActive }) => isActive)
|
||||
.map(({ bondTotal }) => bondTotal)
|
||||
.sort((a, b) => a.cmp(b));
|
||||
const totalStaked = activeTotals.reduce((total: BN, value) => total.iadd(value), new BN(0));
|
||||
const avgStaked = totalStaked.divn(activeTotals.length);
|
||||
|
||||
// all validators, calc median commission
|
||||
const minNominated = Object.values(nominators).reduce((min: BN, value) => {
|
||||
return min.isZero() || value.lt(min)
|
||||
? value
|
||||
: min;
|
||||
}, BN_ZERO);
|
||||
const validators = arrayFlatten([elected, waiting]);
|
||||
const commValues = validators.map(({ commissionPer }) => commissionPer).sort((a, b) => a - b);
|
||||
const midIndex = Math.floor(commValues.length / 2);
|
||||
const medianComm = commValues.length
|
||||
? commValues.length % 2
|
||||
? commValues[midIndex]
|
||||
: (commValues[midIndex - 1] + commValues[midIndex]) / 2
|
||||
: 0;
|
||||
|
||||
// ids
|
||||
const waitingIds = waiting.map(({ key }) => key);
|
||||
const validatorIds = arrayFlatten([
|
||||
elected.map(({ key }) => key),
|
||||
waitingIds
|
||||
]);
|
||||
const nominateIds = arrayFlatten([
|
||||
elected.filter(({ isBlocking }) => !isBlocking).map(({ key }) => key),
|
||||
waiting.filter(({ isBlocking }) => !isBlocking).map(({ key }) => key)
|
||||
]);
|
||||
|
||||
return {
|
||||
avgStaked,
|
||||
lastEra: lastEraInfo.lastEra,
|
||||
lowStaked: activeTotals[0] || BN_ZERO,
|
||||
medianComm,
|
||||
minNominated,
|
||||
nominateIds,
|
||||
nominators: Object.keys(nominators),
|
||||
totalIssuance,
|
||||
totalStaked,
|
||||
validatorIds,
|
||||
validators,
|
||||
waitingIds
|
||||
};
|
||||
}
|
||||
|
||||
function useSortedTargetsImpl (favorites: string[], withLedger: boolean, apiOverride?: ApiPromise): SortedTargets {
|
||||
const { api: connectedApi } = useApi();
|
||||
const api = useMemo(() => apiOverride ?? connectedApi, [apiOverride, connectedApi]);
|
||||
const { allAccounts } = useAccounts();
|
||||
const { counterForNominators, counterForValidators, historyDepth, maxNominatorsCount, maxValidatorsCount, minNominatorBond, minValidatorBond, totalIssuance } = useCallMulti<MultiResult>([
|
||||
api.query.staking.historyDepth,
|
||||
api.query.staking.counterForNominators,
|
||||
api.query.staking.counterForValidators,
|
||||
api.query.staking.maxNominatorsCount,
|
||||
api.query.staking.maxValidatorsCount,
|
||||
api.query.staking.minNominatorBond,
|
||||
api.query.staking.minValidatorBond,
|
||||
api.query.balances?.totalIssuance
|
||||
], OPT_MULTI);
|
||||
const electedInfo = useCall<DeriveStakingElected>(api.derive.staking.electedInfo, [{ ...DEFAULT_FLAGS_ELECTED, withClaimedRewardsEras: withLedger, withLedger }]);
|
||||
const waitingInfo = useCall<DeriveStakingWaiting>(api.derive.staking.waitingInfo, [{ ...DEFAULT_FLAGS_WAITING, withClaimedRewardsEras: withLedger, withLedger }]);
|
||||
const lastEraInfo = useCall<LastEra>(api.derive.session.info, undefined, OPT_ERA);
|
||||
|
||||
const baseInfo = useMemo(
|
||||
() => electedInfo && lastEraInfo && totalIssuance && waitingInfo
|
||||
? extractBaseInfo(api, allAccounts, electedInfo, waitingInfo, favorites, totalIssuance, lastEraInfo, api.consts.staking.historyDepth || historyDepth)
|
||||
: EMPTY_PARTIAL,
|
||||
[api, allAccounts, electedInfo, favorites, historyDepth, lastEraInfo, totalIssuance, waitingInfo]
|
||||
);
|
||||
|
||||
const inflation = useInflation(baseInfo?.totalStaked);
|
||||
|
||||
return useMemo(
|
||||
(): SortedTargets => ({
|
||||
counterForNominators,
|
||||
counterForValidators,
|
||||
historyDepth: api.consts.staking.historyDepth || historyDepth,
|
||||
inflation,
|
||||
maxNominatorsCount,
|
||||
maxValidatorsCount,
|
||||
medianComm: 0,
|
||||
minNominated: BN_ZERO,
|
||||
minNominatorBond,
|
||||
minValidatorBond,
|
||||
...(
|
||||
inflation?.stakedReturn
|
||||
? addReturns(inflation, baseInfo)
|
||||
: baseInfo
|
||||
)
|
||||
}),
|
||||
[api, baseInfo, counterForNominators, counterForValidators, historyDepth, inflation, maxNominatorsCount, maxValidatorsCount, minNominatorBond, minValidatorBond]
|
||||
);
|
||||
}
|
||||
|
||||
export default createNamedHook('useSortedTargets', useSortedTargetsImpl);
|
||||
Reference in New Issue
Block a user