mirror of
https://github.com/pezkuwichain/pezkuwi-apps.git
synced 2026-06-09 20:11:10 +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,123 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { Badge, styled, Tag } from '@pezkuwi/react-components';
|
||||
|
||||
import { useTranslation } from './translate.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
isRelay?: boolean;
|
||||
minCommission?: BN;
|
||||
}
|
||||
|
||||
function Legend ({ className, isRelay, minCommission }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<StyledDiv className={className}>
|
||||
<span>
|
||||
<Badge
|
||||
color='blue'
|
||||
icon='chevron-right'
|
||||
/>
|
||||
<span>{t('Next session')}</span>
|
||||
</span>
|
||||
{minCommission && (
|
||||
<span>
|
||||
<Badge
|
||||
color='red'
|
||||
icon='cancel'
|
||||
/>
|
||||
<span>{t('Chilled')}</span>
|
||||
</span>
|
||||
)}
|
||||
{isRelay && (
|
||||
<span>
|
||||
<Badge
|
||||
color='purple'
|
||||
icon='vector-square'
|
||||
/>
|
||||
<span>{t('Para validator')}</span>
|
||||
</span>
|
||||
)}
|
||||
<span>
|
||||
<Badge
|
||||
color='green'
|
||||
info='5'
|
||||
/>
|
||||
<span>{t('Produced blocks')}</span>
|
||||
</span>
|
||||
<span>
|
||||
<Badge
|
||||
color='green'
|
||||
icon='envelope'
|
||||
/>
|
||||
<span>{t('Online message')}</span>
|
||||
</span>
|
||||
<span>
|
||||
<Badge
|
||||
color='green'
|
||||
icon='hand-paper'
|
||||
/>
|
||||
<span>{t('Nominating')}</span>
|
||||
</span>
|
||||
<span>
|
||||
<Badge
|
||||
color='red'
|
||||
icon='balance-scale-right'
|
||||
/>
|
||||
<span>{t('Oversubscribed')}</span>
|
||||
</span>
|
||||
<span>
|
||||
<Badge
|
||||
color='red'
|
||||
icon='skull-crossbones'
|
||||
/>
|
||||
<span>{t('Slashed')}</span>
|
||||
</span>
|
||||
<span>
|
||||
<Badge
|
||||
color='red'
|
||||
icon='user-slash'
|
||||
/>
|
||||
<span>{t('Blocks nominations')}</span>
|
||||
</span>
|
||||
<span>
|
||||
<Tag
|
||||
color='lightgrey'
|
||||
label='1,220'
|
||||
/>
|
||||
<span>{t('Era points')}</span>
|
||||
</span>
|
||||
</StyledDiv>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledDiv = styled.div`
|
||||
font-size: var(--font-size-small);
|
||||
padding: 1rem 0.5rem;
|
||||
text-align: center;
|
||||
|
||||
.ui--Badge, .ui--Tag {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
span {
|
||||
vertical-align: middle;
|
||||
|
||||
* {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
+ span {
|
||||
margin-left: 1rem;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(Legend);
|
||||
@@ -0,0 +1,124 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
import type { Params } from './types.js';
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
|
||||
import { Button, Input, InputAddress, InputBalance, InputNumber, Modal, TxButton } from '@pezkuwi/react-components';
|
||||
import { useApi, useToggle } from '@pezkuwi/react-hooks';
|
||||
import { bnMax } from '@pezkuwi/util';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
import useAmountError from './useAmountError.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
isDisabled?: boolean;
|
||||
ownAccounts?: string[];
|
||||
params: Params;
|
||||
}
|
||||
|
||||
const MAX_META_LEN = 32;
|
||||
const MIN_META_LEN = 3;
|
||||
|
||||
function Create ({ className, isDisabled, ownAccounts, params: { minCreateBond, minNominatorBond, nextPoolId } }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const { api } = useApi();
|
||||
const [isOpen, toggleOpen] = useToggle();
|
||||
const [accountId, setAccount] = useState<string | null>(null);
|
||||
const [amount, setAmount] = useState<BN | undefined>();
|
||||
const [metadata, setMetadata] = useState('');
|
||||
|
||||
const minValue = useMemo(
|
||||
() => minCreateBond && minNominatorBond &&
|
||||
bnMax(minCreateBond, minNominatorBond, api.consts.balances.existentialDeposit),
|
||||
[api, minCreateBond, minNominatorBond]
|
||||
);
|
||||
|
||||
const isAmountError = useAmountError(accountId, amount, minValue);
|
||||
|
||||
const isMetaError = useMemo(
|
||||
() => !metadata || (metadata.length < MIN_META_LEN) || (metadata.length > MAX_META_LEN),
|
||||
[metadata]
|
||||
);
|
||||
|
||||
const extrinsic = useMemo(
|
||||
() => amount && accountId && !isAmountError && nextPoolId
|
||||
? api.tx.utility.batch([
|
||||
api.tx.nominationPools.create(amount, accountId, accountId, accountId),
|
||||
api.tx.nominationPools.setMetadata(nextPoolId, metadata)
|
||||
])
|
||||
: null,
|
||||
[api, accountId, amount, isAmountError, metadata, nextPoolId]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
icon='plus'
|
||||
isDisabled={isDisabled || !minValue}
|
||||
label={t('Add pool')}
|
||||
onClick={toggleOpen}
|
||||
/>
|
||||
{isOpen && (
|
||||
<Modal
|
||||
className={className}
|
||||
header={t('Create nomination pool')}
|
||||
onClose={toggleOpen}
|
||||
size='large'
|
||||
>
|
||||
<Modal.Content>
|
||||
<Modal.Columns hint={t('The origin account will also be set as the pool admin, nominator and state toggler.')}>
|
||||
<InputAddress
|
||||
filter={ownAccounts}
|
||||
label={t('create pool from')}
|
||||
onChange={setAccount}
|
||||
type='account'
|
||||
value={accountId}
|
||||
withExclude
|
||||
/>
|
||||
</Modal.Columns>
|
||||
<Modal.Columns hint={t('The initial value to assign to the pool. It is set to the maximum of the minimum bond and the minimum nomination value.')}>
|
||||
<InputBalance
|
||||
autoFocus
|
||||
defaultValue={minValue}
|
||||
isError={isAmountError}
|
||||
label={t('initial value')}
|
||||
onChange={setAmount}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
<Modal.Columns hint={t('The metadata description to set for this pool')}>
|
||||
<Input
|
||||
isError={isMetaError}
|
||||
label={t('description')}
|
||||
maxLength={MAX_META_LEN}
|
||||
onChange={setMetadata}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
<Modal.Columns hint={t('The id that will be assigned to this nomination pool.')}>
|
||||
<InputNumber
|
||||
defaultValue={nextPoolId}
|
||||
isDisabled
|
||||
label={t('pool id')}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<TxButton
|
||||
accountId={accountId}
|
||||
extrinsic={extrinsic}
|
||||
icon='plus'
|
||||
isDisabled={!accountId || isAmountError || isMetaError}
|
||||
label={t('Create')}
|
||||
onStart={toggleOpen}
|
||||
/>
|
||||
</Modal.Actions>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Create);
|
||||
@@ -0,0 +1,88 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
import type { Params } from './types.js';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { Button, InputAddress, InputBalance, Modal, TxButton } from '@pezkuwi/react-components';
|
||||
import { useApi, useToggle } from '@pezkuwi/react-hooks';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
import useAmountError from './useAmountError.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
isDisabled?: boolean;
|
||||
ownAccounts?: string[];
|
||||
params: Params;
|
||||
poolId: BN;
|
||||
}
|
||||
|
||||
function Join ({ className, isDisabled, ownAccounts, params: { minMemberBond }, poolId }: Props): React.ReactElement<Props> | null {
|
||||
const { t } = useTranslation();
|
||||
const { api } = useApi();
|
||||
const [isOpen, toggleOpen] = useToggle();
|
||||
const [accountId, setAccount] = useState<string | null>(null);
|
||||
const [amount, setAmount] = useState<BN | undefined>();
|
||||
const isAmountError = useAmountError(accountId, amount, minMemberBond);
|
||||
|
||||
if (isDisabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
icon='plus'
|
||||
isDisabled={!minMemberBond}
|
||||
label={t('Join')}
|
||||
onClick={toggleOpen}
|
||||
/>
|
||||
{isOpen && (
|
||||
<Modal
|
||||
className={className}
|
||||
header={t('Join nomination pool')}
|
||||
onClose={toggleOpen}
|
||||
size='large'
|
||||
>
|
||||
<Modal.Content>
|
||||
<Modal.Columns hint={t('The account that will join the pool.')}>
|
||||
<InputAddress
|
||||
filter={ownAccounts}
|
||||
label={t('join pool from')}
|
||||
onChange={setAccount}
|
||||
type='account'
|
||||
value={accountId}
|
||||
withExclude
|
||||
/>
|
||||
</Modal.Columns>
|
||||
<Modal.Columns hint={t('The initial value to assign to the pool. It is set to the minimum value required to join a pool.')}>
|
||||
<InputBalance
|
||||
autoFocus
|
||||
defaultValue={minMemberBond}
|
||||
isError={isAmountError}
|
||||
label={t('initial value')}
|
||||
onChange={setAmount}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<TxButton
|
||||
accountId={accountId}
|
||||
icon='plus'
|
||||
isDisabled={!accountId || isAmountError}
|
||||
label={t('Join')}
|
||||
onStart={toggleOpen}
|
||||
params={[amount, poolId]}
|
||||
tx={api.tx.nominationPools.join}
|
||||
/>
|
||||
</Modal.Actions>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Join);
|
||||
@@ -0,0 +1,188 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
import type { MembersMapEntry, Params } from './types.js';
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
|
||||
import { AddressMini, ExpandButton, ExpanderScroll, Spinner, styled, Table } from '@pezkuwi/react-components';
|
||||
import { useToggle } from '@pezkuwi/react-hooks';
|
||||
import { FormatBalance } from '@pezkuwi/react-query';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
import Join from './Join.js';
|
||||
import usePoolInfo from './usePoolInfo.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
members: MembersMapEntry[];
|
||||
ownAccounts?: string[];
|
||||
params: Params;
|
||||
poolId: BN;
|
||||
}
|
||||
|
||||
function Pool ({ className = '', members, ownAccounts, params, poolId }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const info = usePoolInfo(poolId);
|
||||
const [isExpanded, toggleExpanded] = useToggle(false);
|
||||
|
||||
const renderMembers = useCallback(
|
||||
() => members.map(({ accountId, member }, count): React.ReactNode => (
|
||||
<AddressMini
|
||||
balance={member.points}
|
||||
key={`${count}:${accountId}`}
|
||||
value={accountId}
|
||||
withBalance
|
||||
withShrink
|
||||
/>
|
||||
)),
|
||||
[members]
|
||||
);
|
||||
|
||||
const renderNominees = useCallback(
|
||||
() => info?.nominating.map((stashId, count): React.ReactNode => (
|
||||
<AddressMini
|
||||
key={`${count}:${stashId}`}
|
||||
value={stashId}
|
||||
withShrink
|
||||
/>
|
||||
)),
|
||||
[info]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledTr className={`${className} isFirst isExpanded ${isExpanded ? '' : 'isLast'}`}>
|
||||
<Table.Column.Id value={poolId} />
|
||||
<td className='start'>
|
||||
<div className={`${isExpanded ? '' : 'clamp'}`}>
|
||||
{info
|
||||
? info.metadata
|
||||
: <span className='--tmp'>This is a pool placeholder</span>}
|
||||
</div>
|
||||
</td>
|
||||
<td className='number media--1100'>
|
||||
{info
|
||||
? info.bonded.state.type
|
||||
: <span className='--tmp'>Destroying</span>}
|
||||
</td>
|
||||
<Table.Column.Balance
|
||||
value={info?.bonded.points}
|
||||
withLoading
|
||||
/>
|
||||
<td className='number media--1400'>{info && !info.rewardClaimable.isZero() && <FormatBalance value={info.rewardClaimable} />}</td>
|
||||
<td className='number'>{info && info.bonded.commission.current.value && <div>{info.bonded.commission.current.value[0]?.toHuman()}</div>}</td>
|
||||
<td className='number'>
|
||||
{info && info.nominating.length !== 0 && (
|
||||
<ExpanderScroll
|
||||
className='media--1300'
|
||||
empty={t('No nominees')}
|
||||
renderChildren={renderNominees}
|
||||
summary={t('Nominees ({{count}})', { replace: { count: info.nominating.length } })}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td className='number'>
|
||||
{members && members.length !== 0 && (
|
||||
<ExpanderScroll
|
||||
className='media--1200'
|
||||
empty={t('No members')}
|
||||
renderChildren={renderMembers}
|
||||
summary={t('Members ({{count}})', { replace: { count: members.length } })}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td className='button'>
|
||||
{info
|
||||
? (
|
||||
<>
|
||||
<Join
|
||||
isDisabled={!info.bonded.state.isOpen || (!!params.maxMembersPerPool && !info.bonded.memberCounter.ltn(params.maxMembersPerPool))}
|
||||
ownAccounts={ownAccounts}
|
||||
params={params}
|
||||
poolId={poolId}
|
||||
/>
|
||||
<ExpandButton
|
||||
expanded={isExpanded}
|
||||
onClick={toggleExpanded}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
: <Spinner noLabel />
|
||||
}
|
||||
</td>
|
||||
</StyledTr>
|
||||
{info && isExpanded && (
|
||||
<StyledTr className={`${className} isExpanded isLast`}>
|
||||
<td colSpan={4}>
|
||||
<div className='label-column-right'>
|
||||
<div className='label'>{t('creator')}</div>
|
||||
<div className='inline-balance'><AddressMini value={info.bonded.roles.depositor} /></div>
|
||||
</div>
|
||||
{info.bonded.roles.root.isSome && (
|
||||
<div className='label-column-right'>
|
||||
<div className='label'>{t('root')}</div>
|
||||
<div className='inline-balance'><AddressMini value={info.bonded.roles.root.unwrap()} /></div>
|
||||
</div>
|
||||
)}
|
||||
{info.bonded.roles.nominator.isSome && (
|
||||
<div className='label-column-right'>
|
||||
<div className='label'>{t('nominator')}</div>
|
||||
<div className='inline-balance'><AddressMini value={info.bonded.roles.nominator.unwrap()} /></div>
|
||||
</div>
|
||||
)}
|
||||
{(info.bonded.roles as { stateToggler?: { isSome: boolean } }).stateToggler?.isSome && (
|
||||
<div className='label-column-right'>
|
||||
<div className='label'>{t('toggler')}</div>
|
||||
<div className='inline-balance'><AddressMini value={(info.bonded.roles as unknown as { stateToggler: { unwrap: () => string } }).stateToggler.unwrap()} /></div>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td colSpan={4}>
|
||||
<div className='label-column-right'>
|
||||
<div className='label'>{t('stash')}</div>
|
||||
<div className='inline-balance'><AddressMini value={info.stashId} /></div>
|
||||
</div>
|
||||
<div className='label-column-right'>
|
||||
<div className='label'>{t('rewards')}</div>
|
||||
<div className='inline-balance'><AddressMini value={info.rewardId} /></div>
|
||||
</div>
|
||||
</td>
|
||||
</StyledTr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledTr = styled.tr`
|
||||
.label-column-right,
|
||||
.label-column-left{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.label {
|
||||
width: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
text-align: right;
|
||||
padding: 0 1.7rem 0 0;
|
||||
line-height: normal;
|
||||
color: var(--color-label);
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
.clamp {
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 1;
|
||||
box-orient: vertical;
|
||||
display: -webkit-box;
|
||||
line-clamp: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(Pool);
|
||||
@@ -0,0 +1,99 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
import type { OwnPool, Params } from './types.js';
|
||||
|
||||
import React, { useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { Button, Table, ToggleGroup } from '@pezkuwi/react-components';
|
||||
import { arrayFlatten } from '@pezkuwi/util';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
import Create from './Create.js';
|
||||
import Pool from './Pool.js';
|
||||
import useMembers from './useMembers.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
ids?: BN[];
|
||||
ownPools?: OwnPool[];
|
||||
params: Params;
|
||||
}
|
||||
|
||||
function Pools ({ className, ids, ownPools, params }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const membersMap = useMembers();
|
||||
const [typeIndex, setTypeIndex] = useState(() => ownPools?.length ? 0 : 1);
|
||||
|
||||
const ownAccounts = useMemo(
|
||||
() => ownPools && arrayFlatten(ownPools.map(({ members }) => Object.keys(members))),
|
||||
[ownPools]
|
||||
);
|
||||
|
||||
const noCreate = useMemo(
|
||||
() => !ids || (!!params.maxPools && (ids.length > params.maxPools)),
|
||||
[ids, params]
|
||||
);
|
||||
|
||||
const filtered = useMemo(
|
||||
() => ownPools && ids
|
||||
? typeIndex
|
||||
? ids
|
||||
: ids.filter((id) => ownPools.some(({ poolId }) => id.eq(poolId)))
|
||||
: undefined,
|
||||
[ids, ownPools, typeIndex]
|
||||
);
|
||||
|
||||
const header = useMemo<[React.ReactNode?, string?, number?][]>(
|
||||
() => [
|
||||
[t('pools'), 'start', 2],
|
||||
[t('state'), 'media--1100'],
|
||||
[t('points')],
|
||||
[t('claimable'), 'media--1400'],
|
||||
[t('commission')],
|
||||
[undefined, undefined, 3]
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
const poolTypes = useRef([
|
||||
{ text: t('Own pools'), value: 'mine' },
|
||||
{ text: t('All pools'), value: 'all' }
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button.Group>
|
||||
<ToggleGroup
|
||||
onChange={setTypeIndex}
|
||||
options={poolTypes.current}
|
||||
value={typeIndex}
|
||||
/>
|
||||
<Create
|
||||
isDisabled={noCreate}
|
||||
ownAccounts={ownAccounts}
|
||||
params={params}
|
||||
/>
|
||||
</Button.Group>
|
||||
<Table
|
||||
className={className}
|
||||
empty={membersMap && filtered && t('No available nomination pools')}
|
||||
emptySpinner={t('Retrieving nomination pools')}
|
||||
header={header}
|
||||
>
|
||||
{membersMap && filtered?.map((poolId) => (
|
||||
<Pool
|
||||
key={poolId.toString()}
|
||||
members={membersMap[poolId.toString()]}
|
||||
ownAccounts={ownAccounts}
|
||||
params={params}
|
||||
poolId={poolId}
|
||||
/>
|
||||
))}
|
||||
</Table>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Pools);
|
||||
@@ -0,0 +1,50 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { Params } from './types.js';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { CardSummary, SummaryBox } from '@pezkuwi/react-components';
|
||||
import { formatNumber, isNumber } from '@pezkuwi/util';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
params: Params;
|
||||
poolCount?: number;
|
||||
}
|
||||
|
||||
function Summary ({ className, params: { maxMembers, maxMembersPerPool, maxPools }, poolCount }: Props): React.ReactElement<Props> | null {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<SummaryBox className={className}>
|
||||
<CardSummary label={t('pools')}>
|
||||
{isNumber(poolCount) && (
|
||||
<>
|
||||
{formatNumber(poolCount)}
|
||||
{maxPools > 0 && (
|
||||
<> / {formatNumber(maxPools)}</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardSummary>
|
||||
<section>
|
||||
{maxMembers > 0 && (
|
||||
<CardSummary label={t('max. members')}>
|
||||
{formatNumber(maxMembers)}
|
||||
</CardSummary>
|
||||
)}
|
||||
{maxMembersPerPool > 0 && (
|
||||
<CardSummary label={t('max. members / pool')}>
|
||||
{formatNumber(maxMembersPerPool)}
|
||||
</CardSummary>
|
||||
)}
|
||||
</section>
|
||||
</SummaryBox>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Summary);
|
||||
@@ -0,0 +1,39 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { OwnPool } from './types.js';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import Pools from './Pools.js';
|
||||
import Summary from './Summary.js';
|
||||
import useOwnPools from './useOwnPools.js';
|
||||
import useParams from './useParams.js';
|
||||
import usePoolIds from './usePoolIds.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
ownPools?: OwnPool[];
|
||||
}
|
||||
|
||||
function NominationPools ({ className, ownPools: ownPoolProp }: Props): React.ReactElement<Props> {
|
||||
const ids = usePoolIds();
|
||||
const ownPools = useOwnPools();
|
||||
const params = useParams();
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<Summary
|
||||
params={params}
|
||||
poolCount={ids?.length}
|
||||
/>
|
||||
<Pools
|
||||
ids={ids}
|
||||
ownPools={ownPools || ownPoolProp}
|
||||
params={params}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(NominationPools);
|
||||
@@ -0,0 +1,51 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { u32 } from '@pezkuwi/types';
|
||||
import type { PalletNominationPoolsBondedPoolInner, PalletNominationPoolsPoolMember, PalletNominationPoolsRewardPool } from '@pezkuwi/types/lookup';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
|
||||
export interface PoolAccounts {
|
||||
rewardId: string;
|
||||
stashId: string;
|
||||
}
|
||||
|
||||
export interface OwnPoolBase {
|
||||
members: Record<string, PalletNominationPoolsPoolMember>;
|
||||
poolId: u32;
|
||||
}
|
||||
|
||||
export interface OwnPool extends OwnPoolBase, PoolAccounts {
|
||||
// nothing additional, only combined
|
||||
}
|
||||
|
||||
export interface Params {
|
||||
lastPoolId: BN;
|
||||
maxMembers: number;
|
||||
maxMembersPerPool: number;
|
||||
maxPools: number;
|
||||
minCreateBond?: BN;
|
||||
minJoinBond?: BN;
|
||||
minMemberBond?: BN;
|
||||
minNominatorBond?: BN;
|
||||
nextPoolId: BN;
|
||||
}
|
||||
|
||||
export interface PoolInfoBase {
|
||||
bonded: PalletNominationPoolsBondedPoolInner;
|
||||
reward: PalletNominationPoolsRewardPool;
|
||||
metadata: string | null;
|
||||
nominating: string[];
|
||||
rewardClaimable: BN;
|
||||
}
|
||||
|
||||
export interface PoolInfo extends PoolInfoBase, PoolAccounts {
|
||||
// nothing extra
|
||||
}
|
||||
|
||||
export interface MembersMapEntry {
|
||||
accountId: string;
|
||||
member: PalletNominationPoolsPoolMember;
|
||||
}
|
||||
|
||||
export type MembersMap = Record<string, MembersMapEntry[]>;
|
||||
@@ -0,0 +1,43 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { DeriveBalancesAll } from '@pezkuwi/api-derive/types';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { createNamedHook, useApi, useCall } from '@pezkuwi/react-hooks';
|
||||
import { BN_ZERO, hexToString } from '@pezkuwi/util';
|
||||
|
||||
// Consider only OpenGov-related locks
|
||||
const openGovLockIds = ['referenda', 'convictionVoting', 'pyconvot'];
|
||||
|
||||
function useAmountErrorImpl (accountId?: string | null, amount?: BN | null, minAmount?: BN | null): boolean {
|
||||
const { api } = useApi();
|
||||
const balances = useCall<DeriveBalancesAll>(!!accountId && api.derive.balances.all, [accountId]);
|
||||
|
||||
return useMemo(
|
||||
() => {
|
||||
if (!amount || amount.isZero() || !minAmount || minAmount.gt(amount) || !balances) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Filter out OpenGov-related locks (those that don't prevent staking)
|
||||
const openGovLocks = balances.lockedBreakdown.filter((lock) => {
|
||||
return openGovLockIds.includes(hexToString(lock.id.toHex()));
|
||||
});
|
||||
|
||||
// Total locked amount that affects staking
|
||||
const openGovLockedBalance = openGovLocks.reduce((acc, lock) => acc.add(lock.amount), BN_ZERO);
|
||||
|
||||
const usableBalance = (balances.transferable || balances.availableBalance)
|
||||
.add(openGovLockedBalance)
|
||||
.sub(api.consts.balances.existentialDeposit);
|
||||
|
||||
return amount.gt(usableBalance);
|
||||
},
|
||||
[api, amount, balances, minAmount]
|
||||
);
|
||||
}
|
||||
|
||||
export default createNamedHook('useAmountError', useAmountErrorImpl);
|
||||
@@ -0,0 +1,108 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { Changes } from '@pezkuwi/react-hooks/useEventChanges';
|
||||
import type { bool, Option, StorageKey, u32, u128 } from '@pezkuwi/types';
|
||||
import type { AccountId32, EventRecord } from '@pezkuwi/types/interfaces';
|
||||
import type { PalletNominationPoolsPoolMember } from '@pezkuwi/types/lookup';
|
||||
import type { MembersMap, MembersMapEntry } from './types.js';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { createNamedHook, useApi, useCall, useEventChanges, useMapEntries } from '@pezkuwi/react-hooks';
|
||||
|
||||
const EMPTY_START: AccountId32[] = [];
|
||||
|
||||
const OPT_ENTRIES = {
|
||||
transform: (entries: [StorageKey<[AccountId32]>, Option<PalletNominationPoolsPoolMember>][]): MembersMap =>
|
||||
entries.reduce((all: MembersMap, [{ args: [accountId] }, optMember]) => {
|
||||
if (optMember.isSome) {
|
||||
const member = optMember.unwrap();
|
||||
const poolId = member.poolId.toString();
|
||||
|
||||
if (!all[poolId]) {
|
||||
all[poolId] = [];
|
||||
}
|
||||
|
||||
all[poolId].push({
|
||||
accountId: accountId.toString(),
|
||||
member
|
||||
});
|
||||
}
|
||||
|
||||
return all;
|
||||
}, {})
|
||||
};
|
||||
|
||||
const OPT_MULTI = {
|
||||
transform: ([[ids], values]: [[AccountId32[]], Option<PalletNominationPoolsPoolMember>[]]): MembersMapEntry[] =>
|
||||
ids
|
||||
.filter((_, i) => values[i].isSome)
|
||||
.map((accountId, i) => ({
|
||||
accountId: accountId.toString(),
|
||||
member: values[i].unwrap()
|
||||
})),
|
||||
withParamsTransform: true
|
||||
};
|
||||
|
||||
function filterEvents (records: EventRecord[]): Changes<AccountId32> {
|
||||
const added: AccountId32[] = [];
|
||||
const removed: AccountId32[] = [];
|
||||
|
||||
records.forEach(({ event: { data, method } }): void => {
|
||||
if (method === 'Bonded') {
|
||||
const [accountId,,, joined] = data as unknown as [AccountId32, u32, u128, bool];
|
||||
|
||||
if (joined.isTrue) {
|
||||
added.push(accountId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { added, removed };
|
||||
}
|
||||
|
||||
function interleave (prev: MembersMap, additions: MembersMapEntry[]): MembersMap {
|
||||
return additions.reduce<MembersMap>((all, entry) => {
|
||||
const poolId = entry.member.poolId.toString();
|
||||
const arr: MembersMapEntry[] = [];
|
||||
|
||||
if (all[poolId]) {
|
||||
all[poolId].forEach((prev): void => {
|
||||
if (prev.accountId !== entry.accountId) {
|
||||
arr.push(prev);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
arr.push(entry);
|
||||
|
||||
all[poolId] = arr;
|
||||
|
||||
return all;
|
||||
}, { ...prev });
|
||||
}
|
||||
|
||||
function useMembersImpl (): MembersMap | undefined {
|
||||
const { api } = useApi();
|
||||
const [membersMap, setMembersMap] = useState<MembersMap | undefined>();
|
||||
const queryMap = useMapEntries(api.query.nominationPools.poolMembers, [], OPT_ENTRIES);
|
||||
const ids = useEventChanges([
|
||||
api.events.nominationPools.Bonded
|
||||
], filterEvents, EMPTY_START);
|
||||
const additions = useCall(ids && ids.length !== 0 && api.query.nominationPools.poolMembers.multi, [ids], OPT_MULTI);
|
||||
|
||||
// initial entries
|
||||
useEffect((): void => {
|
||||
queryMap && setMembersMap(queryMap);
|
||||
}, [queryMap]);
|
||||
|
||||
// additions via events
|
||||
useEffect((): void => {
|
||||
additions && setMembersMap((prev) => prev && interleave(prev, additions));
|
||||
}, [additions]);
|
||||
|
||||
return membersMap;
|
||||
}
|
||||
|
||||
export default createNamedHook('useMembers', useMembersImpl);
|
||||
@@ -0,0 +1,52 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { Option } from '@pezkuwi/types';
|
||||
import type { PalletNominationPoolsPoolMember } from '@pezkuwi/types/lookup';
|
||||
import type { OwnPool, OwnPoolBase } from './types.js';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { createNamedHook, useAccounts, useApi, useCall } from '@pezkuwi/react-hooks';
|
||||
|
||||
import { createAccounts } from './usePoolAccounts.js';
|
||||
|
||||
const OPT_MULTI = {
|
||||
transform: ([[ids], opts]: [[string[]], Option<PalletNominationPoolsPoolMember>[]]): OwnPoolBase[] => {
|
||||
const pools: OwnPoolBase[] = [];
|
||||
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
if (opts[i].isSome) {
|
||||
const member = opts[i].unwrap();
|
||||
let entry = pools.find(({ poolId }) => poolId.eq(member.poolId));
|
||||
|
||||
if (!entry) {
|
||||
entry = { members: {}, poolId: member.poolId };
|
||||
|
||||
pools.push(entry);
|
||||
}
|
||||
|
||||
entry.members[ids[i]] = member;
|
||||
}
|
||||
}
|
||||
|
||||
return pools;
|
||||
},
|
||||
withParamsTransform: true
|
||||
};
|
||||
|
||||
function useOwnPoolsImpl (): OwnPool[] | undefined {
|
||||
const { api } = useApi();
|
||||
const { allAccounts } = useAccounts();
|
||||
const base = useCall(api.query.nominationPools?.poolMembers.multi, [allAccounts], OPT_MULTI);
|
||||
|
||||
return useMemo(
|
||||
() => base?.map((base) => ({
|
||||
...base,
|
||||
...createAccounts(api, base.poolId)
|
||||
})),
|
||||
[api, base]
|
||||
);
|
||||
}
|
||||
|
||||
export default createNamedHook('useOwnPools', useOwnPoolsImpl);
|
||||
@@ -0,0 +1,46 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { Option, u32 } from '@pezkuwi/types';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
import type { Params } from './types.js';
|
||||
|
||||
import { createNamedHook, useApi, useCallMulti } from '@pezkuwi/react-hooks';
|
||||
import { BN_ONE, BN_ZERO } from '@pezkuwi/util';
|
||||
|
||||
const OPT_MULTI = {
|
||||
defaultValue: {
|
||||
lastPoolId: BN_ZERO,
|
||||
maxMembers: 0,
|
||||
maxMembersPerPool: 0,
|
||||
maxPools: 0,
|
||||
nextPoolId: BN_ONE
|
||||
},
|
||||
transform: ([lastPoolId, maxPoolMembers, maxPoolMembersPerPool, maxPools, minCreateBond, minJoinBond, minNominatorBond]: [BN, Option<u32>, Option<u32>, Option<u32>, BN, BN, BN]): Params => ({
|
||||
lastPoolId,
|
||||
maxMembers: maxPoolMembers.unwrapOr(BN_ZERO).toNumber(),
|
||||
maxMembersPerPool: maxPoolMembersPerPool.unwrapOr(BN_ZERO).toNumber(),
|
||||
maxPools: maxPools.unwrapOr(BN_ZERO).toNumber(),
|
||||
minCreateBond,
|
||||
minJoinBond,
|
||||
minMemberBond: minJoinBond,
|
||||
minNominatorBond,
|
||||
nextPoolId: lastPoolId.add(BN_ONE)
|
||||
})
|
||||
};
|
||||
|
||||
function useParamsImpl (): Params {
|
||||
const { api } = useApi();
|
||||
|
||||
return useCallMulti<Params>([
|
||||
api.query.nominationPools.lastPoolId,
|
||||
api.query.nominationPools.maxPoolMembers,
|
||||
api.query.nominationPools.maxPoolMembersPerPool,
|
||||
api.query.nominationPools.maxPools,
|
||||
api.query.nominationPools.minCreateBond,
|
||||
api.query.nominationPools.minJoinBond,
|
||||
api.query.staking.minNominatorBond
|
||||
], OPT_MULTI);
|
||||
}
|
||||
|
||||
export default createNamedHook('useParams', useParamsImpl);
|
||||
@@ -0,0 +1,45 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { ApiPromise } from '@pezkuwi/api';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
import type { PoolAccounts } from './types.js';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { createNamedHook, useApi } from '@pezkuwi/react-hooks';
|
||||
import { bnToU8a, stringToU8a, u8aConcat } from '@pezkuwi/util';
|
||||
|
||||
const EMPTY_H256 = new Uint8Array(32);
|
||||
const MOD_PREFIX = stringToU8a('modl');
|
||||
const U32_OPTS = { bitLength: 32, isLe: true };
|
||||
|
||||
function createAccount (api: ApiPromise, palletId: Uint8Array, poolId: BN, index: number): string {
|
||||
return api.registry.createType('AccountId32', u8aConcat(
|
||||
MOD_PREFIX,
|
||||
palletId,
|
||||
new Uint8Array([index]),
|
||||
bnToU8a(poolId, U32_OPTS),
|
||||
EMPTY_H256
|
||||
)).toString();
|
||||
}
|
||||
|
||||
export function createAccounts (api: ApiPromise, poolId: BN): PoolAccounts {
|
||||
const palletId = api.consts.nominationPools.palletId.toU8a();
|
||||
|
||||
return {
|
||||
rewardId: createAccount(api, palletId, poolId, 1),
|
||||
stashId: createAccount(api, palletId, poolId, 0)
|
||||
};
|
||||
}
|
||||
|
||||
function usePoolAccountsImpl (poolId: BN): PoolAccounts {
|
||||
const { api } = useApi();
|
||||
|
||||
return useMemo(
|
||||
() => createAccounts(api, poolId),
|
||||
[api, poolId]
|
||||
);
|
||||
}
|
||||
|
||||
export default createNamedHook('usePoolAccounts', usePoolAccountsImpl);
|
||||
@@ -0,0 +1,42 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { Changes } from '@pezkuwi/react-hooks/useEventChanges';
|
||||
import type { StorageKey, u32 } from '@pezkuwi/types';
|
||||
import type { EventRecord } from '@pezkuwi/types/interfaces';
|
||||
|
||||
import { createNamedHook, useApi, useEventChanges, useMapKeys } from '@pezkuwi/react-hooks';
|
||||
|
||||
const OPT_KEYS = {
|
||||
transform: (keys: StorageKey<[u32]>[]): u32[] =>
|
||||
keys
|
||||
.map(({ args: [poolId] }) => poolId)
|
||||
.sort((a, b) => a.cmp(b))
|
||||
};
|
||||
|
||||
function filterEvents (records: EventRecord[]): Changes<u32> {
|
||||
const added: u32[] = [];
|
||||
const removed: u32[] = [];
|
||||
|
||||
records.forEach(({ event: { data, method } }): void => {
|
||||
if (method === 'Created') {
|
||||
added.push(data[1] as u32);
|
||||
} else {
|
||||
removed.push(data[0] as u32);
|
||||
}
|
||||
});
|
||||
|
||||
return { added, removed };
|
||||
}
|
||||
|
||||
function usePoolIdsImpl (): u32[] | undefined {
|
||||
const { api } = useApi();
|
||||
const startValue = useMapKeys(api.query.nominationPools.bondedPools, [], OPT_KEYS);
|
||||
|
||||
return useEventChanges([
|
||||
api.events.nominationPools.Created,
|
||||
api.events.nominationPools.Destroyed
|
||||
], filterEvents, startValue);
|
||||
}
|
||||
|
||||
export default createNamedHook('usePoolIds', usePoolIdsImpl);
|
||||
@@ -0,0 +1,63 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { Bytes, Option } from '@pezkuwi/types';
|
||||
import type { FrameSystemAccountInfo, PalletNominationPoolsBondedPoolInner, PalletNominationPoolsRewardPool, PalletStakingNominations } from '@pezkuwi/types/lookup';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
import type { PoolInfo, PoolInfoBase } from './types.js';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { createNamedHook, useApi, useCallMulti } from '@pezkuwi/react-hooks';
|
||||
import { BN_ZERO, bnMax } from '@pezkuwi/util';
|
||||
|
||||
import usePoolAccounts from './usePoolAccounts.js';
|
||||
|
||||
const OPT_MULTI = {
|
||||
defaultValue: null,
|
||||
transform: ([optBonded, metadata, optReward, optNominating, accountInfo]: [Option<PalletNominationPoolsBondedPoolInner>, Bytes, Option<PalletNominationPoolsRewardPool>, Option<PalletStakingNominations>, FrameSystemAccountInfo]): PoolInfoBase | null =>
|
||||
optBonded.isSome && optReward.isSome
|
||||
? {
|
||||
bonded: optBonded.unwrap(),
|
||||
metadata: metadata.length
|
||||
? transformName(
|
||||
metadata.isUtf8
|
||||
? metadata.toUtf8()
|
||||
: metadata.toString()
|
||||
)
|
||||
: null,
|
||||
nominating: optNominating
|
||||
.unwrapOr({ targets: [] })
|
||||
.targets.map((n) => n.toString()),
|
||||
reward: optReward.unwrap(),
|
||||
rewardClaimable: accountInfo.data.free
|
||||
}
|
||||
: null
|
||||
};
|
||||
|
||||
function transformName (input: string): string {
|
||||
return input.replace(/[^\x20-\x7E]/g, '');
|
||||
}
|
||||
|
||||
function usePoolInfoImpl (poolId: BN): PoolInfo | null | undefined {
|
||||
const { api } = useApi();
|
||||
const accounts = usePoolAccounts(poolId);
|
||||
const baseInfo = useCallMulti([
|
||||
[api.query.nominationPools.bondedPools, poolId],
|
||||
[api.query.nominationPools.metadata, poolId],
|
||||
[api.query.nominationPools.rewardPools, poolId],
|
||||
[api.query.staking.nominators, accounts.stashId],
|
||||
[api.query.system.account, accounts.rewardId]
|
||||
], OPT_MULTI);
|
||||
|
||||
return useMemo(
|
||||
() => baseInfo && {
|
||||
...accounts,
|
||||
...baseInfo,
|
||||
rewardClaimable: bnMax(BN_ZERO, baseInfo.rewardClaimable.sub(api.consts.balances.existentialDeposit))
|
||||
},
|
||||
[api, baseInfo, accounts]
|
||||
);
|
||||
}
|
||||
|
||||
export default createNamedHook('usePoolInfo', usePoolInfoImpl);
|
||||
@@ -0,0 +1,103 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { SessionInfo, Validator } from '../../types.js';
|
||||
|
||||
import React, { useMemo, useRef } from 'react';
|
||||
|
||||
import { Table, Tag } from '@pezkuwi/react-components';
|
||||
import { useToggle } from '@pezkuwi/react-hooks';
|
||||
import { FormatBalance } from '@pezkuwi/react-query';
|
||||
import { formatNumber } from '@pezkuwi/util';
|
||||
|
||||
import useExposure from '../useExposure.js';
|
||||
import useHeartbeat from '../useHeartbeat.js';
|
||||
import Bottom from './Row/Bottom.js';
|
||||
import Middle from './Row/Middle.js';
|
||||
import Top from './Row/Top.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
points?: number;
|
||||
sessionInfo: SessionInfo;
|
||||
toggleFavorite: (stashId: string) => void;
|
||||
validator: Validator;
|
||||
}
|
||||
|
||||
interface PropsExpanded {
|
||||
className?: string;
|
||||
validator: Validator;
|
||||
}
|
||||
|
||||
function EntryExpanded ({ className = '' }: PropsExpanded): React.ReactElement<PropsExpanded> {
|
||||
return <td className={className} />;
|
||||
}
|
||||
|
||||
function Entry ({ className = '', points, sessionInfo, toggleFavorite, validator }: Props): React.ReactElement<Props> {
|
||||
const [isExpanded, toggleExpanded] = useToggle();
|
||||
const pointsRef = useRef<{ counter: number, points: number }>({ counter: 0, points: 0 });
|
||||
const exposure = useExposure(validator, sessionInfo);
|
||||
const heartbeat = useHeartbeat(validator, sessionInfo);
|
||||
|
||||
const pointsAnimClass = useMemo(
|
||||
(): string => {
|
||||
if (!points || pointsRef.current.points === points) {
|
||||
return '';
|
||||
}
|
||||
|
||||
pointsRef.current.points = points;
|
||||
pointsRef.current.counter = (pointsRef.current.counter + 1) % 25;
|
||||
|
||||
return `greyAnim-${pointsRef.current.counter}`;
|
||||
},
|
||||
[points, pointsRef]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Top
|
||||
className={className}
|
||||
heartbeat={heartbeat}
|
||||
isExpanded={isExpanded}
|
||||
toggleExpanded={toggleExpanded}
|
||||
toggleFavorite={toggleFavorite}
|
||||
validator={validator}
|
||||
>
|
||||
{points && (
|
||||
<Tag
|
||||
className={`${pointsAnimClass} absolute`}
|
||||
color='lightgrey'
|
||||
label={formatNumber(points)}
|
||||
/>
|
||||
)}
|
||||
</Top>
|
||||
<Middle
|
||||
className={className}
|
||||
isExpanded={isExpanded}
|
||||
>
|
||||
<Table.Column.Balance
|
||||
className='relative'
|
||||
label={
|
||||
exposure?.waiting && (
|
||||
<>
|
||||
<span>(</span>
|
||||
<FormatBalance value={exposure.waiting.total} />
|
||||
<span>, {exposure.waiting.others.length})</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
value={exposure?.clipped?.total}
|
||||
withLoading
|
||||
/>
|
||||
</Middle>
|
||||
<Bottom
|
||||
className={className}
|
||||
isExpanded={isExpanded}
|
||||
>
|
||||
<EntryExpanded validator={validator} />
|
||||
</Bottom>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Entry);
|
||||
@@ -0,0 +1,27 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
isExpanded: boolean;
|
||||
}
|
||||
|
||||
function Bottom ({ children, className = '', isExpanded }: Props): React.ReactElement<Props> | null {
|
||||
if (!isExpanded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<tr className={`${className} isExpanded isLast`}>
|
||||
<td />
|
||||
<td />
|
||||
{children}
|
||||
<td />
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Bottom);
|
||||
@@ -0,0 +1,22 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
isExpanded: boolean;
|
||||
}
|
||||
|
||||
function Middle ({ children, className = '', isExpanded }: Props): React.ReactElement<Props> {
|
||||
return (
|
||||
<tr className={`${className} isExpanded ${isExpanded ? '' : 'isLast'} packedTop`}>
|
||||
<td />
|
||||
{children}
|
||||
<td />
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Middle);
|
||||
@@ -0,0 +1,52 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { Validator } from '../../../types.js';
|
||||
import type { UseHeartbeat } from '../../types.js';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { AddressSmall, Table } from '@pezkuwi/react-components';
|
||||
|
||||
import Status from '../Status.js';
|
||||
|
||||
interface Props {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
heartbeat?: UseHeartbeat;
|
||||
isExpanded: boolean;
|
||||
toggleExpanded: () => void;
|
||||
toggleFavorite: (stashId: string) => void;
|
||||
validator: Validator;
|
||||
}
|
||||
|
||||
function Top ({ children, className = '', heartbeat, isExpanded, toggleExpanded, toggleFavorite, validator }: Props): React.ReactElement<Props> {
|
||||
return (
|
||||
<tr className={`${className} isExpanded isFirst packedBottom`}>
|
||||
<Table.Column.Favorite
|
||||
address={validator.stashId}
|
||||
isFavorite={validator.isFavorite}
|
||||
toggle={toggleFavorite}
|
||||
/>
|
||||
<td
|
||||
className='statusInfo'
|
||||
rowSpan={2}
|
||||
>
|
||||
<Status
|
||||
heartbeat={heartbeat}
|
||||
validator={validator}
|
||||
/>
|
||||
</td>
|
||||
<td className='address relative all'>
|
||||
<AddressSmall value={validator.stashId} />
|
||||
{children}
|
||||
</td>
|
||||
<Table.Column.Expand
|
||||
isExpanded={isExpanded}
|
||||
toggle={toggleExpanded}
|
||||
/>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Top);
|
||||
@@ -0,0 +1,100 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { Validator } from '../../types.js';
|
||||
import type { UseHeartbeat } from '../types.js';
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { Badge, styled } from '@pezkuwi/react-components';
|
||||
import { useAccounts } from '@pezkuwi/react-hooks';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
heartbeat?: UseHeartbeat;
|
||||
isChilled?: boolean;
|
||||
nominators?: string[];
|
||||
validator: Validator;
|
||||
}
|
||||
|
||||
function Status ({ className, heartbeat: { authoredBlocks, isOnline } = {}, isChilled, nominators, validator: { isElected, isPara } }: Props): React.ReactElement<Props> {
|
||||
const { allAccounts } = useAccounts();
|
||||
|
||||
const isNominating = useMemo(
|
||||
() => nominators && nominators.some((a) => allAccounts.includes(a)),
|
||||
[allAccounts, nominators]
|
||||
);
|
||||
|
||||
const emptyBadge = (
|
||||
<Badge
|
||||
className='opaque'
|
||||
color='gray'
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledDiv className={className}>
|
||||
{isNominating
|
||||
? (
|
||||
<Badge
|
||||
color='green'
|
||||
icon='hand-paper'
|
||||
/>
|
||||
)
|
||||
: emptyBadge
|
||||
}
|
||||
{isPara
|
||||
? (
|
||||
<Badge
|
||||
color='purple'
|
||||
icon='vector-square'
|
||||
/>
|
||||
)
|
||||
: emptyBadge
|
||||
}
|
||||
{isChilled
|
||||
? (
|
||||
<Badge
|
||||
color='red'
|
||||
icon='cancel'
|
||||
/>
|
||||
)
|
||||
: isElected
|
||||
? (
|
||||
<Badge
|
||||
color='blue'
|
||||
icon='chevron-right'
|
||||
/>
|
||||
)
|
||||
: emptyBadge
|
||||
}
|
||||
{isOnline
|
||||
? authoredBlocks
|
||||
? (
|
||||
<Badge
|
||||
color='green'
|
||||
info={
|
||||
<span className='authoredBlocks'>{authoredBlocks}</span>
|
||||
}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<Badge
|
||||
color='green'
|
||||
icon='envelope'
|
||||
/>
|
||||
)
|
||||
: emptyBadge
|
||||
}
|
||||
</StyledDiv>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledDiv = styled.div`
|
||||
.authoredBlocks {
|
||||
vertical-align: top;
|
||||
font-size: var(--font-percent-tiny);
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(Status);
|
||||
@@ -0,0 +1,55 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { SessionInfo, Validator } from '../../types.js';
|
||||
import type { UsePoints } from '../types.js';
|
||||
|
||||
import React, { useRef } from 'react';
|
||||
|
||||
import { Table } from '@pezkuwi/react-components';
|
||||
import { useNextTick } from '@pezkuwi/react-hooks';
|
||||
|
||||
import { useTranslation } from '../../translate.js';
|
||||
import Entry from './Entry.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
legend: React.ReactNode;
|
||||
points?: UsePoints;
|
||||
sessionInfo: SessionInfo;
|
||||
toggleFavorite: (stashId: string) => void;
|
||||
validatorsActive?: Validator[];
|
||||
}
|
||||
|
||||
function Active ({ className = '', legend, points, sessionInfo, toggleFavorite, validatorsActive }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const isNextTick = useNextTick();
|
||||
|
||||
const header = useRef<[string?, string?, number?][]>([
|
||||
// favorite, badges, details, expand
|
||||
[t('validators'), 'start', 4]
|
||||
]);
|
||||
|
||||
return (
|
||||
<Table
|
||||
className={className}
|
||||
empty={isNextTick && validatorsActive && t('No session validators found')}
|
||||
emptySpinner={t('Retrieving session validators')}
|
||||
header={header.current}
|
||||
isSplit
|
||||
legend={legend}
|
||||
>
|
||||
{isNextTick && validatorsActive?.map((v) => (
|
||||
<Entry
|
||||
key={v.key}
|
||||
points={points?.[v.stashId]}
|
||||
sessionInfo={sessionInfo}
|
||||
toggleFavorite={toggleFavorite}
|
||||
validator={v}
|
||||
/>
|
||||
))}
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Active);
|
||||
@@ -0,0 +1,57 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { Validator } from '../../types.js';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { useToggle } from '@pezkuwi/react-hooks';
|
||||
|
||||
import Bottom from '../Active/Row/Bottom.js';
|
||||
import Middle from '../Active/Row/Middle.js';
|
||||
import Top from '../Active/Row/Top.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
toggleFavorite: (stashId: string) => void;
|
||||
validator: Validator;
|
||||
}
|
||||
|
||||
interface PropsExpanded {
|
||||
className?: string;
|
||||
validator: Validator;
|
||||
}
|
||||
|
||||
function EntryExpanded ({ className = '' }: PropsExpanded): React.ReactElement<PropsExpanded> {
|
||||
return <td className={className} />;
|
||||
}
|
||||
|
||||
function Entry ({ className = '', toggleFavorite, validator }: Props): React.ReactElement<Props> {
|
||||
const [isExpanded, toggleExpanded] = useToggle();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Top
|
||||
className={className}
|
||||
isExpanded={isExpanded}
|
||||
toggleExpanded={toggleExpanded}
|
||||
toggleFavorite={toggleFavorite}
|
||||
validator={validator}
|
||||
/>
|
||||
<Middle
|
||||
className={className}
|
||||
isExpanded={isExpanded}
|
||||
>
|
||||
<td />
|
||||
</Middle>
|
||||
<Bottom
|
||||
className={className}
|
||||
isExpanded={isExpanded}
|
||||
>
|
||||
<EntryExpanded validator={validator} />
|
||||
</Bottom>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Entry);
|
||||
@@ -0,0 +1,54 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { SessionInfo, Validator } from '../../types.js';
|
||||
|
||||
import React, { useRef } from 'react';
|
||||
|
||||
import { Table } from '@pezkuwi/react-components';
|
||||
import { useNextTick } from '@pezkuwi/react-hooks';
|
||||
|
||||
import { useTranslation } from '../../translate.js';
|
||||
import useValidatorsWaiting from '../../useValidatorsWaiting.js';
|
||||
import Entry from './Entry.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
favorites: string[];
|
||||
legend: React.ReactNode;
|
||||
sessionInfo: SessionInfo;
|
||||
toggleFavorite: (stashId: string) => void;
|
||||
validatorsActive?: Validator[];
|
||||
}
|
||||
|
||||
function Waiting ({ className = '', favorites, legend, sessionInfo, toggleFavorite, validatorsActive }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const isNextTick = useNextTick();
|
||||
const validatorsWaiting = useValidatorsWaiting(favorites, sessionInfo, validatorsActive);
|
||||
|
||||
const header = useRef<[string?, string?, number?][]>([
|
||||
// favorite, badges, details, expand
|
||||
[t('waiting'), 'start', 4]
|
||||
]);
|
||||
|
||||
return (
|
||||
<Table
|
||||
className={className}
|
||||
empty={isNextTick && validatorsWaiting && t('No waiting validators found')}
|
||||
emptySpinner={t('Retrieving waiting validators')}
|
||||
header={header.current}
|
||||
isSplit
|
||||
legend={legend}
|
||||
>
|
||||
{isNextTick && validatorsWaiting?.map((v) => (
|
||||
<Entry
|
||||
key={v.key}
|
||||
toggleFavorite={toggleFavorite}
|
||||
validator={v}
|
||||
/>
|
||||
))}
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Waiting);
|
||||
@@ -0,0 +1,98 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { SessionInfo } from '../types.js';
|
||||
|
||||
import React, { useRef, useState } from 'react';
|
||||
|
||||
import { Button, styled, ToggleGroup } from '@pezkuwi/react-components';
|
||||
|
||||
import Legend from '../Legend.js';
|
||||
import { useTranslation } from '../translate.js';
|
||||
import useValidatorsActive from '../useValidatorsActive.js';
|
||||
import Active from './Active/index.js';
|
||||
import Waiting from './Waiting/index.js';
|
||||
import usePoints from './usePoints.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
favorites: string[];
|
||||
isRelay: boolean;
|
||||
sessionInfo: SessionInfo;
|
||||
toggleFavorite: (stashId: string) => void;
|
||||
}
|
||||
|
||||
function Validators ({ className = '', favorites, isRelay, sessionInfo, toggleFavorite }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const [intentIndex, setIntentIndex] = useState(0);
|
||||
const validatorsActive = useValidatorsActive(favorites, sessionInfo);
|
||||
const points = usePoints(sessionInfo);
|
||||
|
||||
const intentOptions = useRef([
|
||||
{ text: t('Active'), value: 'active' },
|
||||
{ text: t('Waiting'), value: 'waiting' }
|
||||
]);
|
||||
|
||||
const legend = <Legend isRelay={isRelay} />;
|
||||
|
||||
return (
|
||||
<StyledDiv className={className}>
|
||||
<Button.Group>
|
||||
<ToggleGroup
|
||||
onChange={setIntentIndex}
|
||||
options={intentOptions.current}
|
||||
value={intentIndex}
|
||||
/>
|
||||
</Button.Group>
|
||||
{intentIndex === 0
|
||||
? (
|
||||
<Active
|
||||
legend={legend}
|
||||
points={points}
|
||||
sessionInfo={sessionInfo}
|
||||
toggleFavorite={toggleFavorite}
|
||||
validatorsActive={validatorsActive}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<Waiting
|
||||
favorites={favorites}
|
||||
legend={legend}
|
||||
sessionInfo={sessionInfo}
|
||||
toggleFavorite={toggleFavorite}
|
||||
validatorsActive={validatorsActive}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</StyledDiv>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledDiv = styled.div`
|
||||
.ui--Table table {
|
||||
td.statusInfo {
|
||||
padding: 0 0 0 0.5rem;
|
||||
vertical-align: middle;
|
||||
|
||||
> div {
|
||||
display: inline-block;
|
||||
max-width: 3.6rem;
|
||||
min-width: 3.6rem;
|
||||
|
||||
.ui--Badge {
|
||||
margin: 0.125rem;
|
||||
|
||||
&.opaque {
|
||||
opacity: var(--opacity-gray);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+ td.address {
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(Validators);
|
||||
@@ -0,0 +1,31 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
|
||||
export type UsePoints = Record<string, number>;
|
||||
|
||||
export interface UseHeartbeat {
|
||||
authoredBlocks?: number;
|
||||
isOnline?: boolean;
|
||||
}
|
||||
|
||||
export interface UseExposureExposureEntry {
|
||||
who: string,
|
||||
value: BN
|
||||
}
|
||||
|
||||
export interface UseExposureExposure {
|
||||
others: UseExposureExposureEntry[];
|
||||
own: BN;
|
||||
total: BN;
|
||||
}
|
||||
|
||||
export interface UseExposure {
|
||||
clipped?: UseExposureExposure;
|
||||
exposure?: UseExposureExposure;
|
||||
waiting?: {
|
||||
others: UseExposureExposureEntry[];
|
||||
total: BN;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { SpStakingExposure } from '@pezkuwi/types/lookup';
|
||||
import type { SessionInfo, Validator } from '../types.js';
|
||||
import type { UseExposure, UseExposureExposure } from './types.js';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { createNamedHook, useApi, useCall } from '@pezkuwi/react-hooks';
|
||||
import { BN } from '@pezkuwi/util';
|
||||
|
||||
import { useCacheMap } from '../useCache.js';
|
||||
|
||||
const OPT_EXPOSURE = {
|
||||
transform: ({ others, own, total }: SpStakingExposure): UseExposureExposure => ({
|
||||
others: others
|
||||
.map(({ value, who }) => ({
|
||||
value: value.unwrap(),
|
||||
who: who.toString()
|
||||
}))
|
||||
.sort((a, b) => (b.value as BN).cmp(a.value)),
|
||||
own: own.unwrap(),
|
||||
total: total.unwrap()
|
||||
})
|
||||
};
|
||||
|
||||
function getResult (exposure: UseExposureExposure, clipped: UseExposureExposure): UseExposure {
|
||||
let waiting: UseExposure['waiting'];
|
||||
|
||||
const others = exposure.others.filter(({ who }) =>
|
||||
!clipped.others.find((c) =>
|
||||
who === c.who
|
||||
)
|
||||
);
|
||||
|
||||
if (others.length) {
|
||||
waiting = {
|
||||
others,
|
||||
total: others.reduce((total, { value }) => total.iadd(value), new BN(0))
|
||||
};
|
||||
}
|
||||
|
||||
return { clipped, exposure, waiting };
|
||||
}
|
||||
|
||||
function useExposureImpl ({ stashId }: Validator, { activeEra }: SessionInfo): UseExposure | undefined {
|
||||
const { api } = useApi();
|
||||
|
||||
const params = useMemo(
|
||||
() => activeEra && [activeEra, stashId],
|
||||
[activeEra, stashId]
|
||||
);
|
||||
|
||||
const fullExposure = useCall(params && api.query.staking.erasStakers, params, OPT_EXPOSURE);
|
||||
const clipExposure = useCall(params && api.query.staking.erasStakersClipped, params, OPT_EXPOSURE);
|
||||
|
||||
const result = useMemo(
|
||||
() => fullExposure && clipExposure && getResult(fullExposure, clipExposure),
|
||||
[clipExposure, fullExposure]
|
||||
);
|
||||
|
||||
return useCacheMap('useExposure', stashId, result);
|
||||
}
|
||||
|
||||
export default createNamedHook('useExposure', useExposureImpl);
|
||||
@@ -0,0 +1,56 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { Option, u32 } from '@pezkuwi/types';
|
||||
import type { Codec } from '@pezkuwi/types/types';
|
||||
import type { SessionInfo, Validator } from '../types.js';
|
||||
import type { UseHeartbeat } from './types.js';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { createNamedHook, useApi, useCall } from '@pezkuwi/react-hooks';
|
||||
import { isBoolean, isNumber } from '@pezkuwi/util';
|
||||
|
||||
import { useCacheMap } from '../useCache.js';
|
||||
|
||||
const EMPTY: UseHeartbeat = {};
|
||||
|
||||
const OPT_BLOCKS = {
|
||||
transform: (authoredBlocks: u32): number =>
|
||||
authoredBlocks.toNumber()
|
||||
};
|
||||
|
||||
const OPT_BEATS = {
|
||||
// Option<WrapperOpaque<PalletImOnlineBoundedOpaqueNetworkState>>
|
||||
transform: (receivedHeartbeats: Option<Codec>): boolean =>
|
||||
receivedHeartbeats.isSome
|
||||
};
|
||||
|
||||
function useHeartbeatImpl ({ stashId, stashIndex }: Validator, { currentSession }: SessionInfo): UseHeartbeat {
|
||||
const { api } = useApi();
|
||||
|
||||
const params = useMemo(
|
||||
() => stashIndex === -1
|
||||
? undefined
|
||||
: currentSession && ({
|
||||
authoredBlocks: [currentSession, stashId],
|
||||
receivedHeartbeats: [currentSession, stashIndex]
|
||||
}),
|
||||
[currentSession, stashId, stashIndex]
|
||||
);
|
||||
|
||||
const authoredBlocks = useCall(params && api.query.imOnline.authoredBlocks, params?.authoredBlocks, OPT_BLOCKS);
|
||||
const receivedHeartbeats = useCall(params && api.query.imOnline.receivedHeartbeats, params?.receivedHeartbeats, OPT_BEATS);
|
||||
|
||||
const result = useMemo(
|
||||
() => isNumber(authoredBlocks) && isBoolean(receivedHeartbeats) && ({
|
||||
authoredBlocks,
|
||||
isOnline: !!(authoredBlocks || receivedHeartbeats)
|
||||
}),
|
||||
[authoredBlocks, receivedHeartbeats]
|
||||
);
|
||||
|
||||
return useCacheMap('useHeartbeat', stashId, result) || EMPTY;
|
||||
}
|
||||
|
||||
export default createNamedHook('useHeartbeat', useHeartbeatImpl);
|
||||
@@ -0,0 +1,39 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { PalletStakingEraRewardPoints } from '@pezkuwi/types/lookup';
|
||||
import type { SessionInfo } from '../types.js';
|
||||
import type { UsePoints } from './types.js';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { createNamedHook, useApi, useCall } from '@pezkuwi/react-hooks';
|
||||
import { BN_ZERO } from '@pezkuwi/util';
|
||||
|
||||
import { useCacheValue } from '../useCache.js';
|
||||
|
||||
const OPT_POINTS = {
|
||||
transform: ({ individual }: PalletStakingEraRewardPoints): UsePoints =>
|
||||
[...individual.entries()]
|
||||
.filter(([, points]) => points.gt(BN_ZERO))
|
||||
.reduce((result: UsePoints, [stashId, points]): UsePoints => {
|
||||
result[stashId.toString()] = points.toNumber();
|
||||
|
||||
return result;
|
||||
}, {})
|
||||
};
|
||||
|
||||
function usePointsImpl ({ activeEra }: SessionInfo): UsePoints | undefined {
|
||||
const { api } = useApi();
|
||||
|
||||
const queryParams = useMemo(
|
||||
() => activeEra && [activeEra],
|
||||
[activeEra]
|
||||
);
|
||||
|
||||
const points = useCall(queryParams && api.query.staking.erasRewardPoints, queryParams, OPT_POINTS);
|
||||
|
||||
return useCacheValue('usePoints', points);
|
||||
}
|
||||
|
||||
export default createNamedHook('usePoints', usePointsImpl);
|
||||
@@ -0,0 +1,6 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
export const MAX_NOMINATIONS = 16;
|
||||
|
||||
export const STORE_FAVS_BASE = 'staking:favorites';
|
||||
@@ -0,0 +1,84 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { AppProps as Props } from '@pezkuwi/react-components/types';
|
||||
|
||||
import React, { useEffect, useMemo, useRef } from 'react';
|
||||
import { Route, Routes } from 'react-router';
|
||||
|
||||
import { Tabs } from '@pezkuwi/react-components';
|
||||
import { useApi, useFavorites } from '@pezkuwi/react-hooks';
|
||||
import { isFunction } from '@pezkuwi/util';
|
||||
|
||||
import Pools from './Pools/index.js';
|
||||
import Validators from './Validators/index.js';
|
||||
import { STORE_FAVS_BASE } from './constants.js';
|
||||
import { useTranslation } from './translate.js';
|
||||
import { clearCache } from './useCache.js';
|
||||
import useSessionInfo from './useSessionInfo.js';
|
||||
|
||||
function StakingApp ({ basePath }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const { api } = useApi();
|
||||
|
||||
// on unmount anything else, ensure that for the next round we
|
||||
// are starting with a fresh cache (there could be large delays)
|
||||
// between opening up staking (executed inline, not via effect)
|
||||
useEffect((): () => void => {
|
||||
return (): void => {
|
||||
clearCache();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const [favorites, toggleFavorite] = useFavorites(STORE_FAVS_BASE);
|
||||
const sessionInfo = useSessionInfo();
|
||||
|
||||
const isRelay = useMemo(
|
||||
() => !!(api.query.parasShared || api.query.shared)?.activeValidatorIndices,
|
||||
[api]
|
||||
);
|
||||
|
||||
const itemsRef = useRef([
|
||||
{
|
||||
isRoot: true,
|
||||
name: 'sign',
|
||||
text: t('Validators')
|
||||
},
|
||||
isFunction(api.query.nominationPools?.minCreateBond) && {
|
||||
name: 'pools',
|
||||
text: t('Pools')
|
||||
}
|
||||
]);
|
||||
|
||||
return (
|
||||
<main className='staking--App'>
|
||||
<Tabs
|
||||
basePath={basePath}
|
||||
items={itemsRef.current}
|
||||
/>
|
||||
<Routes>
|
||||
<Route path={basePath}>
|
||||
<Route
|
||||
element={
|
||||
<Pools />
|
||||
}
|
||||
path='pools'
|
||||
/>
|
||||
<Route
|
||||
element={
|
||||
<Validators
|
||||
favorites={favorites}
|
||||
isRelay={isRelay}
|
||||
sessionInfo={sessionInfo}
|
||||
toggleFavorite={toggleFavorite}
|
||||
/>
|
||||
}
|
||||
index
|
||||
/>
|
||||
</Route>
|
||||
</Routes>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(StakingApp);
|
||||
@@ -0,0 +1,8 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { useTranslation as useTranslationBase } from 'react-i18next';
|
||||
|
||||
export function useTranslation (): { t: (key: string, options?: { replace: Record<string, unknown> }) => string } {
|
||||
return useTranslationBase('app-staking');
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
|
||||
export interface SessionInfo {
|
||||
activeEra?: BN | null;
|
||||
currentEra?: BN | null;
|
||||
currentSession?: BN | null;
|
||||
}
|
||||
|
||||
export interface Validator {
|
||||
isElected: boolean;
|
||||
isFavorite: boolean;
|
||||
isOwned: boolean;
|
||||
isPara?: boolean;
|
||||
key: string;
|
||||
stashId: string;
|
||||
stashIndex: number;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export interface CacheValue<T> {
|
||||
value?: T;
|
||||
}
|
||||
|
||||
type CacheMapKey = `use${'Exposure' | 'Heartbeat'}`;
|
||||
|
||||
type CacheValKey = `use${'ElectedValidators' | 'Points' | 'ValidatorsActive' | 'ValidatorsAll' | 'ValidatorsWaiting'}`;
|
||||
|
||||
const cacheMap: Record<CacheMapKey, Record<string, CacheValue<any>>> = {
|
||||
useExposure: {},
|
||||
useHeartbeat: {}
|
||||
};
|
||||
|
||||
const cacheVal: Record<CacheValKey, CacheValue<any>> = {
|
||||
useElectedValidators: {},
|
||||
usePoints: {},
|
||||
useValidatorsActive: {},
|
||||
useValidatorsAll: {},
|
||||
useValidatorsWaiting: {}
|
||||
};
|
||||
|
||||
export function clearCache (): void {
|
||||
for (const k of Object.keys(cacheMap)) {
|
||||
cacheMap[k as CacheMapKey] = {};
|
||||
}
|
||||
|
||||
for (const k of Object.keys(cacheVal)) {
|
||||
cacheVal[k as CacheValKey] = {};
|
||||
}
|
||||
}
|
||||
|
||||
export function useCacheMap <T> (section: CacheMapKey, id: string, value?: T): T | undefined {
|
||||
// update the cached result on value changes
|
||||
useEffect((): void => {
|
||||
if (value) {
|
||||
cacheMap[section][id] = { value };
|
||||
}
|
||||
}, [id, section, value]);
|
||||
|
||||
return value || (cacheMap[section][id] as CacheValue<T>)?.value;
|
||||
}
|
||||
|
||||
export function useCacheValue <T> (section: CacheValKey, value?: T): T | undefined {
|
||||
// update the cached result on value changes
|
||||
useEffect((): void => {
|
||||
if (value) {
|
||||
cacheVal[section] = { value };
|
||||
}
|
||||
}, [section, value]);
|
||||
|
||||
return value || (cacheVal[section] as CacheValue<T>)?.value;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { StorageKey, u32 } from '@pezkuwi/types';
|
||||
import type { AccountId32 } from '@pezkuwi/types/interfaces';
|
||||
import type { SessionInfo } from './types.js';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { createNamedHook, useApi, useMapKeys } from '@pezkuwi/react-hooks';
|
||||
|
||||
import { useCacheValue } from './useCache.js';
|
||||
|
||||
const OPT_ELECTED = {
|
||||
transform: (keys: StorageKey<[u32, AccountId32]>[]): string[] =>
|
||||
keys.map(({ args: [, stashId] }) =>
|
||||
stashId.toString()
|
||||
)
|
||||
};
|
||||
|
||||
function useElectedValidatorsImpl ({ currentEra }: SessionInfo): string[] | undefined {
|
||||
const { api } = useApi();
|
||||
|
||||
const electedParams = useMemo(
|
||||
() => currentEra && [currentEra],
|
||||
[currentEra]
|
||||
);
|
||||
|
||||
const elected = useMapKeys(electedParams && api.query.staking.erasStakers, electedParams, OPT_ELECTED);
|
||||
|
||||
return useCacheValue('useElectedValidators', elected);
|
||||
}
|
||||
|
||||
export default createNamedHook('useElectedValidators', useElectedValidatorsImpl);
|
||||
@@ -0,0 +1,37 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { Option, u32 } from '@pezkuwi/types';
|
||||
import type { PalletStakingActiveEraInfo } from '@pezkuwi/types/lookup';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
import type { SessionInfo } from './types.js';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { createNamedHook, useApi, useCall } from '@pezkuwi/react-hooks';
|
||||
|
||||
const OPT_ACTIVEERA = {
|
||||
transform: (activeEra: Option<PalletStakingActiveEraInfo>): BN | null =>
|
||||
activeEra.isSome
|
||||
? activeEra.unwrap().index
|
||||
: null
|
||||
};
|
||||
|
||||
const OPT_CURRENTERA = {
|
||||
transform: (currentEra: Option<u32>): BN | null =>
|
||||
currentEra.unwrapOr(null)
|
||||
};
|
||||
|
||||
function useSessionInfoImpl (): SessionInfo {
|
||||
const { api } = useApi();
|
||||
const activeEra = useCall(api.query.staking.activeEra, undefined, OPT_ACTIVEERA);
|
||||
const currentEra = useCall(api.query.staking.currentEra, undefined, OPT_CURRENTERA);
|
||||
const currentSession = useCall<u32>(api.query.session.currentIndex);
|
||||
|
||||
return useMemo(
|
||||
() => ({ activeEra, currentEra, currentSession }),
|
||||
[activeEra, currentEra, currentSession]
|
||||
);
|
||||
}
|
||||
|
||||
export default createNamedHook('useSessionInfo', useSessionInfoImpl);
|
||||
@@ -0,0 +1,67 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { SessionInfo, Validator } from './types.js';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { createNamedHook, useAccounts } from '@pezkuwi/react-hooks';
|
||||
import { objectSpread } from '@pezkuwi/util';
|
||||
|
||||
import useElectedValidators from './useElectedValidators.js';
|
||||
|
||||
function sort (a: Validator, b: Validator): number {
|
||||
return a.isFavorite === b.isFavorite
|
||||
? a.isOwned === b.isOwned
|
||||
? a.isElected === b.isElected
|
||||
? 0
|
||||
: a.isElected
|
||||
? -1
|
||||
: 1
|
||||
: a.isOwned
|
||||
? -1
|
||||
: 1
|
||||
: a.isFavorite
|
||||
? -1
|
||||
: 1;
|
||||
}
|
||||
|
||||
function withElected (validators: Validator[], elected: string[]): Validator[] {
|
||||
return validators.map((v): Validator => {
|
||||
const isElected = elected.includes(v.stashId);
|
||||
|
||||
return v.isElected !== isElected
|
||||
? objectSpread({}, v, { isElected })
|
||||
: v;
|
||||
});
|
||||
}
|
||||
|
||||
function withSort (allAccounts: string[], favorites: string[], validators: Validator[]): Validator[] {
|
||||
return validators
|
||||
.map((v): Validator => {
|
||||
const isFavorite = favorites.includes(v.stashId);
|
||||
const isOwned = allAccounts.includes(v.stashId);
|
||||
|
||||
return v.isFavorite !== isFavorite || v.isOwned !== isOwned
|
||||
? objectSpread({}, v, { isFavorite, isOwned })
|
||||
: v;
|
||||
})
|
||||
.sort(sort);
|
||||
}
|
||||
|
||||
function useTaggedValidatorsImpl (favorites: string[], sessionInfo: SessionInfo, validators?: Validator[]): Validator[] | undefined {
|
||||
const { allAccounts } = useAccounts();
|
||||
const elected = useElectedValidators(sessionInfo);
|
||||
|
||||
const flagged = useMemo(
|
||||
() => elected && validators && withElected(validators, elected),
|
||||
[elected, validators]
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => flagged && withSort(allAccounts, favorites, flagged),
|
||||
[allAccounts, favorites, flagged]
|
||||
);
|
||||
}
|
||||
|
||||
export default createNamedHook('useTaggedValidators', useTaggedValidatorsImpl);
|
||||
@@ -0,0 +1,58 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { u32 } from '@pezkuwi/types';
|
||||
import type { AccountId32 } from '@pezkuwi/types/interfaces';
|
||||
import type { SessionInfo, Validator } from './types.js';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { createNamedHook, useApi, useCall } from '@pezkuwi/react-hooks';
|
||||
import { objectSpread } from '@pezkuwi/util';
|
||||
|
||||
import { useCacheValue } from './useCache.js';
|
||||
import useTaggedValidators from './useTaggedValidators.js';
|
||||
|
||||
const OPT_VALIDATORS = {
|
||||
transform: (validators: AccountId32[]): Validator[] =>
|
||||
validators.map((a, stashIndex) => {
|
||||
const stashId = a.toString();
|
||||
|
||||
return {
|
||||
isElected: false,
|
||||
isFavorite: false,
|
||||
isOwned: false,
|
||||
key: `${stashId}:${stashIndex}`,
|
||||
stashId,
|
||||
stashIndex
|
||||
};
|
||||
})
|
||||
};
|
||||
|
||||
const OPT_INDICES = {
|
||||
transform: (indices: u32[]): number[] =>
|
||||
indices.map((n) => n.toNumber())
|
||||
};
|
||||
|
||||
function useValidatorsActiveImpl (favorites: string[], sessionInfo: SessionInfo): Validator[] | undefined {
|
||||
const { api } = useApi();
|
||||
const sessionValidators = useCall(api.query.session.validators, undefined, OPT_VALIDATORS);
|
||||
const activeIndices = useCall((api.query.parasShared || api.query.shared)?.activeValidatorIndices, undefined, OPT_INDICES);
|
||||
|
||||
const validatorsWithPara = useMemo(
|
||||
() => activeIndices && sessionValidators
|
||||
? sessionValidators.map((v, index) =>
|
||||
activeIndices.includes(index)
|
||||
? objectSpread<Validator>({ isPara: true }, v)
|
||||
: v
|
||||
)
|
||||
: sessionValidators,
|
||||
[activeIndices, sessionValidators]
|
||||
);
|
||||
|
||||
const tagged = useTaggedValidators(favorites, sessionInfo, validatorsWithPara);
|
||||
|
||||
return useCacheValue('useValidatorsActive', tagged);
|
||||
}
|
||||
|
||||
export default createNamedHook('useValidatorsActive', useValidatorsActiveImpl);
|
||||
@@ -0,0 +1,71 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { Changes } from '@pezkuwi/react-hooks/useEventChanges';
|
||||
import type { StorageKey } from '@pezkuwi/types';
|
||||
import type { AccountId32, EventRecord } from '@pezkuwi/types/interfaces';
|
||||
import type { SessionInfo, Validator } from './types.js';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { createNamedHook, useApi, useEventChanges, useMapKeys } from '@pezkuwi/react-hooks';
|
||||
|
||||
import { useCacheValue } from './useCache.js';
|
||||
import useTaggedValidators from './useTaggedValidators.js';
|
||||
|
||||
const EMPTY_PARAMS: unknown[] = [];
|
||||
|
||||
const OPT_VALIDATORS = {
|
||||
transform: (keys: StorageKey<[AccountId32]>[]): AccountId32[] =>
|
||||
keys.map(({ args: [id] }) => id)
|
||||
};
|
||||
|
||||
function eventFilter (records: EventRecord[]): Changes<AccountId32> {
|
||||
const added: AccountId32[] = [];
|
||||
const removed: AccountId32[] = [];
|
||||
|
||||
records.forEach(({ event: { data: [id], method } }): void => {
|
||||
if (method === 'Bonded') {
|
||||
added.push(id as AccountId32);
|
||||
} else {
|
||||
removed.push(id as AccountId32);
|
||||
}
|
||||
});
|
||||
|
||||
return { added, removed };
|
||||
}
|
||||
|
||||
function mapValidators (validators?: AccountId32[]): Validator[] | undefined {
|
||||
return validators?.map((a) => {
|
||||
const stashId = a.toString();
|
||||
|
||||
return {
|
||||
isElected: false,
|
||||
isFavorite: false,
|
||||
isOwned: false,
|
||||
key: `${stashId}::-1`,
|
||||
stashId,
|
||||
stashIndex: -1
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function useValidatorsAllImpl (favorites: string[], sessionInfo: SessionInfo): Validator[] | undefined {
|
||||
const { api } = useApi();
|
||||
const startValue = useMapKeys(api.query.staking.validators, EMPTY_PARAMS, OPT_VALIDATORS);
|
||||
|
||||
const validators = useEventChanges([
|
||||
api.events.staking.Bonded
|
||||
], eventFilter, startValue);
|
||||
|
||||
const validatorsIndexed = useMemo(
|
||||
() => mapValidators(validators),
|
||||
[validators]
|
||||
);
|
||||
|
||||
const tagged = useTaggedValidators(favorites, sessionInfo, validatorsIndexed);
|
||||
|
||||
return useCacheValue('useValidatorsAll', tagged);
|
||||
}
|
||||
|
||||
export default createNamedHook('useValidatorsAll', useValidatorsAllImpl);
|
||||
@@ -0,0 +1,32 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { SessionInfo, Validator } from './types.js';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { createNamedHook } from '@pezkuwi/react-hooks';
|
||||
|
||||
import { useCacheValue } from './useCache.js';
|
||||
import useValidatorsAll from './useValidatorsAll.js';
|
||||
|
||||
function excludeValidators (from: Validator[], exclude: Validator[]): Validator[] {
|
||||
return from.filter(({ stashId }) =>
|
||||
!exclude.some((v) => v.stashId === stashId)
|
||||
);
|
||||
}
|
||||
|
||||
function useValidatorsWaitingImpl (favorites: string[], sessionInfo: SessionInfo, activeValidators?: Validator[]): Validator[] | undefined {
|
||||
const allValidators = useValidatorsAll(favorites, sessionInfo);
|
||||
|
||||
// both active and all is already sorted and tagged, so we don't
|
||||
// need to re-sort the waiting list
|
||||
const tagged = useMemo(
|
||||
() => allValidators && activeValidators && excludeValidators(allValidators, activeValidators),
|
||||
[activeValidators, allValidators]
|
||||
);
|
||||
|
||||
return useCacheValue('useValidatorsWaiting', tagged);
|
||||
}
|
||||
|
||||
export default createNamedHook('useValidatorsWaiting', useValidatorsWaitingImpl);
|
||||
Reference in New Issue
Block a user