Initial commit: Pezkuwi SDK UI

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>
This commit is contained in:
2026-01-19 13:55:36 +03:00
commit d949863789
5831 changed files with 327739 additions and 0 deletions
@@ -0,0 +1,103 @@
// Copyright 2017-2026 @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-2026 @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-2026 @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-2026 @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-2026 @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-2026 @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-2026 @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-2026 @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-2026 @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-2026 @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-2026 @pezkuwi/app-staking authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { PezspStakingExposure } 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 }: PezspStakingExposure): 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-2026 @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-2026 @pezkuwi/app-staking authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { PezpalletStakingEraRewardPoints } 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 }: PezpalletStakingEraRewardPoints): 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);