mirror of
https://github.com/pezkuwichain/pezkuwi-apps.git
synced 2026-06-13 01:11:11 +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,11 @@
|
||||
# @pezkuwi/app-staking-async
|
||||
|
||||
With Asset Hub migration, the workflow of the staking system will change. The "user interactions" (nominate, bond, etc) are by and large the same, yet some details outlined below will change:
|
||||
|
||||
On the relay chain, the session pallet will rotate session (aka. epochs) at a fixed rate as it did before. It will send these to AH in the form of a SessionReport message. Possibly, it will also send messages about offences to AH so that they can be applied and actually slash staker balances in AH.
|
||||
|
||||
`pallet-session` on the relay chain will only interact with `pallet-staking-async-ah-client`. `ah-client` could at any point, if it has one, return a validator set to session to be used for the next session.
|
||||
|
||||
In any session change in pallet-session where a new validator set is activated, that SessionReport will contain two pieces of information:
|
||||
|
||||
More Info can be found here - https://hackmd.io/7PiBrGxxRG2ib-WRZYJZhQ
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"bugs": "https://github.com/pezkuwichain/pezkuwi-apps/issues",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"homepage": "https://github.com/pezkuwichain/pezkuwi-apps/tree/master/packages/page-staking-async#readme",
|
||||
"license": "Apache-2.0",
|
||||
"name": "@pezkuwi/app-staking-async",
|
||||
"private": true,
|
||||
"repository": {
|
||||
"directory": "packages/page-staking-async",
|
||||
"type": "git",
|
||||
"url": "https://github.com/pezkuwichain/pezkuwi-apps.git"
|
||||
},
|
||||
"sideEffects": false,
|
||||
"type": "module",
|
||||
"version": "0.168.2-4-x",
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
"react-dom": "*",
|
||||
"react-is": "*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking-async 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,134 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking-async 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 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-async 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-async 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-async 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-async 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-async 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,147 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking-async 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 { 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();
|
||||
|
||||
// 1. Fetch session info first. It's the primary dependency.
|
||||
const sessionInfo = useCall<DeriveSessionIndexes>(api.query.staking && api.derive.session?.indexes);
|
||||
|
||||
// 2. CORRECTED: Fetch eraExposure only when sessionInfo.activeEra is available.
|
||||
// Removed the broken check for 'erasStakers'.
|
||||
const eraExposure = useCall<DeriveEraExposure>(sessionInfo && api.derive.staking.eraExposure, [sessionInfo?.activeEra]);
|
||||
|
||||
// 3. The useInactives hook is self-contained and fetches its own data.
|
||||
const { nomsActive, nomsAtRisk, nomsInactive, nomsOver, nomsWaiting } = useInactives(stashId, nominating);
|
||||
|
||||
const [renActive, renAtRisk, renInactive, renOver, renWaiting] = useMemo(
|
||||
() => [
|
||||
renderNominators(stashId, nomsActive, eraExposure),
|
||||
// eraExposure is not needed for at-risk, as it's not about rewards
|
||||
renderNominators(stashId, nomsAtRisk),
|
||||
renderNominators(stashId, nomsInactive),
|
||||
renderNominators(stashId, nomsOver),
|
||||
renderNominators(stashId, nomsWaiting)
|
||||
],
|
||||
[eraExposure, nomsActive, nomsAtRisk, nomsInactive, nomsOver, nomsWaiting, stashId]
|
||||
);
|
||||
|
||||
const isLoading = useMemo(() =>
|
||||
!eraExposure || !nominating || nomsActive === undefined,
|
||||
[eraExposure, nominating, nomsActive]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Spinner
|
||||
label='Checking validators'
|
||||
variant='app'
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{renOver && (
|
||||
<ExpanderScroll
|
||||
className='stakeOver'
|
||||
renderChildren={renOver[1]}
|
||||
summary={t('Oversubscribed nominations ({{count}})', { replace: { count: renOver[0] } })}
|
||||
/>
|
||||
)}
|
||||
{renActive && (
|
||||
<ExpanderScroll
|
||||
renderChildren={renActive[1]}
|
||||
summary={t('Active nominations ({{count}})', { replace: { count: renActive[0] } })}
|
||||
/>
|
||||
)}
|
||||
{renInactive && (
|
||||
<ExpanderScroll
|
||||
renderChildren={renInactive[1]}
|
||||
summary={t('Inactive nominations ({{count}})', { replace: { count: renInactive[0] } })}
|
||||
/>
|
||||
)}
|
||||
{renAtRisk && (
|
||||
<ExpanderScroll
|
||||
renderChildren={renAtRisk[1]}
|
||||
summary={t('Renomination required ({{count}})', { replace: { count: renAtRisk[0] } })}
|
||||
/>
|
||||
)}
|
||||
{renWaiting && (
|
||||
<ExpanderScroll
|
||||
renderChildren={renWaiting[1]}
|
||||
summary={t('Waiting nominations ({{count}})', { replace: { count: renWaiting[0] } })}
|
||||
/>
|
||||
)}
|
||||
{nomsActive && nomsInactive && (nomsActive.length === 0) && (nomsInactive.length !== 0) && (
|
||||
<MarkWarning content={t('This could mean your nomination has not been applied to any validator in the active set by the election algorithm or it has been applied against a validator who is either oversubscribed or chilled.')} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(ListNominees);
|
||||
@@ -0,0 +1,71 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking-async authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { SortedTargets } from '@pezkuwi/app-staking/types';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
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-async 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-async 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-async 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-async authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { SessionInfo } from '../partials/types.js';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { Modal, TxButton } from '@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-async 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-async 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-async 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,394 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking-async 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 { SortedTargets } from '@pezkuwi/app-staking/types';
|
||||
import type { StakerState } from '@pezkuwi/react-hooks/types';
|
||||
import type { PalletStakingUnappliedSlash } from '@pezkuwi/types/lookup';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
import type { Slash } from '../types.js';
|
||||
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
|
||||
import { AddressInfo, AddressMini, AddressSmall, Badge, Button, Menu, Popup, StakingBonded, StakingRedeemable, StakingUnbonding, styled, TxButton } from '@pezkuwi/react-components';
|
||||
import { useApi, useCall, useQueue, useStakingAsyncApis, 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, isDisabled, minCommission, targets }: Props): React.ReactElement<Props> {
|
||||
const { controllerId, destination, hexSessionIdNext, hexSessionIdQueue, isLoading, isOwnController, isOwnStash, isStashNominating, isStashValidating, nominating, sessionIds, stakingLedger, stashId } = useMemo(() => info, [info]);
|
||||
const { t } = useTranslation();
|
||||
const { api } = useApi();
|
||||
const { isRelayChain } = useStakingAsyncApis();
|
||||
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}
|
||||
stakingInfo={info}
|
||||
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)
|
||||
? (!isRelayChain &&
|
||||
<TxButton
|
||||
accountId={controllerId}
|
||||
icon='stop'
|
||||
isDisabled={!isOwnController || isDisabled}
|
||||
key='stop'
|
||||
label={t('Stop')}
|
||||
tx={api.tx.staking.chill}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<Button.Group>
|
||||
{(!sessionIds.length || hexSessionIdNext === '0x')
|
||||
? (
|
||||
isRelayChain &&
|
||||
<Button
|
||||
icon='sign-in-alt'
|
||||
isDisabled={!isOwnController || isDisabled}
|
||||
key='set'
|
||||
label={t('Session Key')}
|
||||
onClick={toggleSetSession}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
!isRelayChain &&
|
||||
<Button
|
||||
icon='certificate'
|
||||
isDisabled={!isOwnController || isDisabled || !hasBonded}
|
||||
key='validate'
|
||||
label={t('Validate')}
|
||||
onClick={toggleValidate}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{!isRelayChain &&
|
||||
<Button
|
||||
icon='hand-paper'
|
||||
isDisabled={!isOwnController || isDisabled || !hasBonded}
|
||||
key='nominate'
|
||||
label={t('Nominate')}
|
||||
onClick={toggleNominate}
|
||||
/>
|
||||
}
|
||||
</Button.Group>
|
||||
)
|
||||
}
|
||||
{isRelayChain
|
||||
? (
|
||||
!isStashNominating &&
|
||||
<Popup
|
||||
isDisabled={isDisabled}
|
||||
key='settings'
|
||||
value={
|
||||
<Menu>
|
||||
<Menu.Item
|
||||
isDisabled={!isOwnController}
|
||||
label={t('Change session keys')}
|
||||
onClick={toggleSetSession}
|
||||
/>
|
||||
<Menu.Item
|
||||
label={t('Inject session keys (advanced)')}
|
||||
onClick={toggleInject}
|
||||
/>
|
||||
</Menu>
|
||||
}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<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 || !targets.validators?.length}
|
||||
label={t('Set nominees')}
|
||||
onClick={toggleNominate}
|
||||
/>
|
||||
)}
|
||||
</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-async authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { SortedTargets } from '@pezkuwi/app-staking/types';
|
||||
import type { StakerState } from '@pezkuwi/react-hooks/types';
|
||||
import type { PalletStakingUnappliedSlash } from '@pezkuwi/types/lookup';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
|
||||
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-async authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { SortedTargets } from '@pezkuwi/app-staking/types';
|
||||
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-async authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { BondInfo } from './partials/types.js';
|
||||
|
||||
import React, { useCallback, useState } from 'react';
|
||||
|
||||
import { Button, Modal, TxButton } from '@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-async authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { SortedTargets } from '@pezkuwi/app-staking/types';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
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-async authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { DeriveSessionProgress, DeriveUnlocking } from '@pezkuwi/api-derive/types';
|
||||
import type { SortedTargets } from '@pezkuwi/app-staking/types';
|
||||
import type { PoolInfo } from '@pezkuwi/app-staking2/Pools/types';
|
||||
import type { PalletNominationPoolsPoolMember, PalletNominationPoolsPoolRoles } from '@pezkuwi/types/lookup';
|
||||
|
||||
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-async 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-async 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-async authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { DeriveSessionProgress } from '@pezkuwi/api-derive/types';
|
||||
import type { SortedTargets } from '@pezkuwi/app-staking/types';
|
||||
import type { u32 } from '@pezkuwi/types';
|
||||
import type { PalletNominationPoolsPoolMember } from '@pezkuwi/types/lookup';
|
||||
|
||||
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-async 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-async 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-async authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { DeriveSessionProgress } from '@pezkuwi/api-derive/types';
|
||||
import type { SortedTargets } from '@pezkuwi/app-staking/types';
|
||||
import type { OwnPool } from '@pezkuwi/app-staking2/Pools/types';
|
||||
import type { PalletStakingUnappliedSlash } from '@pezkuwi/types/lookup';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
|
||||
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,38 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking-async authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { Icon, styled } from '@pezkuwi/react-components';
|
||||
|
||||
interface Props {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
withIcon?: boolean;
|
||||
}
|
||||
|
||||
function SessionKeyInfo ({ children, className = '', withIcon = true }: Props): React.ReactElement<Props> {
|
||||
return (
|
||||
<StyledArticle className={`${className} mark warning`}>
|
||||
{withIcon && <Icon icon='exclamation-triangle' />}
|
||||
The <strong>session pallet</strong> in Asset Hub is specifically for <strong>collator selection</strong>. You can no longer set session keys <strong> for your relay chain validators </strong> here.
|
||||
<br />
|
||||
<br />
|
||||
<p>As a validator, to manage your relay session keys, please navigate to the Relay Chain, set your session keys there, and then return here to continue with your staking actions.</p>
|
||||
{children}
|
||||
</StyledArticle>
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
const StyledArticle = styled.article`
|
||||
max-width: 50vw;
|
||||
margin-inline: auto !important;
|
||||
|
||||
.ui--Icon {
|
||||
color: rgba(255, 196, 12, 1);
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(SessionKeyInfo);
|
||||
@@ -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,216 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking-async authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import '@pezkuwi/api-augment';
|
||||
|
||||
import type { SortedTargets } from '@pezkuwi/app-staking/types';
|
||||
import type { OwnPool } from '@pezkuwi/app-staking2/Pools/types';
|
||||
import type { StakerState } from '@pezkuwi/react-hooks/types';
|
||||
|
||||
import React, { useMemo, useRef, useState } from 'react';
|
||||
|
||||
import ElectionBanner from '@pezkuwi/app-staking/ElectionBanner';
|
||||
import { Button, ToggleGroup } from '@pezkuwi/react-components';
|
||||
import { useAvailableSlashes, useStakingAsyncApis } from '@pezkuwi/react-hooks';
|
||||
import { FormatBalance } from '@pezkuwi/react-query';
|
||||
import { BN, BN_ZERO } from '@pezkuwi/util';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
import Accounts from './Accounts.js';
|
||||
import NewNominator from './NewNominator.js';
|
||||
import NewStash from './NewStash.js';
|
||||
import NewValidator from './NewValidator.js';
|
||||
import Pools from './Pools.js';
|
||||
import SessionKeyInfo from './SessionKeyInfo.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 { ahApi, isRelayChain } = useStakingAsyncApis();
|
||||
const allSlashes = useAvailableSlashes(ahApi);
|
||||
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(
|
||||
() => [
|
||||
ahApi && state.foundStashes && filterStashes(stashTypeIndex, state.foundStashes),
|
||||
(
|
||||
<tr key='footer'>
|
||||
<td colSpan={4} />
|
||||
<td className='number'>{formatTotal(stashTypeIndex, state)}</td>
|
||||
<td colSpan={2} />
|
||||
</tr>
|
||||
)
|
||||
],
|
||||
[ahApi, state, stashTypeIndex]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{!isRelayChain && <SessionKeyInfo />}
|
||||
<Button.Group>
|
||||
{ahApi?.consts.nominationPools && !isRelayChain && (
|
||||
<ToggleGroup
|
||||
onChange={setAccTypeIndex}
|
||||
options={accTypes.current}
|
||||
value={accTypeIndex}
|
||||
/>
|
||||
)}
|
||||
{accTypeIndex === 0 && (
|
||||
<>
|
||||
<ToggleGroup
|
||||
onChange={setStashTypeIndex}
|
||||
options={stashTypes.current}
|
||||
value={stashTypeIndex}
|
||||
/>
|
||||
{!isRelayChain &&
|
||||
<>
|
||||
<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-async 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-async authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { SortedTargets } from '@pezkuwi/app-staking/types';
|
||||
import type { u32 } from '@pezkuwi/types';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
import type { NominateInfo } from './types.js';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { MAX_NOMINATIONS, STORE_FAVS_BASE } from '@pezkuwi/app-staking/constants';
|
||||
import { InputAddressMulti, MarkWarning, Modal, styled } from '@pezkuwi/react-components';
|
||||
import { useApi, useFavorites } from '@pezkuwi/react-hooks';
|
||||
|
||||
import { useTranslation } from '../../translate.js';
|
||||
import PoolInfo from './PoolInfo.js';
|
||||
import SenderInfo from './SenderInfo.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
controllerId: string;
|
||||
nominating?: string[];
|
||||
onChange: (info: NominateInfo) => void;
|
||||
poolId?: BN;
|
||||
stashId: string;
|
||||
targets: SortedTargets;
|
||||
withSenders?: boolean;
|
||||
}
|
||||
|
||||
function Nominate ({ className = '', controllerId, nominating, onChange, poolId, stashId, targets: { nominateIds = [] }, withSenders }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const { api } = useApi();
|
||||
const [favorites] = useFavorites(STORE_FAVS_BASE);
|
||||
const [selected, setSelected] = useState<string[]>(nominating || []);
|
||||
const [available] = useState<string[]>((): string[] => {
|
||||
const shortlist = [
|
||||
// ensure that the favorite is included in the list of stashes
|
||||
...favorites.filter((a) => nominateIds.includes(a)),
|
||||
// make sure the nominee is not in our favorites already
|
||||
...(nominating || []).filter((a) => !favorites.includes(a))
|
||||
];
|
||||
|
||||
return shortlist.concat(
|
||||
...(nominateIds.filter((a) => !shortlist.includes(a)))
|
||||
);
|
||||
});
|
||||
|
||||
useEffect((): void => {
|
||||
try {
|
||||
onChange({
|
||||
nominateTx: selected?.length
|
||||
? poolId
|
||||
? api.tx.nominationPools.nominate(poolId, selected)
|
||||
: api.tx.staking.nominate(selected)
|
||||
: null
|
||||
});
|
||||
} catch {
|
||||
onChange({ nominateTx: null });
|
||||
}
|
||||
}, [api, onChange, poolId, selected]);
|
||||
|
||||
const maxNominations = api.consts.staking.maxNominatorRewardedPerValidator
|
||||
? (api.consts.staking.maxNominatorRewardedPerValidator as u32).toNumber()
|
||||
: api.consts.staking.maxNominations
|
||||
? (api.consts.staking.maxNominations as u32).toNumber()
|
||||
: MAX_NOMINATIONS;
|
||||
|
||||
return (
|
||||
<StyledDiv className={className}>
|
||||
{withSenders && (
|
||||
poolId
|
||||
? (
|
||||
<PoolInfo
|
||||
controllerId={controllerId}
|
||||
poolId={poolId}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<SenderInfo
|
||||
controllerId={controllerId}
|
||||
stashId={stashId}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<Modal.Columns
|
||||
hint={
|
||||
<>
|
||||
<p>{t('Nominators can be selected manually from the list of all currently available validators.')}</p>
|
||||
<p>{t('Once transmitted the new selection will only take effect in 2 eras taking the new validator election cycle into account. Until then, the nominations will show as inactive.')}</p>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<InputAddressMulti
|
||||
available={available}
|
||||
availableLabel={t('candidate accounts')}
|
||||
defaultValue={nominating}
|
||||
maxCount={maxNominations}
|
||||
onChange={setSelected}
|
||||
valueLabel={t('nominated accounts')}
|
||||
/>
|
||||
<MarkWarning content={t('You should trust your nominations to act competently and honest; basing your decision purely on their current profitability could lead to reduced profits or even loss of funds.')} />
|
||||
</Modal.Columns>
|
||||
</StyledDiv>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledDiv = styled.div`
|
||||
article.warning {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.auto--toggle {
|
||||
margin: 0.5rem 0 0;
|
||||
text-align: right;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ui--Static .ui--AddressMini.padded.addressStatic {
|
||||
padding-top: 0.5rem;
|
||||
|
||||
.ui--AddressMini-info {
|
||||
min-width: 10rem;
|
||||
max-width: 10rem;
|
||||
}
|
||||
}
|
||||
|
||||
.shortlist {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
.candidate {
|
||||
border: 1px solid #eee;
|
||||
border-radius: 0.25rem;
|
||||
margin: 0.25rem;
|
||||
padding-bottom: 0.25rem;
|
||||
padding-right: 0.5rem;
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
border-color: transparent;
|
||||
border-style: solid;
|
||||
border-radius: 0.25em;
|
||||
border-width: 0.25em;
|
||||
}
|
||||
|
||||
&.isAye {
|
||||
background: #fff;
|
||||
border-color: #ccc;
|
||||
}
|
||||
|
||||
&.member::after {
|
||||
border-color: green;
|
||||
}
|
||||
|
||||
&.runnerup::after {
|
||||
border-color: steelblue;
|
||||
}
|
||||
|
||||
.ui--AddressMini-icon {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.candidate-right {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(Nominate);
|
||||
@@ -0,0 +1,44 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking-async 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-async 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,64 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking-async authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { SessionInfo } from './types.js';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { Input, Modal } from '@pezkuwi/react-components';
|
||||
import { useApi } 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 [keys, setKeys] = useState<string | null>(null);
|
||||
|
||||
useEffect((): void => {
|
||||
try {
|
||||
onChange({
|
||||
sessionTx: isHex(keys)
|
||||
? api?.tx.session.setKeys(keys, EMPTY_PROOF)
|
||||
: null
|
||||
});
|
||||
} catch {
|
||||
onChange({ sessionTx: null });
|
||||
}
|
||||
}, [api?.tx.session, keys, onChange]);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{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-async authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { ValidateInfo } from './types.js';
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { Dropdown, InputNumber, MarkError, Modal } from '@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-async 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-async 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,206 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking-async authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { ApiPromise } from '@pezkuwi/api';
|
||||
import type { QueryableStorageMultiArg } from '@pezkuwi/api/types';
|
||||
import type { DeriveEraExposure, DeriveEraValidatorExposurePaged, DeriveSessionIndexes } from '@pezkuwi/api-derive/types';
|
||||
import type { Option, u16, u32, Vec } from '@pezkuwi/types';
|
||||
import type { EraIndex, Exposure, Nominations, UnappliedSlash } from '@pezkuwi/types/interfaces';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { createNamedHook, useCall, useIsMountedRef, useStakingAsyncApis } from '@pezkuwi/react-hooks';
|
||||
import { BN } from '@pezkuwi/util';
|
||||
|
||||
/**
|
||||
* @interface Inactives
|
||||
* @description Defines the structure for the categorized lists of nominated validators.
|
||||
* 'nomsAtRisk' replaces the legacy 'nomsChilled' concept for new runtime (for staking async).
|
||||
*/
|
||||
export interface Inactives {
|
||||
nomsActive?: string[];
|
||||
nomsAtRisk?: string[];
|
||||
nomsInactive?: string[];
|
||||
nomsOver?: string[];
|
||||
nomsWaiting?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* @interface NominationInfo
|
||||
* @description A robust data structure to safely link a nominee's address to their exposure data for the current era.
|
||||
* This prevents index mismatches when some nominees are not in the active validator set.
|
||||
*/
|
||||
interface NominationInfo {
|
||||
nomineeId: string;
|
||||
exposure?: Exposure;
|
||||
}
|
||||
|
||||
interface ExtractStateParams {
|
||||
api?: ApiPromise;
|
||||
stashId: string;
|
||||
unappliedSlashes: string[];
|
||||
nominationsInfo: NominationInfo[];
|
||||
activeEra: EraIndex| undefined;
|
||||
submittedIn: EraIndex;
|
||||
activeValidators: DeriveEraValidatorExposurePaged;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name extractState
|
||||
* @description A pure function that takes all fetched chain data and classifies the nominations into different states.
|
||||
* This is the core classification logic of the hook.
|
||||
* @param params An object containing all necessary data from the chain.
|
||||
* @returns An `Inactives` object with categorized nominee lists.
|
||||
*/
|
||||
function extractState (params: ExtractStateParams): Inactives {
|
||||
const { activeEra, activeValidators, api, nominationsInfo, stashId, submittedIn, unappliedSlashes } = params;
|
||||
|
||||
// The maximum number of nominators that can be rewarded for a single validator.
|
||||
const max = api?.consts.staking?.maxNominatorRewardedPerValidator as u32 || new BN(512);
|
||||
|
||||
const atRiskSet = new Set(unappliedSlashes);
|
||||
|
||||
// We positively identify which nominees are currently active (i.e., our stake is backing them)
|
||||
// and which are inactive. This is more robust than inferring one from the other.
|
||||
const nomsActive: string[] = [];
|
||||
const nomsInactive: string[] = [];
|
||||
|
||||
for (const { exposure, nomineeId } of nominationsInfo) {
|
||||
// A nominator is active for a validator if their stashId appears in the validator's `others` list (their list of nominators).
|
||||
if (exposure?.others.some(({ who }) => who.eq(stashId))) {
|
||||
nomsActive.push(nomineeId);
|
||||
} else {
|
||||
nomsInactive.push(nomineeId);
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: The 'nomsAtRisk' category is the modern equivalent of the legacy 'nomsChilled' status.
|
||||
// A validator is "at risk" if they have a pending (unapplied) slash against them.
|
||||
const nomsAtRisk = nominationsInfo.map(({ nomineeId }) => nomineeId).filter((nominee) => atRiskSet.has(nominee));
|
||||
|
||||
// A validator is "oversubscribed" for this nominator if they are active, but the nominator's stake
|
||||
// is not high enough to be in the top 'max' nominators for that validator.
|
||||
const nomsOver = nominationsInfo
|
||||
.filter(({ nomineeId }) => nomsActive.includes(nomineeId)) // Only active validators can be oversubscribed.
|
||||
.filter(({ exposure }) => {
|
||||
const isStashInTop = exposure?.others
|
||||
.sort((a, b) => (b.value.unwrap()).cmp(a.value.unwrap()))
|
||||
.slice(0, max.toNumber())
|
||||
.some(({ who }) => who.eq(stashId));
|
||||
|
||||
return !isStashInTop;
|
||||
})
|
||||
.map(({ nomineeId }) => nomineeId);
|
||||
|
||||
// Handle the special case where nominations were submitted in the current era.
|
||||
// In this situation, they cannot be active or inactive yet; they are all "waiting" for the next era.
|
||||
if (submittedIn.eq(activeEra)) {
|
||||
const allNominees = nominationsInfo.map(({ nomineeId }) => nomineeId);
|
||||
|
||||
return {
|
||||
nomsActive: [],
|
||||
nomsAtRisk,
|
||||
nomsInactive: [],
|
||||
nomsOver: [],
|
||||
nomsWaiting: allNominees.filter((n) => !atRiskSet.has(n))
|
||||
};
|
||||
}
|
||||
|
||||
// Refine the base lists into the final categories, ensuring no validator appears in multiple lists.
|
||||
const finalNomsActive = nomsActive.filter((n) => !atRiskSet.has(n) && !nomsOver.includes(n));
|
||||
const nomsWaiting = nomsInactive.filter((n) => !activeValidators[n] && !atRiskSet.has(n));
|
||||
const finalNomsInactive = nomsInactive.filter((n) => !nomsWaiting.includes(n) && !atRiskSet.has(n));
|
||||
|
||||
return {
|
||||
nomsActive: finalNomsActive,
|
||||
nomsAtRisk,
|
||||
nomsInactive: finalNomsInactive,
|
||||
nomsOver,
|
||||
nomsWaiting
|
||||
};
|
||||
}
|
||||
|
||||
function useInactivesImpl (stashId: string, nominees?: string[]): Inactives {
|
||||
const { ahApi: api } = useStakingAsyncApis();
|
||||
const mountedRef = useIsMountedRef();
|
||||
const [state, setState] = useState<Inactives>({});
|
||||
|
||||
// The era in which the user last submitted their nominations.
|
||||
const [submittedIn, setSubmittedIn] = useState<EraIndex>();
|
||||
|
||||
// A list of the user's nominees who have pending slashes.
|
||||
const [unappliedSlashes, setUnappliedSlashes] = useState<string[]>([]);
|
||||
|
||||
const indexes = useCall<DeriveSessionIndexes>(api?.derive.session?.indexes);
|
||||
|
||||
// Fetches the current version of the staking pallet.
|
||||
const version = useCall<u16>(api?.query.staking.palletVersion)?.toNumber();
|
||||
const eraExposure = useCall<DeriveEraExposure>(indexes && api?.derive.staking.eraExposure, [indexes?.activeEra]);
|
||||
|
||||
/**
|
||||
* @description Effect for fetching primary chain data.
|
||||
* It retrieves the nominator's submission era and all unapplied slashes for the active era.
|
||||
*/
|
||||
useEffect((): () => void => {
|
||||
let unsub: (() => void) | undefined;
|
||||
|
||||
if (mountedRef.current && nominees?.length && stashId && indexes) {
|
||||
api?.queryMulti<[Option<Nominations>, Option<Vec<UnappliedSlash>>]>(
|
||||
[
|
||||
[api?.query.staking.nominators, stashId] as QueryableStorageMultiArg<'promise'>,
|
||||
[api?.query.staking.unappliedSlashes, [indexes.activeEra, null]]
|
||||
],
|
||||
([optNominators, eraSlashes]) => {
|
||||
if (optNominators.isSome) {
|
||||
setSubmittedIn(optNominators.unwrap().submittedIn);
|
||||
}
|
||||
|
||||
const atRiskSet = new Set<string>();
|
||||
|
||||
if (eraSlashes.isSome) {
|
||||
for (const slash of eraSlashes.unwrap()) {
|
||||
atRiskSet.add(slash.validator.toString());
|
||||
}
|
||||
}
|
||||
|
||||
const atRiskNominees = nominees.filter((nominee) => atRiskSet.has(nominee));
|
||||
|
||||
setUnappliedSlashes(atRiskNominees);
|
||||
}
|
||||
).then((_unsub) => {
|
||||
unsub = _unsub;
|
||||
}).catch(console.error);
|
||||
}
|
||||
|
||||
return (): void => {
|
||||
unsub && unsub();
|
||||
};
|
||||
}, [api, mountedRef, nominees, stashId, indexes]);
|
||||
|
||||
useEffect(() => {
|
||||
if (eraExposure && nominees?.length && submittedIn && version && indexes) {
|
||||
const nominationsInfo: NominationInfo[] = nominees.map((nomineeId) => ({
|
||||
exposure: eraExposure.validators[nomineeId],
|
||||
nomineeId
|
||||
}));
|
||||
|
||||
if (mountedRef.current) {
|
||||
setState(
|
||||
extractState({
|
||||
activeEra: indexes.activeEra,
|
||||
activeValidators: eraExposure.validators,
|
||||
api,
|
||||
nominationsInfo,
|
||||
stashId,
|
||||
submittedIn,
|
||||
unappliedSlashes
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [api, stashId, nominees, indexes, submittedIn, eraExposure, version, mountedRef, unappliedSlashes]);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
export default createNamedHook('useInactives', useInactivesImpl);
|
||||
@@ -0,0 +1,22 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking-async authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { Option } from '@pezkuwi/types';
|
||||
import type { PalletStakingSlashingSlashingSpans } from '@pezkuwi/types/lookup';
|
||||
|
||||
import { createNamedHook, useCall, useStakingAsyncApis } 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 { ahApi: api } = useStakingAsyncApis();
|
||||
|
||||
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-async 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, useCall, useStakingAsyncApis } from '@pezkuwi/react-hooks';
|
||||
import { BN_ONE, BN_ZERO } from '@pezkuwi/util';
|
||||
|
||||
function useUnbondDurationImpl (): BN | undefined {
|
||||
const { ahApi: api } = useStakingAsyncApis();
|
||||
const sessionInfo = useCall<DeriveSessionInfo>(api?.derive.session.info);
|
||||
|
||||
return useMemo(
|
||||
() => (sessionInfo && sessionInfo.sessionLength.gt(BN_ONE))
|
||||
? sessionInfo.eraLength.mul(api?.consts.staking.bondingDuration ?? BN_ZERO)
|
||||
: undefined,
|
||||
[api, sessionInfo]
|
||||
);
|
||||
}
|
||||
|
||||
export default createNamedHook('useUnbondDuration', useUnbondDurationImpl);
|
||||
@@ -0,0 +1,306 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking-async authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import type { ApiPromise } from '@pezkuwi/api';
|
||||
import type { EnhancedEvent, IAhOutput } from './index.js';
|
||||
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { CardSummary, Expander, Spinner, styled } from '@pezkuwi/react-components';
|
||||
import { Event as EventDisplay } from '@pezkuwi/react-params';
|
||||
import { formatBalance, formatNumber } from '@pezkuwi/util';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
ahApi?: ApiPromise;
|
||||
ahOutput?: IAhOutput;
|
||||
ahEvents: EnhancedEvent[];
|
||||
ahUrl: string;
|
||||
isRelayChain: boolean
|
||||
}
|
||||
|
||||
function AssetHubSection ({ ahApi, ahEvents, ahOutput, ahUrl, children, isRelayChain }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<StyledColumn>
|
||||
<StyledInfoBox>
|
||||
<h2>
|
||||
{t('Asset Hub')}
|
||||
{children}
|
||||
</h2>
|
||||
{!ahApi && <Spinner label={t('Connecting to Asset Hub')} />}
|
||||
{ahOutput && (
|
||||
<div className='info-content'>
|
||||
<div className='block-info'>
|
||||
{!isRelayChain
|
||||
? <Link to={`/explorer/query/${ahOutput.finalizedBlock}`}>#{formatNumber(ahOutput.finalizedBlock)}</Link>
|
||||
: (
|
||||
<Link
|
||||
target='_blank'
|
||||
to={`${window.location.origin}/?rpc=${ahUrl}#/explorer/query/${ahOutput.finalizedBlock}`}
|
||||
>
|
||||
#{formatNumber(ahOutput.finalizedBlock)}
|
||||
</Link>)
|
||||
}
|
||||
</div>
|
||||
<div className='section'>
|
||||
<h4>{t('Staking')}</h4>
|
||||
<div className='stats'>
|
||||
<CardSummary label={t('current era')}>
|
||||
{ahOutput.staking.currentEra}
|
||||
</CardSummary>
|
||||
<CardSummary label={t('active era')}>
|
||||
{ahOutput.staking.activeEra.index}
|
||||
{ahOutput.staking.activeEra.duration && ` (${ahOutput.staking.activeEra.duration})`}
|
||||
</CardSummary>
|
||||
<CardSummary label={t('era start session')}>
|
||||
{ahOutput.staking.erasStartSessionIndex || 'None'}
|
||||
{ahOutput.rcClient.eraDepth && `, Era-depth: ${ahOutput.rcClient.eraDepth} sessions`}
|
||||
</CardSummary>
|
||||
<CardSummary label={t('bonded eras')}>
|
||||
{ahOutput.staking.bondedEras && ahOutput.staking.bondedEras.length > 0
|
||||
? (() => {
|
||||
const firstEra = ahOutput.staking.bondedEras[0];
|
||||
const lastEra = ahOutput.staking.bondedEras[ahOutput.staking.bondedEras.length - 1];
|
||||
|
||||
return `[(${firstEra[0].toString()}@${firstEra[1].toString()}) -> (${lastEra[0].toString()}@${lastEra[1].toString()})]`;
|
||||
})()
|
||||
: 'None'}
|
||||
</CardSummary>
|
||||
<CardSummary label={t('unpruned eras')}>
|
||||
{ahOutput.staking.unprunedEras}
|
||||
</CardSummary>
|
||||
<CardSummary label={t('forcing')}>
|
||||
{ahOutput.staking.forcing || 'None'}
|
||||
</CardSummary>
|
||||
</div>
|
||||
</div>
|
||||
<div className='section'>
|
||||
<h4>{t('Staking RC Client')}</h4>
|
||||
<div className='stats'>
|
||||
<CardSummary label={t('last session report')}>
|
||||
End={ahOutput.rcClient.lastSessionReportEndIndex}, Start={ahOutput.rcClient.lastSessionIndex}
|
||||
</CardSummary>
|
||||
</div>
|
||||
</div>
|
||||
<div className='section'>
|
||||
<h4>{t('Election')}</h4>
|
||||
<div className='stats'>
|
||||
<CardSummary label={t('phase')}>
|
||||
{ahOutput.multiblock.phase}
|
||||
</CardSummary>
|
||||
<CardSummary label={t('round')}>
|
||||
{ahOutput.multiblock.round}
|
||||
</CardSummary>
|
||||
<CardSummary label={t('signed submissions')}>
|
||||
{ahOutput.multiblock.signedSubmissions}
|
||||
</CardSummary>
|
||||
<CardSummary label={t('queued score')}>
|
||||
{ahOutput.multiblock.queuedScore || 'None'}
|
||||
</CardSummary>
|
||||
<CardSummary label={t('snapshot range')}>
|
||||
{ahOutput.multiblock.snapshotRange.length > 0
|
||||
? (() => {
|
||||
const uniquePages = Array.from(new Set(ahOutput.multiblock.snapshotRange.map((p) => Number(p.toString())))).sort((a, b) => a - b);
|
||||
const [minPage, maxPage] = [uniquePages[0], uniquePages[uniquePages.length - 1]];
|
||||
|
||||
return `${minPage} → ${maxPage}`;
|
||||
})()
|
||||
: 'None'}
|
||||
</CardSummary>
|
||||
</div>
|
||||
</div>
|
||||
<div className='section'>
|
||||
<h4>{t('Validators/Nominators')}</h4>
|
||||
<div className='stats'>
|
||||
<CardSummary label={t('wanted validators')}>
|
||||
{ahOutput.staking.validatorCount || 'None'}
|
||||
</CardSummary>
|
||||
<CardSummary label={t('validator candidates')}>
|
||||
{ahOutput.staking.validatorCandidates
|
||||
? `${ahOutput.staking.validatorCandidates}${ahOutput.staking.maxValidatorsCount ? ` (max: ${ahOutput.staking.maxValidatorsCount})` : ''}`
|
||||
: 'None'}
|
||||
</CardSummary>
|
||||
<CardSummary label={t('nominator candidates')}>
|
||||
{ahOutput.staking.nominatorCandidates
|
||||
? `${ahOutput.staking.nominatorCandidates}${ahOutput.staking.maxNominatorsCount ? ` (max: ${ahOutput.staking.maxNominatorsCount})` : ''}`
|
||||
: 'None'}
|
||||
</CardSummary>
|
||||
<CardSummary label={t('min bonds')}>
|
||||
{ahOutput.staking.minNominatorBond || ahOutput.staking.minValidatorBond || ahOutput.staking.minNominatorActiveStake
|
||||
? `${ahOutput.staking.minNominatorBond ? `N: ${formatBalance(ahOutput.staking.minNominatorBond, { forceUnit: '-', withSi: true })}` : ''}${ahOutput.staking.minValidatorBond ? ` / V: ${formatBalance(ahOutput.staking.minValidatorBond, { forceUnit: '-', withSi: true })}` : ''}${ahOutput.staking.minNominatorActiveStake ? ` / Active: ${formatBalance(ahOutput.staking.minNominatorActiveStake, { forceUnit: '-', withSi: true })}` : ''}`
|
||||
: 'None'}
|
||||
</CardSummary>
|
||||
</div>
|
||||
</div>
|
||||
<div className='section'>
|
||||
<h4>{t('Bags List')}</h4>
|
||||
<div className='stats'>
|
||||
<CardSummary label={t('all nodes')}>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-unsafe-member-access */}
|
||||
{ahOutput.bagsList ? ahOutput.bagsList.allNodes : 'None'}
|
||||
</CardSummary>
|
||||
<CardSummary label={t('lock')}>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-unsafe-member-access */}
|
||||
{ahOutput.bagsList ? ahOutput.bagsList.lock : 'None'}
|
||||
</CardSummary>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</StyledInfoBox>
|
||||
<StyledEventsBox>
|
||||
<h3>{t('Asset Hub Events')}</h3>
|
||||
{ahEvents.length === 0 && (
|
||||
<div className='no-events'>
|
||||
{t('No relevant events in recent blocks')}
|
||||
</div>
|
||||
)}
|
||||
{ahEvents.map((enhancedEvent, index) => {
|
||||
const { blockNumber, event, weight } = enhancedEvent;
|
||||
const eventName = `${event.section}.${event.method}`;
|
||||
|
||||
return (
|
||||
<Expander
|
||||
isLeft
|
||||
key={`${event.index.toString()}-${index}`}
|
||||
summary={
|
||||
<>
|
||||
{eventName}
|
||||
<div className='absolute --digits'>
|
||||
{weight && <span className='weight'>[{weight}]</span>}
|
||||
{!isRelayChain
|
||||
? <Link to={`/explorer/query/${blockNumber}`}>#{formatNumber(blockNumber)}</Link>
|
||||
: (
|
||||
<Link
|
||||
target='_blank'
|
||||
to={`${window.location.origin}/?rpc=${ahUrl}#/explorer/query/${blockNumber}`}
|
||||
>
|
||||
#{formatNumber(blockNumber)}
|
||||
</Link>)
|
||||
}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
summaryMeta={event.meta}
|
||||
>
|
||||
<EventDisplay
|
||||
className='details'
|
||||
eventName={eventName}
|
||||
value={event}
|
||||
withExpander
|
||||
/>
|
||||
</Expander>
|
||||
);
|
||||
})}
|
||||
</StyledEventsBox>
|
||||
</StyledColumn>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledColumn = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
`;
|
||||
|
||||
const StyledInfoBox = styled.div`
|
||||
background: var(--bg-table);
|
||||
padding: 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
|
||||
h2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: var(--font-size-h2);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.info-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
|
||||
.block-info {
|
||||
font-size: var(--font-size-h3);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.section {
|
||||
h4 {
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 0.5rem;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.ui--Labelled {
|
||||
label {
|
||||
font-size: medium !important;
|
||||
text-align: left !important;
|
||||
}
|
||||
|
||||
.ui--Labelled-content {
|
||||
font-size: small !important;
|
||||
text-align: left !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledEventsBox = styled.div`
|
||||
background: var(--bg-table);
|
||||
padding: 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
|
||||
h3 {
|
||||
margin-bottom: 1rem;
|
||||
font-size: var(--font-size-h3);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.no-events {
|
||||
color: var(--color-text-secondary);
|
||||
font-style: italic;
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ui--Expander {
|
||||
margin-bottom: 0.5rem;
|
||||
position: relative;
|
||||
|
||||
.absolute {
|
||||
position: absolute;
|
||||
right: 0.5rem;
|
||||
top: 0.72rem;
|
||||
white-space: nowrap;
|
||||
|
||||
.weight {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-small);
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(AssetHubSection);
|
||||
@@ -0,0 +1,859 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking-async authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { ApiPromise } from '@pezkuwi/api';
|
||||
import type { AccountId32, Event, Hash } from '@pezkuwi/types/interfaces';
|
||||
import type { FrameSupportDispatchPerDispatchClassWeight, PezkuwiRuntimeTeyrchainsConfigurationHostConfiguration } from '@pezkuwi/types/lookup';
|
||||
import type { IEventData, ITuple } from '@pezkuwi/types/types';
|
||||
import type { u32, Vec } from '@pezkuwi/types-codec';
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { Button, Dropdown, Input, MarkWarning, styled } from '@pezkuwi/react-components';
|
||||
import { useApi } from '@pezkuwi/react-hooks';
|
||||
import { getApi } from '@pezkuwi/react-hooks/ctx/StakingAsync';
|
||||
import { formatBalance } from '@pezkuwi/util';
|
||||
|
||||
import AssetHubSection from './ah.js';
|
||||
import RelaySection from './relay.js';
|
||||
|
||||
// Enhanced event type with metadata
|
||||
export interface EnhancedEvent {
|
||||
event: Event;
|
||||
blockNumber: number;
|
||||
blockHash: string;
|
||||
weight?: string;
|
||||
}
|
||||
|
||||
export interface IRcOutput {
|
||||
finalizedBlock: number,
|
||||
session: {
|
||||
index: number,
|
||||
hasQueuedInSession: boolean,
|
||||
historicalRange?: [number, number]
|
||||
},
|
||||
stakingAhClient: {
|
||||
mode: string
|
||||
hasNextActiveId?: number,
|
||||
hasQueuedInClient?: [number, AccountId32[]],
|
||||
validatorPoints: number
|
||||
},
|
||||
staking: {
|
||||
forceEra?: string,
|
||||
validatorCount?: number,
|
||||
electionPhase?: string
|
||||
},
|
||||
teyrchainConfig?: {
|
||||
maxDownwardMessageSize: number,
|
||||
maxUpwardMessageSize: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface IAhOutput {
|
||||
finalizedBlock: number,
|
||||
staking: {
|
||||
currentEra: number,
|
||||
activeEra: {index: number, start: string, duration?: string},
|
||||
erasStartSessionIndex?: number,
|
||||
bondedEras: Vec<ITuple<[u32, u32]>>,
|
||||
unprunedEras: string,
|
||||
validatorCount?: number,
|
||||
validatorCandidates?: number,
|
||||
nominatorCandidates?: number,
|
||||
maxValidatorsCount?: number,
|
||||
maxNominatorsCount?: number,
|
||||
minNominatorBond?: string,
|
||||
minValidatorBond?: string,
|
||||
minNominatorActiveStake?: string,
|
||||
forcing?: string
|
||||
},
|
||||
rcClient: {
|
||||
lastSessionReportEndIndex: string,
|
||||
lastSessionIndex: number,
|
||||
eraDepth?: number
|
||||
},
|
||||
multiblock: {
|
||||
phase: string,
|
||||
round: number,
|
||||
snapshotRange: string[]
|
||||
queuedScore: string|null,
|
||||
signedSubmissions: number
|
||||
},
|
||||
bagsList?: {
|
||||
allNodes: number,
|
||||
lock: string
|
||||
}
|
||||
}
|
||||
|
||||
// Filter functions for relevant events
|
||||
const filterRcEvents = (e: IEventData): boolean => {
|
||||
const ahClientEvents = (e: IEventData) =>
|
||||
e.section === 'stakingAhClient';
|
||||
const sessionEvents = (e: IEventData) =>
|
||||
e.section === 'session' || e.section === 'historical';
|
||||
|
||||
return ahClientEvents(e) || sessionEvents(e);
|
||||
};
|
||||
|
||||
const filterAhEvents = (e: IEventData): boolean => {
|
||||
const election = (e: IEventData) =>
|
||||
e.section === 'multiBlockElection' ||
|
||||
e.section === 'multiBlockElectionVerifier' ||
|
||||
e.section === 'multiBlockElectionSigned' ||
|
||||
e.section === 'multiBlockElectionUnsigned';
|
||||
const rcClient = (e: IEventData) => e.section === 'stakingRcClient';
|
||||
const staking = (e: IEventData) =>
|
||||
e.section === 'staking' &&
|
||||
(e.method === 'EraPaid' ||
|
||||
e.method === 'SessionRotated' ||
|
||||
e.method === 'PagedElectionProceeded');
|
||||
|
||||
return election(e) || rcClient(e) || staking(e);
|
||||
};
|
||||
|
||||
const commandCenterHandler = async (
|
||||
rcApi: ApiPromise,
|
||||
ahApi: ApiPromise,
|
||||
setRcOutput: React.Dispatch<React.SetStateAction<IRcOutput | undefined>>,
|
||||
setAhOutput: React.Dispatch<React.SetStateAction<IAhOutput | undefined>>,
|
||||
setRcEvents: React.Dispatch<React.SetStateAction<EnhancedEvent[]>>,
|
||||
setAhEvents: React.Dispatch<React.SetStateAction<EnhancedEvent[]>>
|
||||
): Promise<void> => {
|
||||
await rcApi.rpc.chain.subscribeFinalizedHeads(async (header) => {
|
||||
// --- RC:
|
||||
// current session index
|
||||
const index = await rcApi.query.session.currentIndex();
|
||||
// whether the session pallet has a queued validator set within it
|
||||
const hasQueuedInSession = await rcApi.query.session.queuedChanged();
|
||||
// the range of historical session data that we have in the RC.
|
||||
const historicalRange = await rcApi.query.historical.storedRange();
|
||||
|
||||
// whether there is a validator set queued in ah-client. for this we need to display only the id and the length of the set.
|
||||
const hasQueuedInClient =
|
||||
await rcApi.query.stakingAhClient.validatorSet();
|
||||
// whether we have already passed a new validator set to session, and therefore in the next session rotation we want to pass this id to AH.
|
||||
const hasNextActiveId =
|
||||
await rcApi.query.stakingAhClient.nextSessionChangesValidators();
|
||||
// Operating mode of the client.
|
||||
const mode = await rcApi.query.stakingAhClient.mode();
|
||||
|
||||
// Staking info from RC if available
|
||||
const forceEra = rcApi.query.staking?.forceEra ? await rcApi.query.staking.forceEra() : undefined;
|
||||
const validatorCount = rcApi.query.staking?.validatorCount ? await rcApi.query.staking.validatorCount() : undefined;
|
||||
const electionPhase = rcApi.query.electionProviderMultiPhase?.currentPhase ? await rcApi.query.electionProviderMultiPhase.currentPhase() : undefined;
|
||||
|
||||
// Validator points
|
||||
const validatorPointsKeys = await rcApi.query.stakingAhClient.validatorPoints.keys();
|
||||
|
||||
// Teyrchain config
|
||||
let teyrchainConfig;
|
||||
|
||||
try {
|
||||
const configuration = await rcApi.query.configuration.activeConfig() as PezkuwiRuntimeTeyrchainsConfigurationHostConfiguration;
|
||||
|
||||
teyrchainConfig = {
|
||||
maxDownwardMessageSize: configuration.maxDownwardMessageSize?.toNumber() || 0,
|
||||
maxUpwardMessageSize: configuration.maxUpwardMessageSize?.toNumber() || 0
|
||||
};
|
||||
} catch {
|
||||
teyrchainConfig = undefined;
|
||||
}
|
||||
|
||||
// Events that we are interested in from RC:
|
||||
const eventsOfInterest = (await (await rcApi.at(header.hash.toHex())).query.system.events())
|
||||
.map((e) => e.event)
|
||||
.filter((e) => filterRcEvents(e.data));
|
||||
|
||||
// Wrap events with metadata
|
||||
const enhancedEvents: EnhancedEvent[] = eventsOfInterest.map((event) => ({
|
||||
blockHash: header.hash.toString(),
|
||||
blockNumber: header.number.toNumber(),
|
||||
event
|
||||
}));
|
||||
|
||||
setRcOutput({
|
||||
finalizedBlock: header.number.toNumber(),
|
||||
teyrchainConfig,
|
||||
session: {
|
||||
hasQueuedInSession: hasQueuedInSession.isTrue,
|
||||
historicalRange: historicalRange.isSome
|
||||
? [historicalRange.unwrap()[0].toNumber(), historicalRange.unwrap()[1].toNumber()] as [number, number]
|
||||
: undefined,
|
||||
index: index.toNumber()
|
||||
},
|
||||
staking: {
|
||||
electionPhase: electionPhase?.toString(),
|
||||
forceEra: forceEra?.toString(),
|
||||
validatorCount: validatorCount?.toNumber()
|
||||
},
|
||||
stakingAhClient: {
|
||||
hasNextActiveId: hasNextActiveId.isEmpty
|
||||
? undefined
|
||||
: rcApi.createType('Option<u32>', hasNextActiveId).unwrap().toNumber(),
|
||||
hasQueuedInClient: (() => {
|
||||
const parsed = rcApi.createType('Option<(u32,Vec<AccountId32>)>', hasQueuedInClient);
|
||||
|
||||
return parsed.isNone ? undefined : [parsed.unwrap()[0].toNumber(), parsed.unwrap()[1]] as [number, AccountId32[]];
|
||||
})(),
|
||||
mode: mode.toString(),
|
||||
validatorPoints: validatorPointsKeys.length
|
||||
}
|
||||
});
|
||||
|
||||
if (enhancedEvents.length > 0) {
|
||||
setRcEvents(enhancedEvents);
|
||||
}
|
||||
});
|
||||
|
||||
await ahApi.rpc.chain.subscribeFinalizedHeads(async (header) => {
|
||||
// the current planned era
|
||||
const currentEra = (await ahApi.query.staking.currentEra()).unwrap().toNumber();
|
||||
// the active era
|
||||
const activeEra = (await ahApi.query.staking.activeEra()).unwrap();
|
||||
const activeEraDuration = activeEra.start.isSome
|
||||
? (() => {
|
||||
const startTime = activeEra.start.unwrap().toNumber();
|
||||
const durationMs = Date.now() - startTime;
|
||||
const hours = Math.floor(durationMs / 3600000);
|
||||
const minutes = Math.floor((durationMs % 3600000) / 60000);
|
||||
const seconds = Math.floor((durationMs % 60000) / 1000);
|
||||
|
||||
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||
})()
|
||||
: undefined;
|
||||
|
||||
// the starting index of the active era
|
||||
const bondedEras = await ahApi.query.staking.bondedEras();
|
||||
const activeEraStartSessionIndex = bondedEras.find(([e]) => e.eq(activeEra.index))?.[1];
|
||||
|
||||
// Unpruned eras
|
||||
const unprunedEras = ahApi.query.staking.eraPruningState
|
||||
? (await ahApi.query.staking.eraPruningState.entries()).map(([k]) => k.args[0]).sort().join(', ')
|
||||
: 'unimplemented!';
|
||||
|
||||
// Additional staking info
|
||||
const validatorCount = ahApi.query.staking.validatorCount ? await ahApi.query.staking.validatorCount() : undefined;
|
||||
const validatorCandidates = ahApi.query.staking.counterForValidators ? await ahApi.query.staking.counterForValidators() : undefined;
|
||||
const nominatorCandidates = ahApi.query.staking.counterForNominators ? await ahApi.query.staking.counterForNominators() : undefined;
|
||||
const maxValidatorsCount = ahApi.query.staking.maxValidatorsCount ? await ahApi.query.staking.maxValidatorsCount() : undefined;
|
||||
const maxNominatorsCount = ahApi.query.staking.maxNominatorsCount ? await ahApi.query.staking.maxNominatorsCount() : undefined;
|
||||
const minNominatorBond = ahApi.query.staking.minNominatorBond ? await ahApi.query.staking.minNominatorBond() : undefined;
|
||||
const minValidatorBond = ahApi.query.staking.minValidatorBond ? await ahApi.query.staking.minValidatorBond() : undefined;
|
||||
const minNominatorActiveStake = ahApi.query.staking.minimumActiveStake ? await ahApi.query.staking.minimumActiveStake() : undefined;
|
||||
const forcing = ahApi.query.staking.forceEra ? await ahApi.query.staking.forceEra() : undefined;
|
||||
|
||||
// the basic state of the election provider
|
||||
const phase = await ahApi.query.multiBlockElection.currentPhase();
|
||||
const round = await ahApi.query.multiBlockElection.round();
|
||||
const snapshotRange = (
|
||||
await ahApi.query.multiBlockElection.pagedVoterSnapshotHash.entries()
|
||||
)
|
||||
.map(([k]) => k.args[1])
|
||||
.sort();
|
||||
const queuedScore =
|
||||
await ahApi.query.multiBlockElectionVerifier.queuedSolutionScore(round);
|
||||
const signedSubmissions =
|
||||
await ahApi.query.multiBlockElectionSigned.sortedScores(round);
|
||||
|
||||
// The client
|
||||
const lastSessionReportEndIndexRaw = await ahApi.query.stakingRcClient.lastSessionReportEndingIndex();
|
||||
const lastSessionReportEndIndex = ahApi.createType('Option<BlockNumber>', lastSessionReportEndIndexRaw);
|
||||
const lastSessionIndex = lastSessionReportEndIndex.isSome ? lastSessionReportEndIndex.unwrap().toNumber() + 1 : 0;
|
||||
const eraDepth = activeEraStartSessionIndex ? lastSessionIndex - activeEraStartSessionIndex.toNumber() : undefined;
|
||||
|
||||
// Bags list
|
||||
let bagsList;
|
||||
|
||||
try {
|
||||
const allNodes = await ahApi.query.voterList.counterForListNodes();
|
||||
const lock = await ahApi.query.voterList.lock();
|
||||
|
||||
bagsList = {
|
||||
allNodes: allNodes.toNumber(),
|
||||
lock: lock.toU8a().toString()
|
||||
};
|
||||
} catch {
|
||||
bagsList = undefined;
|
||||
}
|
||||
|
||||
// Events that we are interested in from AH:
|
||||
const eventsOfInterest = (await (await ahApi.at(header.hash.toHex())).query.system.events())
|
||||
.map((e) => e.event)
|
||||
.filter((e) => filterAhEvents(e.data));
|
||||
|
||||
// Get block weight
|
||||
const weight = await (await ahApi.at(header.hash)).query.system.blockWeight();
|
||||
|
||||
const formatWeight = (w: FrameSupportDispatchPerDispatchClassWeight) => {
|
||||
const normalRef = w.normal?.refTime?.toBigInt() || 0n;
|
||||
const operationalRef = w.operational?.refTime?.toBigInt() || 0n;
|
||||
const mandatoryRef = w.mandatory?.refTime?.toBigInt() || 0n;
|
||||
const totalRef = normalRef + operationalRef + mandatoryRef;
|
||||
|
||||
const normalProof = w.normal?.proofSize?.toBigInt() || 0n;
|
||||
const operationalProof = w.operational?.proofSize?.toBigInt() || 0n;
|
||||
const mandatoryProof = w.mandatory?.proofSize?.toBigInt() || 0n;
|
||||
const totalProof = normalProof + operationalProof + mandatoryProof;
|
||||
|
||||
return `${(Number(totalRef) / 1_000_000_000_000).toFixed(2)}s / ${(Number(totalProof) / 1024).toFixed(0)}KB`;
|
||||
};
|
||||
|
||||
// Wrap events with metadata including weight
|
||||
const enhancedEvents: EnhancedEvent[] = eventsOfInterest.map((event) => ({
|
||||
blockHash: header.hash.toString(),
|
||||
blockNumber: header.number.toNumber(),
|
||||
event,
|
||||
weight: formatWeight(weight)
|
||||
}));
|
||||
|
||||
const parsedQueuedScore = ahApi.createType('Option<SpNposElectionsElectionScore>', queuedScore);
|
||||
const formattedQueuedScore = parsedQueuedScore.isSome
|
||||
? (() => {
|
||||
const score = parsedQueuedScore.unwrap();
|
||||
const minimalStake = score.minimalStake?.toString() || '0';
|
||||
const formattedMinStake = formatBalance(minimalStake, { forceUnit: '-', withSi: true });
|
||||
|
||||
return `minStake: ${formattedMinStake}, ...`;
|
||||
})()
|
||||
: null;
|
||||
|
||||
// Format phase to be more readable (e.g., "Unsigned(1)" instead of {"unsigned":1})
|
||||
const formattedPhase = (() => {
|
||||
const phaseStr = phase.toString();
|
||||
|
||||
try {
|
||||
const phaseJson = JSON.parse(phaseStr) as Record<string, string>;
|
||||
|
||||
if (typeof phaseJson === 'object' && phaseJson !== null) {
|
||||
const key = Object.keys(phaseJson)[0];
|
||||
const value = phaseJson[key];
|
||||
|
||||
return `${key.charAt(0).toUpperCase() + key.slice(1)}(${value})`;
|
||||
}
|
||||
} catch {
|
||||
// If not JSON, return as is
|
||||
}
|
||||
|
||||
return phaseStr;
|
||||
})();
|
||||
|
||||
setAhOutput({
|
||||
bagsList,
|
||||
finalizedBlock: header.number.toNumber(),
|
||||
multiblock: {
|
||||
phase: formattedPhase,
|
||||
queuedScore: formattedQueuedScore,
|
||||
round: ahApi.createType('u32', round).toNumber(),
|
||||
signedSubmissions: ahApi.createType(
|
||||
'Vec<(AccountId32,SpNposElectionsElectionScore)>',
|
||||
signedSubmissions
|
||||
).length,
|
||||
snapshotRange: snapshotRange.map((a) => a.toString())
|
||||
},
|
||||
rcClient: {
|
||||
eraDepth,
|
||||
lastSessionIndex,
|
||||
lastSessionReportEndIndex: lastSessionReportEndIndex.isSome ? lastSessionReportEndIndex.unwrap().toString() : '0'
|
||||
},
|
||||
staking: {
|
||||
activeEra: {
|
||||
duration: activeEraDuration,
|
||||
index: activeEra.index.toNumber(),
|
||||
start: activeEra.toString()
|
||||
},
|
||||
bondedEras,
|
||||
currentEra,
|
||||
erasStartSessionIndex: activeEraStartSessionIndex?.toNumber(),
|
||||
forcing: forcing?.toString(),
|
||||
maxNominatorsCount: maxNominatorsCount?.isSome ? maxNominatorsCount.unwrap().toNumber() : undefined,
|
||||
maxValidatorsCount: maxValidatorsCount?.isSome ? maxValidatorsCount.unwrap().toNumber() : undefined,
|
||||
minNominatorActiveStake: minNominatorActiveStake?.toString(),
|
||||
minNominatorBond: minNominatorBond?.toString(),
|
||||
minValidatorBond: minValidatorBond?.toString(),
|
||||
nominatorCandidates: nominatorCandidates?.toNumber(),
|
||||
unprunedEras,
|
||||
validatorCandidates: validatorCandidates?.toNumber(),
|
||||
validatorCount: validatorCount?.toNumber()
|
||||
}
|
||||
});
|
||||
|
||||
if (enhancedEvents.length > 0) {
|
||||
setAhEvents(enhancedEvents);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
interface Props {
|
||||
ahApi?: ApiPromise
|
||||
rcApi?: ApiPromise
|
||||
isRelayChain: boolean
|
||||
rcEndPoints: string[]
|
||||
ahEndPoints: string[]
|
||||
}
|
||||
|
||||
function CommandCenter ({ ahApi: initialAhApi, ahEndPoints, isRelayChain, rcApi: initialRcApi, rcEndPoints }: Props) {
|
||||
const { api, apiUrl } = useApi();
|
||||
|
||||
const [rcOutput, setRcOutput] = useState<IRcOutput | undefined>(undefined);
|
||||
const [ahOutput, setAhOutput] = useState<IAhOutput | undefined>(undefined);
|
||||
const [rcEvents, setRcEvents] = useState<EnhancedEvent[]>([]);
|
||||
const [ahEvents, setAhEvents] = useState<EnhancedEvent[]>([]);
|
||||
|
||||
const [rcUrl, setRcUrl] = useState<string|undefined>(undefined);
|
||||
const [ahUrl, setAhUrl] = useState<string|undefined>(undefined);
|
||||
|
||||
const [ahApi, setAhApi] = useState<ApiPromise|undefined>(initialAhApi);
|
||||
const [rcApi, setRcApi] = useState<ApiPromise|undefined>(initialRcApi);
|
||||
|
||||
const [isLoadingHistory, setIsLoadingHistory] = useState(false);
|
||||
const [rcLowestBlock, setRcLowestBlock] = useState<number | null>(null);
|
||||
const [ahLowestBlock, setAhLowestBlock] = useState<number | null>(null);
|
||||
const [loadingProgress, setLoadingProgress] = useState<{ rc: number; ah: number } | null>(null);
|
||||
|
||||
const [customRcUrl, setCustomRcUrl] = useState<string>('ws://127.0.0.1:9944');
|
||||
const [customAhUrl, setCustomAhUrl] = useState<string>('ws://127.0.0.1:9944');
|
||||
const [showCustomRcInput, setShowCustomRcInput] = useState(false);
|
||||
const [showCustomAhInput, setShowCustomAhInput] = useState(false);
|
||||
|
||||
const CUSTOM_OPTION = 'custom';
|
||||
const rcEndPointOptions = useRef([...rcEndPoints.map((e) => ({ text: e, value: e })), { text: 'Custom endpoint...', value: CUSTOM_OPTION }]);
|
||||
const ahEndPointOptions = useRef([...ahEndPoints.map((e) => ({ text: e, value: e })), { text: 'Custom endpoint...', value: CUSTOM_OPTION }]);
|
||||
|
||||
// Load historical events from RC
|
||||
const loadRcHistoricalEvents = useCallback(async (blocksToLoad: number) => {
|
||||
if (!rcApi) {
|
||||
return;
|
||||
}
|
||||
|
||||
let currentBlockHash: Hash;
|
||||
|
||||
if (rcLowestBlock === null) {
|
||||
const finalizedHead = await rcApi.rpc.chain.getFinalizedHead();
|
||||
|
||||
currentBlockHash = finalizedHead;
|
||||
} else if (rcLowestBlock === 0) {
|
||||
return;
|
||||
} else {
|
||||
currentBlockHash = await rcApi.rpc.chain.getBlockHash(rcLowestBlock);
|
||||
}
|
||||
|
||||
let blocksProcessed = 0;
|
||||
|
||||
while (blocksProcessed < blocksToLoad) {
|
||||
try {
|
||||
const block = await rcApi.rpc.chain.getBlock(currentBlockHash);
|
||||
const blockNumber = block.block.header.number.toNumber();
|
||||
|
||||
if (blockNumber === 0) {
|
||||
setRcLowestBlock(0);
|
||||
break;
|
||||
}
|
||||
|
||||
const events = await (await rcApi.at(currentBlockHash)).query.system.events();
|
||||
const relevantEvents = events
|
||||
.map((e) => e.event)
|
||||
.filter((e) => filterRcEvents(e.data));
|
||||
|
||||
if (relevantEvents.length > 0) {
|
||||
const enhancedEvents: EnhancedEvent[] = relevantEvents.map((event) => ({
|
||||
blockHash: currentBlockHash.toString(),
|
||||
blockNumber,
|
||||
event
|
||||
}));
|
||||
|
||||
setRcEvents((prev) => {
|
||||
// Insert new events and sort by block number (highest first)
|
||||
const combined = [...prev, ...enhancedEvents];
|
||||
|
||||
return combined.sort((a, b) => b.blockNumber - a.blockNumber);
|
||||
});
|
||||
}
|
||||
|
||||
setRcLowestBlock(blockNumber - 1);
|
||||
currentBlockHash = block.block.header.parentHash;
|
||||
blocksProcessed++;
|
||||
|
||||
// Update progress
|
||||
const remaining = blocksToLoad - blocksProcessed;
|
||||
|
||||
setLoadingProgress((prev) => ({ ah: prev?.ah ?? 0, rc: remaining }));
|
||||
} catch (error) {
|
||||
console.error(`RC: Error at block ${currentBlockHash.toHex()}:`, error);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [rcApi, rcLowestBlock]);
|
||||
|
||||
// Load historical events from AH
|
||||
const loadAhHistoricalEvents = useCallback(async (blocksToLoad: number) => {
|
||||
if (!ahApi) {
|
||||
return;
|
||||
}
|
||||
|
||||
let currentBlockHash: Hash;
|
||||
|
||||
if (ahLowestBlock === null) {
|
||||
const finalizedHead = await ahApi.rpc.chain.getFinalizedHead();
|
||||
|
||||
currentBlockHash = finalizedHead;
|
||||
} else if (ahLowestBlock === 0) {
|
||||
return;
|
||||
} else {
|
||||
currentBlockHash = await ahApi.rpc.chain.getBlockHash(ahLowestBlock);
|
||||
}
|
||||
|
||||
let blocksProcessed = 0;
|
||||
|
||||
while (blocksProcessed < blocksToLoad) {
|
||||
try {
|
||||
const block = await ahApi.rpc.chain.getBlock(currentBlockHash);
|
||||
const blockNumber = block.block.header.number.toNumber();
|
||||
|
||||
if (blockNumber === 0) {
|
||||
setAhLowestBlock(0);
|
||||
break;
|
||||
}
|
||||
|
||||
const events = await (await ahApi.at(currentBlockHash)).query.system.events();
|
||||
const relevantEvents = events
|
||||
.map((e) => e.event)
|
||||
.filter((e) => filterAhEvents(e.data));
|
||||
|
||||
if (relevantEvents.length > 0) {
|
||||
// Get block weight
|
||||
const weight = await (await ahApi.at(currentBlockHash)).query.system.blockWeight();
|
||||
|
||||
const formatWeight = (w: FrameSupportDispatchPerDispatchClassWeight) => {
|
||||
const normalRef = w.normal?.refTime?.toBigInt() || 0n;
|
||||
const operationalRef = w.operational?.refTime?.toBigInt() || 0n;
|
||||
const mandatoryRef = w.mandatory?.refTime?.toBigInt() || 0n;
|
||||
const totalRef = normalRef + operationalRef + mandatoryRef;
|
||||
|
||||
const normalProof = w.normal?.proofSize?.toBigInt() || 0n;
|
||||
const operationalProof = w.operational?.proofSize?.toBigInt() || 0n;
|
||||
const mandatoryProof = w.mandatory?.proofSize?.toBigInt() || 0n;
|
||||
const totalProof = normalProof + operationalProof + mandatoryProof;
|
||||
|
||||
return `${(Number(totalRef) / 1_000_000_000_000).toFixed(2)}s / ${(Number(totalProof) / 1024).toFixed(0)}KB`;
|
||||
};
|
||||
|
||||
const enhancedEvents: EnhancedEvent[] = relevantEvents.map((event) => ({
|
||||
blockHash: currentBlockHash.toString(),
|
||||
blockNumber,
|
||||
event,
|
||||
weight: formatWeight(weight)
|
||||
}));
|
||||
|
||||
setAhEvents((prev) => {
|
||||
// Insert new events and sort by block number (highest first)
|
||||
const combined = [...prev, ...enhancedEvents];
|
||||
|
||||
return combined.sort((a, b) => b.blockNumber - a.blockNumber);
|
||||
});
|
||||
}
|
||||
|
||||
setAhLowestBlock(blockNumber - 1);
|
||||
currentBlockHash = block.block.header.parentHash;
|
||||
blocksProcessed++;
|
||||
|
||||
// Update progress
|
||||
const remaining = blocksToLoad - blocksProcessed;
|
||||
|
||||
setLoadingProgress((prev) => ({ ah: remaining, rc: prev?.rc ?? 0 }));
|
||||
} catch (error) {
|
||||
console.error(`AH: Error at block ${currentBlockHash.toHex()}:`, error);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [ahApi, ahLowestBlock]);
|
||||
|
||||
// Load historical events (600 blocks at a time)
|
||||
const _loadHistoricalEvents = useCallback(async () => {
|
||||
if (isLoadingHistory || (!rcApi && !ahApi)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoadingHistory(true);
|
||||
setLoadingProgress({ ah: 600, rc: 600 });
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
loadRcHistoricalEvents(600),
|
||||
loadAhHistoricalEvents(600)
|
||||
]);
|
||||
|
||||
console.log('Historical events loaded');
|
||||
} catch (error) {
|
||||
console.error('Error loading historical events:', error);
|
||||
} finally {
|
||||
setIsLoadingHistory(false);
|
||||
setLoadingProgress(null);
|
||||
}
|
||||
}, [isLoadingHistory, rcApi, ahApi, loadRcHistoricalEvents, loadAhHistoricalEvents]);
|
||||
|
||||
const _onSelectAhUrl = useCallback((newAhUrl: string) => {
|
||||
if (newAhUrl === CUSTOM_OPTION) {
|
||||
setShowCustomAhInput(true);
|
||||
} else if (newAhUrl !== ahUrl) {
|
||||
ahApi?.disconnect().catch(console.log);
|
||||
setShowCustomAhInput(false);
|
||||
setAhUrl(newAhUrl);
|
||||
}
|
||||
}, [ahApi, ahUrl]);
|
||||
|
||||
const _onSelectRcUrl = useCallback((newRcUrl: string) => {
|
||||
if (newRcUrl === CUSTOM_OPTION) {
|
||||
setShowCustomRcInput(true);
|
||||
} else if (newRcUrl !== rcUrl) {
|
||||
rcApi?.disconnect().catch(console.log);
|
||||
setShowCustomRcInput(false);
|
||||
setRcUrl(newRcUrl);
|
||||
}
|
||||
}, [rcApi, rcUrl]);
|
||||
|
||||
const _onCustomRcUrlChange = useCallback((value: string) => {
|
||||
setCustomRcUrl(value);
|
||||
}, []);
|
||||
|
||||
const _onCustomAhUrlChange = useCallback((value: string) => {
|
||||
setCustomAhUrl(value);
|
||||
}, []);
|
||||
|
||||
const _onApplyCustomRcUrl = useCallback(() => {
|
||||
if (customRcUrl?.trim()) {
|
||||
rcApi?.disconnect().catch(console.log);
|
||||
setRcUrl(customRcUrl.trim());
|
||||
setShowCustomRcInput(false);
|
||||
}
|
||||
}, [customRcUrl, rcApi]);
|
||||
|
||||
const _onApplyCustomAhUrl = useCallback(() => {
|
||||
if (customAhUrl?.trim()) {
|
||||
ahApi?.disconnect().catch(console.log);
|
||||
setAhUrl(customAhUrl.trim());
|
||||
setShowCustomAhInput(false);
|
||||
}
|
||||
}, [customAhUrl, ahApi]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRelayChain) {
|
||||
setRcUrl(apiUrl);
|
||||
const ahUrl = ahEndPoints.at(Math.floor(Math.random() * ahEndPoints.length));
|
||||
|
||||
setAhUrl(ahUrl);
|
||||
} else {
|
||||
setAhUrl(apiUrl);
|
||||
const rcUrl = rcEndPoints.at(Math.floor(Math.random() * rcEndPoints.length));
|
||||
|
||||
setRcUrl(rcUrl);
|
||||
}
|
||||
}, [ahEndPoints, apiUrl, isRelayChain, rcEndPoints]);
|
||||
|
||||
useEffect(() => {
|
||||
setRcApi(undefined);
|
||||
setAhApi(undefined);
|
||||
setRcOutput(undefined);
|
||||
setAhOutput(undefined);
|
||||
setRcEvents([]);
|
||||
setAhEvents([]);
|
||||
|
||||
if (isRelayChain) {
|
||||
setRcApi(api);
|
||||
|
||||
if (ahUrl) {
|
||||
getApi(ahUrl).then((ahApi) => setAhApi(ahApi)).catch(console.log);
|
||||
}
|
||||
} else if (api.tx.staking && api.tx.stakingRcClient) { // Check if Asset Hub chain
|
||||
setAhApi(api);
|
||||
|
||||
if (rcUrl) {
|
||||
getApi(rcUrl).then((rcApi) => setRcApi(rcApi)).catch(console.log);
|
||||
}
|
||||
}
|
||||
}, [ahUrl, api, isRelayChain, rcUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
ahApi && rcApi && commandCenterHandler(rcApi, ahApi, setRcOutput, setAhOutput, setRcEvents, setAhEvents).catch(console.log);
|
||||
}, [ahApi, rcApi]);
|
||||
|
||||
// Auto-load 600 blocks on page load
|
||||
useEffect(() => {
|
||||
if (ahApi && rcApi && rcLowestBlock === null && ahLowestBlock === null) {
|
||||
_loadHistoricalEvents().catch(console.error);
|
||||
}
|
||||
}, [ahApi, rcApi, rcLowestBlock, ahLowestBlock, _loadHistoricalEvents]);
|
||||
|
||||
return (
|
||||
<StyledDiv>
|
||||
<StyledWarningBanner>
|
||||
<MarkWarning content='This page is for developer debugging only.' />
|
||||
</StyledWarningBanner>
|
||||
<StyledButtonContainer>
|
||||
<Button
|
||||
icon='history'
|
||||
isDisabled={isLoadingHistory || (!rcApi && !ahApi)}
|
||||
label='Load 600 Blocks History'
|
||||
onClick={_loadHistoricalEvents}
|
||||
/>
|
||||
{loadingProgress && (
|
||||
<div className='history-info progress'>
|
||||
Loading historical events... RC: {loadingProgress.rc} blocks left | AH: {loadingProgress.ah} blocks left
|
||||
</div>
|
||||
)}
|
||||
{!loadingProgress && (rcLowestBlock !== null || ahLowestBlock !== null) && (
|
||||
<div className='history-info'>
|
||||
{rcLowestBlock !== null && `RC: Indexed to Block #${rcLowestBlock}`}
|
||||
{rcLowestBlock !== null && ahLowestBlock !== null && ' | '}
|
||||
{ahLowestBlock !== null && `AH: Indexed to Block #${ahLowestBlock}`}
|
||||
</div>
|
||||
)}
|
||||
</StyledButtonContainer>
|
||||
<RelaySection
|
||||
isRelayChain={!!isRelayChain}
|
||||
rcApi={rcApi}
|
||||
rcEvents={rcEvents}
|
||||
rcOutput={rcOutput}
|
||||
rcUrl={rcUrl || ''}
|
||||
>
|
||||
<StyledEndpointControls>
|
||||
<Dropdown
|
||||
defaultValue={rcUrl}
|
||||
isButton
|
||||
isDisabled={!!isRelayChain}
|
||||
onChange={_onSelectRcUrl}
|
||||
options={rcEndPointOptions.current}
|
||||
/>
|
||||
{showCustomRcInput && !isRelayChain && (
|
||||
<StyledCustomInput>
|
||||
<Input
|
||||
autoFocus
|
||||
label='Custom RC Endpoint'
|
||||
onChange={_onCustomRcUrlChange}
|
||||
placeholder='wss://your-relay-chain-endpoint.com'
|
||||
value={customRcUrl}
|
||||
/>
|
||||
<Button
|
||||
label='Connect'
|
||||
onClick={_onApplyCustomRcUrl}
|
||||
/>
|
||||
</StyledCustomInput>
|
||||
)}
|
||||
</StyledEndpointControls>
|
||||
</RelaySection>
|
||||
<AssetHubSection
|
||||
ahApi={ahApi}
|
||||
ahEvents={ahEvents}
|
||||
ahOutput={ahOutput}
|
||||
ahUrl={ahUrl || ''}
|
||||
isRelayChain={!!isRelayChain}
|
||||
>
|
||||
<StyledEndpointControls>
|
||||
<Dropdown
|
||||
defaultValue={ahUrl}
|
||||
isButton
|
||||
isDisabled={!isRelayChain}
|
||||
onChange={_onSelectAhUrl}
|
||||
options={ahEndPointOptions.current}
|
||||
/>
|
||||
{showCustomAhInput && isRelayChain && (
|
||||
<StyledCustomInput>
|
||||
<Input
|
||||
autoFocus
|
||||
label='Custom AH Endpoint'
|
||||
onChange={_onCustomAhUrlChange}
|
||||
placeholder='wss://your-asset-hub-endpoint.com'
|
||||
value={customAhUrl}
|
||||
/>
|
||||
<Button
|
||||
label='Connect'
|
||||
onClick={_onApplyCustomAhUrl}
|
||||
/>
|
||||
</StyledCustomInput>
|
||||
)}
|
||||
</StyledEndpointControls>
|
||||
</AssetHubSection>
|
||||
</StyledDiv>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledEndpointControls = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
`;
|
||||
|
||||
const StyledCustomInput = styled.div`
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-end;
|
||||
|
||||
> div {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
button {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledWarningBanner = styled.div`
|
||||
grid-column: 1 / -1;
|
||||
padding: 1rem 1.5rem;
|
||||
background: var(--bg-table);
|
||||
border-radius: 0.5rem;
|
||||
border: 2px solid var(--color-warning);
|
||||
|
||||
.ui--MarkWarning {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-h3);
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledButtonContainer = styled.div`
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background: var(--bg-table);
|
||||
border-radius: 0.5rem;
|
||||
|
||||
.history-info {
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-secondary);
|
||||
|
||||
&.progress {
|
||||
color: var(--color-text);
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledDiv = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1rem;
|
||||
|
||||
.dropdown {
|
||||
min-width: max-content !important;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1200px){
|
||||
grid-template-columns: repeat(1, 1fr);
|
||||
}
|
||||
|
||||
.ui--Spinner {
|
||||
margin-top: 4rem;
|
||||
}
|
||||
|
||||
.ui {
|
||||
margin-left: 0.5rem !important;
|
||||
width: 20rem !important;
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(CommandCenter);
|
||||
@@ -0,0 +1,259 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking-async authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import type { ApiPromise } from '@pezkuwi/api';
|
||||
import type { EnhancedEvent, IRcOutput } from './index.js';
|
||||
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { CardSummary, Expander, Spinner, styled } from '@pezkuwi/react-components';
|
||||
import { Event as EventDisplay } from '@pezkuwi/react-params';
|
||||
import { formatNumber } from '@pezkuwi/util';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
rcApi?: ApiPromise;
|
||||
rcOutput?: IRcOutput;
|
||||
rcEvents: EnhancedEvent[];
|
||||
rcUrl: string;
|
||||
isRelayChain: boolean
|
||||
}
|
||||
|
||||
function RelaySection ({ children, isRelayChain, rcApi, rcEvents, rcOutput, rcUrl }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<StyledColumn>
|
||||
<StyledInfoBox>
|
||||
<h2>
|
||||
{t('Relay Chain')}
|
||||
{children}
|
||||
</h2>
|
||||
{!rcApi && <Spinner label={t('Connecting to Relay chain')} />}
|
||||
{rcOutput && (
|
||||
<div className='info-content'>
|
||||
<div className='block-info'>
|
||||
{isRelayChain
|
||||
? <Link to={`/explorer/query/${rcOutput.finalizedBlock}`}>#{formatNumber(rcOutput.finalizedBlock)}</Link>
|
||||
: (
|
||||
<Link
|
||||
target='_blank'
|
||||
to={`${window.location.origin}/?rpc=${rcUrl}#/explorer/query/${rcOutput.finalizedBlock}`}
|
||||
>
|
||||
#{formatNumber(rcOutput.finalizedBlock)}
|
||||
</Link>)
|
||||
}
|
||||
</div>
|
||||
<div className='section'>
|
||||
<h4>{t('Session Info')}</h4>
|
||||
<div className='stats'>
|
||||
<CardSummary label={t('session')}>
|
||||
#{formatNumber(rcOutput.session.index)}
|
||||
</CardSummary>
|
||||
<CardSummary label={t('queued')}>
|
||||
{rcOutput.session.hasQueuedInSession ? 'Yes' : 'No'}
|
||||
</CardSummary>
|
||||
{rcOutput.session.historicalRange &&
|
||||
<CardSummary label={t('historical range')}>
|
||||
{`#${formatNumber(rcOutput.session.historicalRange[0])} → #${formatNumber(rcOutput.session.historicalRange[1])}`}
|
||||
</CardSummary>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div className='section'>
|
||||
<h4>{t('Staking AH Client')}</h4>
|
||||
<div className='stats'>
|
||||
<CardSummary label={t('mode')}>
|
||||
{rcOutput.stakingAhClient.mode}
|
||||
</CardSummary>
|
||||
<CardSummary label={t('next active id')}>
|
||||
{rcOutput.stakingAhClient.hasNextActiveId !== undefined ? rcOutput.stakingAhClient.hasNextActiveId : 'None'}
|
||||
</CardSummary>
|
||||
<CardSummary label={t('queued validator points')}>
|
||||
{rcOutput.stakingAhClient.validatorPoints}
|
||||
</CardSummary>
|
||||
</div>
|
||||
<div className='stats'>
|
||||
<CardSummary label={t('queued validator set id')}>
|
||||
{rcOutput.stakingAhClient.hasQueuedInClient ? rcOutput.stakingAhClient.hasQueuedInClient[0] : 'None'}
|
||||
</CardSummary>
|
||||
<CardSummary label={t('queued validators')}>
|
||||
{rcOutput.stakingAhClient.hasQueuedInClient ? rcOutput.stakingAhClient.hasQueuedInClient[1].length : 'None'}
|
||||
</CardSummary>
|
||||
</div>
|
||||
</div>
|
||||
<div className='section'>
|
||||
<h4>{t('Staking/Elections')}</h4>
|
||||
<div className='stats'>
|
||||
<CardSummary label={t('force era')}>
|
||||
{rcOutput.staking.forceEra || 'None'}
|
||||
</CardSummary>
|
||||
<CardSummary label={t('election phase')}>
|
||||
{rcOutput.staking.electionPhase || 'None'}
|
||||
</CardSummary>
|
||||
<CardSummary label={t('validator count')}>
|
||||
{rcOutput.staking.validatorCount || 'None'}
|
||||
</CardSummary>
|
||||
</div>
|
||||
</div>
|
||||
<div className='section'>
|
||||
<h4>{t('Teyrchain Config')}</h4>
|
||||
<div className='stats'>
|
||||
<CardSummary label={t('max downward msg size')}>
|
||||
{rcOutput.teyrchainConfig ? formatNumber(rcOutput.teyrchainConfig.maxDownwardMessageSize) : 'None'}
|
||||
</CardSummary>
|
||||
<CardSummary label={t('max upward msg size')}>
|
||||
{rcOutput.teyrchainConfig ? formatNumber(rcOutput.teyrchainConfig.maxUpwardMessageSize) : 'None'}
|
||||
</CardSummary>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</StyledInfoBox>
|
||||
<StyledEventsBox>
|
||||
<h3>{t('Relay Chain Events')}</h3>
|
||||
{rcEvents.length === 0 && (
|
||||
<div className='no-events'>
|
||||
{t('No relevant events in recent blocks')}
|
||||
</div>
|
||||
)}
|
||||
{rcEvents.map((enhancedEvent, index) => {
|
||||
const { blockNumber, event } = enhancedEvent;
|
||||
const eventName = `${event.section}.${event.method}`;
|
||||
|
||||
return (
|
||||
<Expander
|
||||
isLeft
|
||||
key={`${event.index.toString()}-${index}`}
|
||||
summary={
|
||||
<>
|
||||
{eventName}
|
||||
<div className='absolute --digits'>
|
||||
{isRelayChain
|
||||
? <Link to={`/explorer/query/${blockNumber}`}>#{formatNumber(blockNumber)}</Link>
|
||||
: (
|
||||
<Link
|
||||
target='_blank'
|
||||
to={`${window.location.origin}/?rpc=${rcUrl}#/explorer/query/${blockNumber}`}
|
||||
>
|
||||
#{formatNumber(blockNumber)}
|
||||
</Link>)
|
||||
}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
summaryMeta={event.meta}
|
||||
>
|
||||
<EventDisplay
|
||||
className='details'
|
||||
eventName={eventName}
|
||||
value={event}
|
||||
withExpander
|
||||
/>
|
||||
</Expander>
|
||||
);
|
||||
})}
|
||||
</StyledEventsBox>
|
||||
</StyledColumn>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledColumn = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
`;
|
||||
|
||||
const StyledInfoBox = styled.div`
|
||||
background: var(--bg-table);
|
||||
padding: 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
|
||||
h2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: var(--font-size-h2);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.info-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
|
||||
.block-info {
|
||||
font-size: var(--font-size-h3);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.section {
|
||||
h4 {
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 0.5rem;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.ui--Labelled {
|
||||
label {
|
||||
font-size: medium !important;
|
||||
text-align: left !important;
|
||||
}
|
||||
|
||||
.ui--Labelled-content {
|
||||
font-size: small !important;
|
||||
text-align: left !important;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledEventsBox = styled.div`
|
||||
background: var(--bg-table);
|
||||
padding: 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
|
||||
h3 {
|
||||
margin-bottom: 1rem;
|
||||
font-size: var(--font-size-h3);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.no-events {
|
||||
color: var(--color-text-secondary);
|
||||
font-style: italic;
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ui--Expander {
|
||||
margin-bottom: 0.5rem;
|
||||
position: relative;
|
||||
|
||||
.absolute {
|
||||
position: absolute;
|
||||
right: 0.5rem;
|
||||
top: 0.72rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(RelaySection);
|
||||
@@ -0,0 +1,110 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking-async authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type BN from 'bn.js';
|
||||
import type { ApiPromise } from '@pezkuwi/api';
|
||||
import type { AppProps } from '@pezkuwi/react-components/types';
|
||||
import type { ElectionStatus, ParaValidatorIndex, ValidatorId } from '@pezkuwi/types/interfaces';
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
|
||||
import useSortedTargets from '@pezkuwi/app-staking/useSortedTargets';
|
||||
import { Tabs } from '@pezkuwi/react-components';
|
||||
import { useCallMulti, useFavorites, useOwnStashInfos } from '@pezkuwi/react-hooks';
|
||||
|
||||
import Actions from '../Actions/index.js';
|
||||
import CommandCenter from '../CommandCenter/index.js';
|
||||
import { STORE_FAVS_BASE } from '../constants.js';
|
||||
import { useTranslation } from '../translate.js';
|
||||
|
||||
interface Props extends AppProps {
|
||||
ahApi?: ApiPromise
|
||||
rcApi?: ApiPromise
|
||||
isRelayChain: boolean
|
||||
rcEndPoints: string[]
|
||||
ahEndPoints: string[]
|
||||
}
|
||||
|
||||
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 ({ ahApi: api, ahEndPoints, basePath, isRelayChain, rcApi, rcEndPoints }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [withLedger] = useState(false);
|
||||
const [favorites] = useFavorites(STORE_FAVS_BASE);
|
||||
const ownStashes = useOwnStashInfos(api);
|
||||
const targets = useSortedTargets(favorites, withLedger, api);
|
||||
const queries = useMemo(
|
||||
() => api
|
||||
? [
|
||||
api.query.staking.eraElectionStatus,
|
||||
api.query.staking.minCommission,
|
||||
api.query.session.validators,
|
||||
(api.query.parasShared || api.query.shared)?.activeValidatorIndices
|
||||
]
|
||||
: [],
|
||||
[api]
|
||||
);
|
||||
|
||||
const [isInElection, minCommission] = useCallMulti<[boolean, BN | undefined, Record<string, boolean>]>(queries, OPT_MULTI);
|
||||
|
||||
const items = useMemo(() => [
|
||||
{
|
||||
isRoot: true,
|
||||
name: 'actions',
|
||||
text: t('Accounts')
|
||||
},
|
||||
{
|
||||
name: 'command-center',
|
||||
text: t('Command Center')
|
||||
}
|
||||
], [t]);
|
||||
|
||||
return <>
|
||||
<Tabs
|
||||
basePath={basePath}
|
||||
items={items}
|
||||
/>
|
||||
<Routes>
|
||||
<Route path={basePath}>
|
||||
<Route
|
||||
element={
|
||||
<CommandCenter
|
||||
ahApi={api}
|
||||
ahEndPoints={ahEndPoints}
|
||||
isRelayChain={isRelayChain}
|
||||
rcApi={rcApi}
|
||||
rcEndPoints={rcEndPoints}
|
||||
/>
|
||||
}
|
||||
path='command-center'
|
||||
/>
|
||||
<Route
|
||||
element={
|
||||
<Actions
|
||||
isInElection={isInElection}
|
||||
minCommission={minCommission}
|
||||
ownStashes={ownStashes}
|
||||
targets={targets}
|
||||
/>
|
||||
}
|
||||
index
|
||||
/>
|
||||
</Route>
|
||||
</Routes>
|
||||
</>;
|
||||
}
|
||||
|
||||
export default React.memo(StakingApp);
|
||||
@@ -0,0 +1,267 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking-async authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { ApiPromise } from '@pezkuwi/api';
|
||||
import type { DeriveStakingOverview, DeriveStakingValidators } from '@pezkuwi/api-derive/types';
|
||||
import type { AppProps } 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 Bags from '@pezkuwi/app-staking/Bags';
|
||||
import Payouts from '@pezkuwi/app-staking/Payouts';
|
||||
import Query from '@pezkuwi/app-staking/Query';
|
||||
import Slashes from '@pezkuwi/app-staking/Slashes';
|
||||
import Targets from '@pezkuwi/app-staking/Targets';
|
||||
import useNominations from '@pezkuwi/app-staking/useNominations';
|
||||
import useSortedTargets from '@pezkuwi/app-staking/useSortedTargets';
|
||||
import Pools from '@pezkuwi/app-staking2/Pools';
|
||||
import useOwnPools from '@pezkuwi/app-staking2/Pools/useOwnPools';
|
||||
import { 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 CommandCenter from '../CommandCenter/index.js';
|
||||
import { STORE_FAVS_BASE } from '../constants.js';
|
||||
import { useTranslation } from '../translate.js';
|
||||
import Validators from '../Validators/index.js';
|
||||
|
||||
const HIDDEN_ACC = ['actions', 'payout'];
|
||||
|
||||
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 }), {})
|
||||
: {}
|
||||
]
|
||||
};
|
||||
|
||||
interface Props extends AppProps {
|
||||
ahApi?: ApiPromise
|
||||
rcApi?: ApiPromise
|
||||
isRelayChain: boolean
|
||||
rcEndPoints: string[]
|
||||
ahEndPoints: string[]
|
||||
}
|
||||
|
||||
function StakingApp ({ ahApi, ahEndPoints, basePath, isRelayChain, rcApi, rcEndPoints }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const { api } = useApi();
|
||||
|
||||
const [withLedger, setWithLedger] = useState(false);
|
||||
const [favorites, toggleFavorite] = useFavorites(STORE_FAVS_BASE);
|
||||
const [loadNominations, setLoadNominations] = useState(false);
|
||||
const { areAccountsLoaded, hasAccounts } = useAccounts();
|
||||
const ownStashes = useOwnStashInfos();
|
||||
const slashes = useAvailableSlashes();
|
||||
const targets = useSortedTargets(favorites, withLedger);
|
||||
const [isInElection, minCommission, paraValidators] = useCallMulti<[boolean, BN | undefined, Record<string, boolean>]>([
|
||||
api.query.staking.eraElectionStatus,
|
||||
api.query.staking.minCommission,
|
||||
api.query.session.validators,
|
||||
(api.query.parasShared || api.query.shared)?.activeValidatorIndices
|
||||
], OPT_MULTI);
|
||||
const nominatedBy = useNominations(loadNominations);
|
||||
const ownPools = useOwnPools();
|
||||
const ahStakingOverview = useCall<DeriveStakingOverview>(api?.derive.staking.overview);
|
||||
const validatorsInfo = useCall<DeriveStakingValidators>(rcApi?.derive.staking.validators);
|
||||
|
||||
const stakingOverview = useMemo(() => (
|
||||
!!ahStakingOverview && !!validatorsInfo
|
||||
? {
|
||||
...ahStakingOverview,
|
||||
...validatorsInfo
|
||||
}
|
||||
: undefined
|
||||
), [ahStakingOverview, validatorsInfo]);
|
||||
|
||||
const toggleNominatedBy = useCallback(
|
||||
() => setLoadNominations(true),
|
||||
[]
|
||||
);
|
||||
|
||||
const toggleLedger = useCallback(
|
||||
() => setWithLedger(true),
|
||||
[]
|
||||
);
|
||||
|
||||
const hasQueries = useMemo(
|
||||
() => hasAccounts && !!(api.query.imOnline?.authoredBlocks) && !!(api.query.staking.activeEra),
|
||||
[api, hasAccounts]
|
||||
);
|
||||
|
||||
const hasStashes = useMemo(
|
||||
() => hasAccounts && !!ownStashes && (ownStashes.length !== 0),
|
||||
[hasAccounts, ownStashes]
|
||||
);
|
||||
|
||||
const ownValidators = useMemo(
|
||||
() => (ownStashes || []).filter(({ isStashValidating }) => isStashValidating),
|
||||
[ownStashes]
|
||||
);
|
||||
|
||||
const items = useMemo(() => [
|
||||
{
|
||||
isRoot: true,
|
||||
name: 'active-validators',
|
||||
text: t('Active Validators')
|
||||
},
|
||||
{
|
||||
name: 'actions',
|
||||
text: t('Accounts')
|
||||
},
|
||||
hasStashes && isFunction(api.query.staking.activeEra) && {
|
||||
name: 'payout',
|
||||
text: t('Payouts')
|
||||
},
|
||||
isFunction(api.query.nominationPools?.minCreateBond) && {
|
||||
name: 'pools',
|
||||
text: t('Pools')
|
||||
},
|
||||
{
|
||||
alias: 'returns',
|
||||
name: 'all-validators',
|
||||
text: t('All Validators')
|
||||
},
|
||||
hasStashes && isFunction((api.query.voterBagsList || api.query.bagsList || api.query.voterList)?.counterForListNodes) && {
|
||||
name: 'bags',
|
||||
text: t('Bags')
|
||||
},
|
||||
{
|
||||
count: slashes.reduce((count, [, unapplied]) => count + unapplied.length, 0),
|
||||
name: 'slashes',
|
||||
text: t('Slashes')
|
||||
},
|
||||
{
|
||||
hasParams: true,
|
||||
name: 'query',
|
||||
text: t('Validator stats')
|
||||
},
|
||||
{
|
||||
name: 'command-center',
|
||||
text: t('Command Center')
|
||||
}
|
||||
].filter((q): q is { name: string; text: string } => !!q), [api, hasStashes, slashes, t]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<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={
|
||||
<Actions
|
||||
isInElection={isInElection}
|
||||
minCommission={minCommission}
|
||||
ownPools={ownPools}
|
||||
ownStashes={ownStashes}
|
||||
targets={targets}
|
||||
/>
|
||||
}
|
||||
path='actions'
|
||||
/>
|
||||
<Route
|
||||
element={
|
||||
<Targets
|
||||
isInElection={isInElection}
|
||||
nominatedBy={nominatedBy}
|
||||
ownStashes={ownStashes}
|
||||
targets={targets}
|
||||
toggleFavorite={toggleFavorite}
|
||||
toggleLedger={toggleLedger}
|
||||
toggleNominatedBy={toggleNominatedBy}
|
||||
/>
|
||||
}
|
||||
path='all-validators'
|
||||
/>
|
||||
<Route
|
||||
element={
|
||||
<Slashes
|
||||
ownStashes={ownStashes}
|
||||
slashes={slashes}
|
||||
/>
|
||||
}
|
||||
path='slashes'
|
||||
/>
|
||||
<Route
|
||||
element={
|
||||
<CommandCenter
|
||||
ahApi={ahApi}
|
||||
ahEndPoints={ahEndPoints}
|
||||
isRelayChain={isRelayChain}
|
||||
rcApi={rcApi}
|
||||
rcEndPoints={rcEndPoints}
|
||||
/>
|
||||
}
|
||||
path='command-center'
|
||||
/>
|
||||
<Route
|
||||
element={
|
||||
<Validators
|
||||
favorites={favorites}
|
||||
hasAccounts={hasAccounts}
|
||||
hasQueries={hasQueries}
|
||||
minCommission={minCommission}
|
||||
nominatedBy={nominatedBy}
|
||||
ownStashes={ownStashes}
|
||||
paraValidators={paraValidators}
|
||||
stakingOverview={stakingOverview}
|
||||
targets={targets}
|
||||
toggleFavorite={toggleFavorite}
|
||||
toggleNominatedBy={toggleNominatedBy}
|
||||
/>
|
||||
}
|
||||
index
|
||||
/>
|
||||
</Route>
|
||||
</Routes>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(StakingApp);
|
||||
@@ -0,0 +1,21 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking-async 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-async authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { ApiPromise } from '@pezkuwi/api';
|
||||
import type { NominatedBy as NominatedByType } from '@pezkuwi/app-staking/types';
|
||||
import type { SlashingSpans } from '@pezkuwi/types/interfaces';
|
||||
|
||||
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-async 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,119 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking-async authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import MaxBadge from '@pezkuwi/app-staking/MaxBadge';
|
||||
import { Badge } from '@pezkuwi/react-components';
|
||||
import { useAccounts } from '@pezkuwi/react-hooks';
|
||||
|
||||
interface Props {
|
||||
isChilled?: boolean;
|
||||
isElected: boolean;
|
||||
isMain?: boolean;
|
||||
isPara?: boolean;
|
||||
isRelay?: boolean;
|
||||
nominators?: { nominatorId: string }[];
|
||||
onlineCount?: false | BN;
|
||||
onlineMessage?: boolean;
|
||||
}
|
||||
|
||||
const NO_NOMS: { nominatorId: string }[] = [];
|
||||
|
||||
function Status ({ isChilled, isElected, isMain, isPara, isRelay, nominators = NO_NOMS, onlineCount, onlineMessage }: Props): React.ReactElement<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-async authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { ApiPromise } from '@pezkuwi/api';
|
||||
import type { DeriveHeartbeatAuthor } from '@pezkuwi/api-derive/types';
|
||||
import type { NominatedBy as NominatedByType, ValidatorInfo } from '@pezkuwi/app-staking/types';
|
||||
import type { Option } from '@pezkuwi/types';
|
||||
import type { SlashingSpans, ValidatorPrefs } from '@pezkuwi/types/interfaces';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
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-async 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-async authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { DeriveHeartbeats, DeriveStakingOverview } from '@pezkuwi/api-derive/types';
|
||||
import type { NominatedByMap, SortedTargets, ValidatorInfo } from '@pezkuwi/app-staking/types';
|
||||
import type { AccountId } from '@pezkuwi/types/interfaces';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
|
||||
import React, { useMemo, useRef, useState } from 'react';
|
||||
|
||||
import Filtering from '@pezkuwi/app-staking/Filtering';
|
||||
import Legend from '@pezkuwi/app-staking2/Legend';
|
||||
import { Table } from '@pezkuwi/react-components';
|
||||
import { useApi, useBlockAuthors, useNextTick } from '@pezkuwi/react-hooks';
|
||||
|
||||
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,53 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking-async authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { Icon, styled } from '@pezkuwi/react-components';
|
||||
|
||||
interface Props {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
withIcon?: boolean;
|
||||
}
|
||||
|
||||
function StakingAsyncOverview ({ children, className = '', withIcon = true }: Props): React.ReactElement<Props> {
|
||||
return (
|
||||
<StyledArticle className={`${className} mark warning`}>
|
||||
{withIcon && <Icon icon='exclamation-triangle' />}
|
||||
<strong>Staking Async is Pezkuwi's staking system</strong> which elects validators <em>for the Relay Chain</em>, <em>on AssetHub</em>.
|
||||
The actual collators of the AssetHub teyrchain are managed by the collator-selection system. To nominate a Relay Chain validator, please use this page and everything works as before. To setup a validator, please see{' '}
|
||||
<a
|
||||
href='https://docs.google.com/document/d/1X4EjL-7he70vtUumNhEqnUs7XdTCDj8TQpGJhNuAklY/edit?tab=t.0#heading=h.xh97bpw96bkk'
|
||||
rel='noreferrer'
|
||||
target='_blank'
|
||||
>
|
||||
this guide
|
||||
</a>.
|
||||
<br />
|
||||
<br />
|
||||
For more information about Staking Async and AssetHub migration, please see the{' '}
|
||||
<a
|
||||
href='https://docs.google.com/document/d/1XR3vL2p4QV0wC7FrlC8eN-q62BqNFTFElbj21wEmMGg/edit?tab=t.tyioldyxov9u'
|
||||
rel='noreferrer'
|
||||
target='_blank'
|
||||
>
|
||||
Asset Hub Migration FAQ
|
||||
</a>.
|
||||
{children}
|
||||
</StyledArticle>
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
const StyledArticle = styled.article`
|
||||
max-width: 50vw;
|
||||
margin-inline: auto !important;
|
||||
|
||||
.ui--Icon {
|
||||
color: rgba(255, 196, 12, 1);
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(StakingAsyncOverview);
|
||||
@@ -0,0 +1,151 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking-async authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { DeriveStakingOverview } from '@pezkuwi/api-derive/types';
|
||||
import type { SortedTargets } from '@pezkuwi/app-staking/types';
|
||||
import type { Option, u32 } from '@pezkuwi/types-codec';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { CardSummary, styled, SummaryBox } from '@pezkuwi/react-components';
|
||||
import { useApi, useCall } from '@pezkuwi/react-hooks';
|
||||
import { formatNumber } from '@pezkuwi/util';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
import SummarySession from './SummarySession.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
nominators?: string[];
|
||||
stakingOverview?: DeriveStakingOverview;
|
||||
targets: SortedTargets;
|
||||
}
|
||||
|
||||
const OPT_CURRENTERA = {
|
||||
transform: (currentEra: Option<u32>): BN | null =>
|
||||
currentEra.unwrapOr(null)
|
||||
};
|
||||
|
||||
const useActiveNominators = () => {
|
||||
const { api } = useApi();
|
||||
const currentEra = useCall(api.query.staking.currentEra, undefined, OPT_CURRENTERA);
|
||||
const [exposedNominators, setExposedNominators] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const getExposedNominators = async () => {
|
||||
const exposedNominators = (await api.query.staking.erasStakersPaged.entries(currentEra)).map(([_, value]) => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-expect-error
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
|
||||
return (value).unwrap().others.map((x) => x.who.toString());
|
||||
}).flat();
|
||||
|
||||
setExposedNominators([...new Set(exposedNominators)]);
|
||||
};
|
||||
|
||||
getExposedNominators().catch(console.log);
|
||||
}, [api.query.staking.erasStakersPaged, currentEra]);
|
||||
|
||||
return exposedNominators;
|
||||
};
|
||||
|
||||
function Summary ({ className = '', stakingOverview, targets: { counterForNominators, inflation: { idealStake, inflation, stakedFraction }, waitingIds } }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const activeNominators = useActiveNominators();
|
||||
|
||||
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')
|
||||
}
|
||||
>
|
||||
{activeNominators
|
||||
? (
|
||||
<>
|
||||
{formatNumber(activeNominators.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,120 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking-async authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { DeriveSessionProgress } from '@pezkuwi/api-derive/types';
|
||||
import type { Forcing } from '@pezkuwi/types/interfaces';
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { CardSummary } from '@pezkuwi/react-components';
|
||||
import { useApi, useBlockInterval, useCall, useStakingAsyncApis } from '@pezkuwi/react-hooks';
|
||||
import { BN, BN_THREE, BN_TWO, BN_ZERO, formatNumber } from '@pezkuwi/util';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
withEra?: boolean;
|
||||
withSession?: boolean;
|
||||
}
|
||||
|
||||
function SummarySession ({ className, withEra = true, withSession = true }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const { api } = useApi(); // Asset Hub API
|
||||
const blockTime = useBlockInterval();
|
||||
const { rcApi } = useStakingAsyncApis();
|
||||
const sessionInfo = useCall<DeriveSessionProgress>(rcApi?.derive.session?.progress);
|
||||
const ahSessionInfo = useCall<DeriveSessionProgress>(api?.derive.session?.progress);
|
||||
const forcing = useCall<Forcing>(rcApi?.query.staking?.forceEra);
|
||||
|
||||
const eraLabel = t('era');
|
||||
const sessionLabel = rcApi?.query.babe
|
||||
? t('epoch')
|
||||
: t('session');
|
||||
const activeEraStart = sessionInfo?.activeEraStart.unwrapOr(null);
|
||||
|
||||
const eraProgress = useMemo(() => {
|
||||
if (!ahSessionInfo) {
|
||||
return BN_ZERO;
|
||||
}
|
||||
|
||||
const currentEraStart = ahSessionInfo.activeEraStart.unwrapOrDefault();
|
||||
|
||||
if (currentEraStart.isZero()) {
|
||||
return BN_ZERO;
|
||||
}
|
||||
|
||||
const currentTimestamp = new BN(Math.floor(Date.now()));
|
||||
|
||||
const elapsed = currentTimestamp.sub(currentEraStart);
|
||||
|
||||
return elapsed.div(blockTime);
|
||||
}, [ahSessionInfo, blockTime]);
|
||||
|
||||
const eraDuration = useMemo(
|
||||
() => {
|
||||
const epochDuration = rcApi?.consts.babe?.epochDuration;
|
||||
const sessionsPerEra = api?.consts.staking?.sessionsPerEra;
|
||||
|
||||
return epochDuration?.mul(sessionsPerEra);
|
||||
},
|
||||
[api?.consts.staking?.sessionsPerEra, rcApi?.consts.babe?.epochDuration]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{rcApi?.derive.session && (
|
||||
<>
|
||||
{withSession && (
|
||||
rcApi?.query.babe
|
||||
? (
|
||||
<CardSummary
|
||||
apiOverride={rcApi}
|
||||
className={className}
|
||||
label={sessionLabel}
|
||||
progress={{
|
||||
isBlurred: !sessionInfo,
|
||||
total: sessionInfo?.sessionLength || BN_THREE,
|
||||
value: sessionInfo?.sessionProgress || BN_TWO,
|
||||
withTime: true
|
||||
}}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<CardSummary
|
||||
apiOverride={rcApi}
|
||||
label={sessionLabel}
|
||||
>
|
||||
#{sessionInfo
|
||||
? formatNumber(sessionInfo.currentIndex)
|
||||
: <span className='--tmp'>123</span>}
|
||||
{withEra && activeEraStart && <div className='isSecondary'> </div>}
|
||||
</CardSummary>
|
||||
)
|
||||
)}
|
||||
<CardSummary
|
||||
apiOverride={rcApi}
|
||||
className={className}
|
||||
label={eraLabel}
|
||||
progress={{
|
||||
isBlurred: !(sessionInfo && forcing),
|
||||
total: sessionInfo && forcing
|
||||
? forcing.isForceAlways
|
||||
? sessionInfo.sessionLength
|
||||
: eraDuration
|
||||
: BN_THREE,
|
||||
value: sessionInfo && forcing
|
||||
? forcing.isForceAlways
|
||||
? sessionInfo.sessionProgress
|
||||
: eraProgress
|
||||
: BN_TWO,
|
||||
withTime: true
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(SummarySession);
|
||||
@@ -0,0 +1,120 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking-async authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { DeriveHeartbeats, DeriveStakingOverview } from '@pezkuwi/api-derive/types';
|
||||
import type { NominatedByMap, SortedTargets } from '@pezkuwi/app-staking/types';
|
||||
import type { StakerState } from '@pezkuwi/react-hooks/types';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
|
||||
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 StakingAsyncOverview from './StakingAsyncOverview.js';
|
||||
import Summary from './Summary.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
favorites: string[];
|
||||
hasAccounts: boolean;
|
||||
hasQueries: boolean;
|
||||
minCommission?: BN;
|
||||
nominatedBy?: NominatedByMap;
|
||||
ownStashes?: StakerState[];
|
||||
paraValidators?: Record<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`}>
|
||||
<StakingAsyncOverview withIcon />
|
||||
<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,4 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking-async authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
export const STORE_FAVS_BASE = 'staking:favorites';
|
||||
@@ -0,0 +1,79 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking-async authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { AppProps as Props } from '@pezkuwi/react-components/types';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { styled } from '@pezkuwi/react-components';
|
||||
import { useStakingAsyncApis } from '@pezkuwi/react-hooks';
|
||||
|
||||
import StakingRelayApp from './Relay/index.js';
|
||||
import StakingSystemApp from './System/index.js';
|
||||
|
||||
function StakingApp ({ basePath, className = '', onStatusChange }: Props): React.ReactElement<Props> {
|
||||
const { ahApi, ahEndPoints, isRelayChain, rcApi, rcEndPoints } = useStakingAsyncApis();
|
||||
|
||||
return (
|
||||
<StyledMain className={`${className} staking--App`}>
|
||||
{isRelayChain
|
||||
? (
|
||||
<StakingRelayApp
|
||||
ahApi={ahApi}
|
||||
ahEndPoints={ahEndPoints}
|
||||
basePath={basePath}
|
||||
isRelayChain={isRelayChain}
|
||||
onStatusChange={onStatusChange}
|
||||
rcApi={rcApi}
|
||||
rcEndPoints={rcEndPoints}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<StakingSystemApp
|
||||
ahApi={ahApi}
|
||||
ahEndPoints={ahEndPoints}
|
||||
basePath={basePath}
|
||||
isRelayChain={isRelayChain}
|
||||
onStatusChange={onStatusChange}
|
||||
rcApi={rcApi}
|
||||
rcEndPoints={rcEndPoints}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</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-async 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-async');
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": "..",
|
||||
"outDir": "./build",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"references": [
|
||||
{ "path": "../react-components/tsconfig.build.json" },
|
||||
{ "path": "../react-hooks/tsconfig.build.json" },
|
||||
{ "path": "../page-staking2/tsconfig.build.json" },
|
||||
{ "path": "../page-staking/tsconfig.build.json" },
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user