mirror of
https://github.com/pezkuwichain/pezkuwi-sdk-ui.git
synced 2026-07-17 13:35:49 +00:00
d949863789
Comprehensive web interface for interacting with Pezkuwi blockchain. Features: - Blockchain explorer - Wallet management - Staking interface - Governance participation - Developer tools Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
439 lines
14 KiB
TypeScript
439 lines
14 KiB
TypeScript
// Copyright 2017-2026 @pezkuwi/app-staking authors & contributors
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
import type { DeriveHasIdentity } from '@pezkuwi/api-derive/types';
|
|
import type { StakerState } from '@pezkuwi/react-hooks/types';
|
|
import type { u32 } from '@pezkuwi/types-codec';
|
|
import type { BN } from '@pezkuwi/util';
|
|
import type { NominatedByMap, SortedTargets, TargetSortBy, ValidatorInfo } from '../types.js';
|
|
|
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
|
|
import Legend from '@pezkuwi/app-staking2/Legend';
|
|
import { Button, Icon, styled, Table, Toggle } from '@pezkuwi/react-components';
|
|
import { useApi, useAvailableSlashes, useBlocksPerDays, useSavedFlags } from '@pezkuwi/react-hooks';
|
|
import { BN_HUNDRED } from '@pezkuwi/util';
|
|
|
|
import { MAX_NOMINATIONS } from '../constants.js';
|
|
import ElectionBanner from '../ElectionBanner.js';
|
|
import Filtering from '../Filtering.js';
|
|
import { useTranslation } from '../translate.js';
|
|
import useIdentities from '../useIdentities.js';
|
|
import Nominate from './Nominate.js';
|
|
import Summary from './Summary.js';
|
|
import useOwnNominators from './useOwnNominators.js';
|
|
import Validator from './Validator.js';
|
|
|
|
interface Props {
|
|
className?: string;
|
|
isInElection: boolean;
|
|
nominatedBy?: NominatedByMap;
|
|
ownStashes?: StakerState[];
|
|
targets: SortedTargets;
|
|
toggleFavorite: (address: string) => void;
|
|
toggleLedger: () => void;
|
|
toggleNominatedBy: () => void;
|
|
}
|
|
|
|
interface SavedFlags {
|
|
withElected: boolean;
|
|
withGroup: boolean;
|
|
withIdentity: boolean;
|
|
withPayout: boolean;
|
|
withoutComm: boolean;
|
|
withoutOver: boolean;
|
|
}
|
|
|
|
interface Flags extends SavedFlags {
|
|
daysPayout: BN;
|
|
isBabe: boolean;
|
|
maxPaid: BN | undefined;
|
|
}
|
|
|
|
interface SortState {
|
|
sortBy: TargetSortBy;
|
|
sortFromMax: boolean;
|
|
}
|
|
|
|
const CLASSES: Record<string, string> = {
|
|
rankBondOther: 'media--1600',
|
|
rankBondOwn: 'media--900'
|
|
};
|
|
const MAX_CAP_PERCENT = 100; // 75 if only using numNominators
|
|
const MAX_COMM_PERCENT = 10; // -1 for median
|
|
const MAX_DAYS = 7;
|
|
const SORT_KEYS = ['rankBondTotal', 'rankBondOwn', 'rankBondOther', 'rankOverall'];
|
|
|
|
function overlapsDisplay (displays: (string[])[], test: string[]): boolean {
|
|
return displays.some((d) =>
|
|
d.length === test.length
|
|
? d.length === 1
|
|
? d[0] === test[0]
|
|
: d.reduce((c, p, i) => c + (p === test[i] ? 1 : 0), 0) >= (test.length - 1)
|
|
: false
|
|
);
|
|
}
|
|
|
|
function applyFilter (validators: ValidatorInfo[], medianComm: number, allIdentity: Record<string, DeriveHasIdentity>, { daysPayout, isBabe, maxPaid, withElected, withGroup, withIdentity, withPayout, withoutComm, withoutOver }: Flags, nominatedBy?: NominatedByMap): ValidatorInfo[] {
|
|
const displays: (string[])[] = [];
|
|
const parentIds: string[] = [];
|
|
|
|
return validators.filter(({ accountId, commissionPer, isElected, isFavorite, lastPayout, numNominators }): boolean => {
|
|
if (isFavorite) {
|
|
return true;
|
|
}
|
|
|
|
const stashId = accountId.toString();
|
|
const thisIdentity = allIdentity[stashId];
|
|
const nomCount = numNominators || nominatedBy?.[stashId]?.length || 0;
|
|
|
|
if (
|
|
(!withElected || isElected) &&
|
|
(!withIdentity || !!thisIdentity?.hasIdentity) &&
|
|
(!withPayout || !isBabe || (!!lastPayout && daysPayout.gte(lastPayout))) &&
|
|
(!withoutComm || (
|
|
MAX_COMM_PERCENT > 0
|
|
? (commissionPer <= MAX_COMM_PERCENT)
|
|
: (!medianComm || (commissionPer <= medianComm)))
|
|
) &&
|
|
(!withoutOver || !maxPaid || maxPaid.muln(MAX_CAP_PERCENT).div(BN_HUNDRED).gten(nomCount))
|
|
) {
|
|
if (!withGroup) {
|
|
return true;
|
|
} else if (!thisIdentity || !thisIdentity.hasIdentity) {
|
|
parentIds.push(stashId);
|
|
|
|
return true;
|
|
} else if (!thisIdentity.parentId) {
|
|
if (!parentIds.includes(stashId)) {
|
|
if (thisIdentity.display) {
|
|
const sanitized = thisIdentity.display
|
|
.replace(/[^\x20-\x7E]/g, '')
|
|
.replace(/-/g, ' ')
|
|
.replace(/_/g, ' ')
|
|
.split(' ')
|
|
.map((p) => p.trim())
|
|
.filter((v) => !!v);
|
|
|
|
if (overlapsDisplay(displays, sanitized)) {
|
|
return false;
|
|
}
|
|
|
|
displays.push(sanitized);
|
|
}
|
|
|
|
parentIds.push(stashId);
|
|
|
|
return true;
|
|
}
|
|
} else if (!parentIds.includes(thisIdentity.parentId)) {
|
|
parentIds.push(thisIdentity.parentId);
|
|
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
});
|
|
}
|
|
|
|
function sort (sortBy: TargetSortBy, sortFromMax: boolean, validators: ValidatorInfo[]): ValidatorInfo[] {
|
|
// Use slice to create new array, so that sorting triggers component render
|
|
return validators
|
|
.slice(0)
|
|
.sort((a, b) =>
|
|
sortFromMax
|
|
? a[sortBy] - b[sortBy]
|
|
: b[sortBy] - a[sortBy]
|
|
)
|
|
.sort((a, b) =>
|
|
a.isFavorite === b.isFavorite
|
|
? 0
|
|
: (a.isFavorite ? -1 : 1)
|
|
);
|
|
}
|
|
|
|
function extractNominees (ownNominators: StakerState[] = []): string[] {
|
|
const myNominees: string[] = [];
|
|
|
|
ownNominators.forEach(({ nominating = [] }: StakerState): void => {
|
|
nominating.forEach((nominee: string): void => {
|
|
!myNominees.includes(nominee) &&
|
|
myNominees.push(nominee);
|
|
});
|
|
});
|
|
|
|
return myNominees;
|
|
}
|
|
|
|
function selectProfitable (list: ValidatorInfo[], maxNominations: number): string[] {
|
|
const result: string[] = [];
|
|
|
|
for (let i = 0; i < list.length && result.length < maxNominations; i++) {
|
|
const { isBlocking, isFavorite, key, stakedReturnCmp } = list[i];
|
|
|
|
(!isBlocking && (isFavorite || (stakedReturnCmp > 0))) &&
|
|
result.push(key);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
const DEFAULT_FLAGS = {
|
|
withElected: false,
|
|
withGroup: true,
|
|
withIdentity: false,
|
|
withPayout: false,
|
|
withoutComm: true,
|
|
withoutOver: true
|
|
};
|
|
|
|
const DEFAULT_NAME = { isQueryFiltered: false, nameFilter: '' };
|
|
|
|
const DEFAULT_SORT: SortState = { sortBy: 'rankOverall', sortFromMax: true };
|
|
|
|
function Targets ({ className = '', isInElection, nominatedBy, ownStashes, targets: { avgStaked, inflation: { stakedReturn }, lastEra, lowStaked, medianComm, minNominated, minNominatorBond, nominators, totalIssuance, totalStaked, validatorIds, validators }, toggleFavorite, toggleLedger, toggleNominatedBy }: Props): React.ReactElement<Props> {
|
|
const { t } = useTranslation();
|
|
const { api } = useApi();
|
|
const allSlashes = useAvailableSlashes();
|
|
const daysPayout = useBlocksPerDays(MAX_DAYS);
|
|
const ownNominators = useOwnNominators(ownStashes);
|
|
const allIdentity = useIdentities(validatorIds);
|
|
const [selected, setSelected] = useState<string[]>([]);
|
|
const [{ isQueryFiltered, nameFilter }, setNameFilter] = useState(DEFAULT_NAME);
|
|
const [toggles, setToggle] = useSavedFlags('staking:targets', DEFAULT_FLAGS);
|
|
const [{ sortBy, sortFromMax }, setSortBy] = useState<SortState>(DEFAULT_SORT);
|
|
const [sorted, setSorted] = useState<ValidatorInfo[] | undefined>();
|
|
|
|
const labelsRef = useRef({
|
|
rankBondOther: t('other stake'),
|
|
rankBondOwn: t('own stake'),
|
|
rankBondTotal: t('total stake'),
|
|
rankOverall: t('return')
|
|
});
|
|
|
|
const flags = useMemo(
|
|
() => ({
|
|
...toggles,
|
|
daysPayout,
|
|
isBabe: !!api.consts.babe,
|
|
isQueryFiltered,
|
|
maxPaid: api.consts.staking?.maxNominatorRewardedPerValidator as u32
|
|
}),
|
|
[api, daysPayout, isQueryFiltered, toggles]
|
|
);
|
|
|
|
const filtered = useMemo(
|
|
() => allIdentity && validators && nominatedBy &&
|
|
applyFilter(validators, medianComm, allIdentity, flags, nominatedBy),
|
|
[allIdentity, flags, medianComm, nominatedBy, validators]
|
|
);
|
|
|
|
// We are using an effect here to get this async. Sorting will have a double-render, however it allows
|
|
// the page to immediately display (with loading), whereas useMemo would have a laggy interface
|
|
// (the same applies for changing the sort order, state here is more effective)
|
|
useEffect((): void => {
|
|
filtered && setSorted(
|
|
sort(sortBy, sortFromMax, filtered)
|
|
);
|
|
}, [filtered, sortBy, sortFromMax]);
|
|
|
|
useEffect((): void => {
|
|
toggleLedger();
|
|
toggleNominatedBy();
|
|
}, [toggleLedger, toggleNominatedBy]);
|
|
|
|
const maxNominations = useMemo(
|
|
() => api.consts.staking.maxNominations
|
|
? (api.consts.staking.maxNominations as u32).toNumber()
|
|
: MAX_NOMINATIONS,
|
|
[api]
|
|
);
|
|
|
|
const myNominees = useMemo(
|
|
() => extractNominees(ownNominators),
|
|
[ownNominators]
|
|
);
|
|
|
|
const _sort = useCallback(
|
|
(sortBy: TargetSortBy) => setSortBy((p) => ({
|
|
sortBy,
|
|
sortFromMax: sortBy === p.sortBy
|
|
? !p.sortFromMax
|
|
: true
|
|
})),
|
|
[]
|
|
);
|
|
|
|
const _toggleSelected = useCallback(
|
|
(address: string) => setSelected(
|
|
selected.includes(address)
|
|
? selected.filter((a) => address !== a)
|
|
: [...selected, address]
|
|
),
|
|
[selected]
|
|
);
|
|
|
|
const _selectProfitable = useCallback(
|
|
() => filtered && setSelected(
|
|
selectProfitable(filtered, maxNominations)
|
|
),
|
|
[filtered, maxNominations]
|
|
);
|
|
|
|
const _setNameFilter = useCallback(
|
|
(nameFilter: string, isQueryFiltered: boolean) => setNameFilter({ isQueryFiltered, nameFilter }),
|
|
[]
|
|
);
|
|
|
|
// False positive, this is part of the type...
|
|
// eslint-disable-next-line func-call-spacing
|
|
const header = useMemo<[React.ReactNode?, string?, number?, (() => void)?][]>(() => [
|
|
[t('validators'), 'start', 4],
|
|
[t('payout'), 'media--1400'],
|
|
[t('nominators'), 'media--1200', 2],
|
|
[t('comm.'), 'media--1100'],
|
|
...(SORT_KEYS as (keyof typeof labelsRef.current)[]).map((header): [React.ReactNode?, string?, number?, (() => void)?] => [
|
|
<>{labelsRef.current[header]}<Icon icon={sortBy === header ? (sortFromMax ? 'chevron-down' : 'chevron-up') : 'minus'} /></>,
|
|
`${sorted ? `isClickable ${sortBy === header ? 'highlight--border' : ''} number` : 'number'} ${CLASSES[header] || ''}`,
|
|
1,
|
|
() => _sort(header as 'rankOverall')
|
|
]),
|
|
[],
|
|
[]
|
|
], [_sort, labelsRef, sortBy, sorted, sortFromMax, t]);
|
|
|
|
const filter = useMemo(() => (
|
|
<div>
|
|
<Filtering
|
|
nameFilter={nameFilter}
|
|
setNameFilter={_setNameFilter}
|
|
setWithIdentity={setToggle.withIdentity}
|
|
withIdentity={toggles.withIdentity}
|
|
>
|
|
<Toggle
|
|
className='staking--buttonToggle'
|
|
label={t('one validator per operator')}
|
|
onChange={setToggle.withGroup}
|
|
value={toggles.withGroup}
|
|
/>
|
|
<Toggle
|
|
className='staking--buttonToggle'
|
|
label={
|
|
MAX_COMM_PERCENT > 0
|
|
? t('comm. <= {{maxComm}}%', { replace: { maxComm: MAX_COMM_PERCENT } })
|
|
: t('comm. <= median')
|
|
}
|
|
onChange={setToggle.withoutComm}
|
|
value={toggles.withoutComm}
|
|
/>
|
|
<Toggle
|
|
className='staking--buttonToggle'
|
|
label={
|
|
MAX_CAP_PERCENT < 100
|
|
? t('capacity < {{maxCap}}%', { replace: { maxCap: MAX_CAP_PERCENT } })
|
|
: t('with capacity')
|
|
}
|
|
onChange={setToggle.withoutOver}
|
|
value={toggles.withoutOver}
|
|
/>
|
|
{api.consts.babe && (
|
|
// FIXME have some sane era defaults for Aura
|
|
<Toggle
|
|
className='staking--buttonToggle'
|
|
label={t('recent payouts')}
|
|
onChange={setToggle.withPayout}
|
|
value={toggles.withPayout}
|
|
/>
|
|
)}
|
|
<Toggle
|
|
className='staking--buttonToggle'
|
|
label={t('currently elected')}
|
|
onChange={setToggle.withElected}
|
|
value={toggles.withElected}
|
|
/>
|
|
</Filtering>
|
|
</div>
|
|
), [api, nameFilter, _setNameFilter, setToggle, t, toggles]);
|
|
|
|
const displayList = isQueryFiltered
|
|
? validators
|
|
: sorted;
|
|
const canSelect = selected.length < maxNominations;
|
|
|
|
return (
|
|
<StyledDiv className={className}>
|
|
<Summary
|
|
avgStaked={avgStaked}
|
|
lastEra={lastEra}
|
|
lowStaked={lowStaked}
|
|
minNominated={minNominated}
|
|
minNominatorBond={minNominatorBond}
|
|
numNominators={nominators?.length}
|
|
numValidators={validators?.length}
|
|
stakedReturn={stakedReturn}
|
|
totalIssuance={totalIssuance}
|
|
totalStaked={totalStaked}
|
|
/>
|
|
<Button.Group>
|
|
<Button
|
|
icon='check'
|
|
isDisabled={!validators?.length || !ownNominators?.length}
|
|
label={t('Most profitable')}
|
|
onClick={_selectProfitable}
|
|
/>
|
|
<Nominate
|
|
isDisabled={isInElection || !validators?.length}
|
|
ownNominators={ownNominators}
|
|
targets={selected}
|
|
/>
|
|
</Button.Group>
|
|
<ElectionBanner isInElection={isInElection} />
|
|
<Table
|
|
empty={sorted && t('No active validators to check')}
|
|
emptySpinner={
|
|
<>
|
|
{!(validators && allIdentity) && <div>{t('Retrieving validators')}</div>}
|
|
{!nominatedBy && <div>{t('Retrieving nominators')}</div>}
|
|
{!displayList && <div>{t('Preparing target display')}</div>}
|
|
</>
|
|
}
|
|
filter={filter}
|
|
header={header}
|
|
legend={<Legend />}
|
|
>
|
|
{displayList?.map((info): React.ReactNode =>
|
|
<Validator
|
|
allSlashes={allSlashes}
|
|
canSelect={canSelect}
|
|
filterName={nameFilter}
|
|
info={info}
|
|
isNominated={myNominees.includes(info.key)}
|
|
isSelected={selected.includes(info.key)}
|
|
key={info.key}
|
|
nominatedBy={nominatedBy?.[info.key]}
|
|
toggleFavorite={toggleFavorite}
|
|
toggleSelected={_toggleSelected}
|
|
/>
|
|
)}
|
|
</Table>
|
|
</StyledDiv>
|
|
);
|
|
}
|
|
|
|
const StyledDiv = styled.div`
|
|
text-align: center;
|
|
|
|
th.isClickable {
|
|
.ui--Icon {
|
|
margin-left: 0.5rem;
|
|
}
|
|
}
|
|
|
|
.ui--Table {
|
|
overflow-x: auto;
|
|
}
|
|
`;
|
|
|
|
export default React.memo(Targets);
|