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:
2026-01-07 13:05:27 +03:00
commit d21bfb1320
5867 changed files with 329019 additions and 0 deletions
+123
View File
@@ -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);
+124
View File
@@ -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);
+88
View File
@@ -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);
+188
View File
@@ -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 && (
<>&nbsp;/&nbsp;{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);
+51
View File
@@ -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);
+6
View File
@@ -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';
+84
View File
@@ -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);
+8
View File
@@ -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');
}
+20
View File
@@ -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;
}
+57
View File
@@ -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);