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
@@ -0,0 +1,78 @@
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { PalletReferenda, TrackDescription, TrackInfoExt } from '../../types.js';
import type { VoteResultItem } from './types.js';
import React, { useMemo } from 'react';
import { MarkWarning, styled, Table } from '@pezkuwi/react-components';
import { useApi } from '@pezkuwi/react-hooks';
import { useTranslation } from '../../translate.js';
import { getTrackInfo } from '../../util.js';
interface Props {
allowEmpty?: boolean;
className?: string;
palletReferenda: PalletReferenda;
trackId: number;
tracks: TrackDescription[];
value?: VoteResultItem[] | null | false;
}
function Activity ({ allowEmpty, className, palletReferenda, tracks, value }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
const { api, specName } = useApi();
const infos = useMemo(
() => value && value.map((v): [VoteResultItem, TrackInfoExt | undefined] =>
[v, getTrackInfo(api, specName, palletReferenda, tracks, v.classId.toNumber())]
),
[api, palletReferenda, specName, tracks, value]
);
if (!infos) {
return null;
}
return (
<StyledDiv className={className}>
{infos.length
? (
<Table isInline>
{infos.map(([{ casting, classId, delegating }, info], index) => (
<tr key={index}>
<td className='all'>
{info?.trackName || classId.toString()}
</td>
<td className='together'>
{
(delegating &&
t('delegating')) ||
(casting &&
`${casting.length} ${casting.length === 1 ? t('vote') : t('votes')}`)
}
</td>
</tr>
))}
</Table>
)
: <MarkWarning content={t('This account has no voting/delegating activity in the chain state')} />
}
{!allowEmpty && infos.some(([{ delegating }]) => delegating) && (
<MarkWarning content={t('This account has some delegations in itself')} />
)}
</StyledDiv>
);
}
const StyledDiv = styled.div`
.ui--Table {
font-size: var(--font-percent-small);
opacity: var(--opacity-light);
padding-left: 2rem;
}
`;
export default React.memo(Activity);
@@ -0,0 +1,292 @@
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { BatchOptions } from '@pezkuwi/react-hooks/types';
import type { BN } from '@pezkuwi/util';
import type { PalletReferenda, PalletVote, TrackDescription } from '../../types.js';
import React, { useCallback, useMemo, useState } from 'react';
import { Button, ConvictionDropdown, InputAddress, Modal, Toggle, ToggleGroup, TxButton, VoteValue } from '@pezkuwi/react-components';
import { useAccounts, useApi, useStepper, useToggle, useTxBatch } from '@pezkuwi/react-hooks';
import { isFunction } from '@pezkuwi/util';
import { useTranslation } from '../../translate.js';
import TrackDropdown from '../Submit/TrackDropdown.js';
import Activity from './Activity.js';
import useActivityAccount from './useActivityAccount.js';
import useActivityFellows from './useActivityFellows.js';
import useActivityNominators from './useActivityNominators.js';
interface Props {
className?: string;
palletReferenda: PalletReferenda;
palletVote: PalletVote;
tracks: TrackDescription[];
}
interface Option {
key: string;
name: string;
value: string;
}
const BATCH_OPTS: BatchOptions = { type: 'force' };
function Delegate ({ className, palletReferenda, palletVote, tracks }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api } = useApi();
const { hasAccounts } = useAccounts();
const [isOpen, toggleOpen] = useToggle();
const [isAllTracks, toggleAllTracks] = useToggle();
const [step, nextStep, prevStep] = useStepper();
const [accountId, setAccountId] = useState<string | null>(null);
const [toAccount, setToAccount] = useState<string | null>(null);
const [trackId, setTrackId] = useState<number>(0);
const [balance, setBalance] = useState<BN | undefined>();
const [conviction, setConviction] = useState(1);
const activityFell = useActivityFellows(palletVote);
const activityVals = useActivityNominators(palletVote);
const activityFrom = useActivityAccount(palletVote, accountId);
const activityTo = useActivityAccount(palletVote, toAccount);
const [accType, setAccType] = useState({ index: 0, type: 'address' });
const includeTracks = useMemo(
() => activityFrom && tracks
.filter((t) => !activityFrom.some((a) => t.id.eq(a.classId)))
.map(({ id }) => id),
[activityFrom, tracks]
);
const allFell = useMemo(
// We also filter the fellows by activity - since there are a number
// we just want to skip those that does not have any activity
() => activityFell &&
Object
.entries(activityFell)
.map(([key, act]) => (act.length > 0) && ({ key, name: key, value: key }))
.filter((a): a is Option => !!a),
[activityFell]
);
const allVals = useMemo(
() => activityVals &&
Object
.entries(activityVals)
.map(([key]) => ({ key, name: key, value: key }))
.filter((a): a is Option => !!a),
[activityVals]
);
const typeOpts = useMemo(
() => [
{ text: t('Addresses'), value: 'address' },
isFunction(api.query.staking?.nominators) &&
{ isDisabled: !allVals?.length, text: t('Validators'), value: 'validators' },
isFunction(api.query.fellowshipCollective?.members) &&
{ isDisabled: !allFell?.length, text: t('Fellows'), value: 'fellows' }
],
[allFell, allVals, api, t]
);
const onChangeType = useCallback(
(index: number, type: string | number) =>
setAccType({ index, type: type.toString() }),
[]
);
const batchInner = useMemo(
() => balance && conviction >= 0 && toAccount && includeTracks
? (isAllTracks ? includeTracks : [trackId]).map((trackId) =>
api.tx[palletVote as 'convictionVoting'].delegate(trackId, toAccount, conviction, balance)
)
: null,
[api, balance, conviction, includeTracks, isAllTracks, palletVote, toAccount, trackId]
);
const extrinsics = useTxBatch(batchInner, BATCH_OPTS);
// NOTE The activityFrom & activityTo checks only checks that the hook has received
// values, not that any values are contained. If we do a length check, that would mean
// we could only delegate to accounts with activity. Instead, we just check that we
// have the results from the on-chain data received via useActivity*
const isStep1Valid = !!(accountId && activityFrom && includeTracks && (includeTracks.length > 0));
const isStep2Valid = !!(toAccount && activityTo);
return (
<>
{isOpen && (
<Modal
className={className}
header={t('Delegate votes {{step}}/{{numSteps}}', { replace: { numSteps: 2, step } })}
onClose={toggleOpen}
size='large'
>
{(step === 1) && (
<Modal.Content>
<Modal.Columns hint={t('Delegate from this account to another. All votes made on the target would count as a delegated vote for this account.')}>
<InputAddress
label={t('delegate from account')}
onChange={setAccountId}
type='account'
withLabel
/>
<Activity
allowEmpty
palletReferenda={palletReferenda}
trackId={-1}
tracks={tracks}
value={activityFrom}
/>
</Modal.Columns>
<Modal.Columns
align='right'
hint={t('Either delegate your votes for a single track as selected or delegate for all available tracks.')}
>
<Toggle
label={t('apply delegation to all tracks')}
onChange={toggleAllTracks}
value={isAllTracks}
/>
{!isAllTracks && includeTracks && (includeTracks.length > 0) && (
<TrackDropdown
include={includeTracks}
onChange={setTrackId}
palletReferenda={palletReferenda}
tracks={tracks}
/>
)}
</Modal.Columns>
<Modal.Columns
hint={
<>
<p>{t('The balance associated with the vote will be locked as per the conviction specified and will not be available for transfer during this period.')}</p>
<p>{t('Conviction locks do overlap and are not additive, meaning that funds locked during a previous vote can be locked again.')}</p>
</>
}
>
<VoteValue
accountId={accountId}
autoFocus
label={t('delegated vote value')}
onChange={setBalance}
/>
<ConvictionDropdown
label={t('conviction')}
onChange={setConviction}
value={conviction}
voteLockingPeriod={api.consts[palletVote as 'convictionVoting'].voteLockingPeriod}
/>
</Modal.Columns>
</Modal.Content>
)}
{(step === 2) && (
<Modal.Content>
{(typeOpts.length > 1) && (
<Modal.Columns
align='center'
hint={t('Select from a list of pre-propulated accounts (based on your account activity) or supply your own')}
>
<ToggleGroup
onChange={onChangeType}
options={typeOpts}
value={accType.index}
/>
</Modal.Columns>
)}
<Modal.Columns hint={t('The account that you wish to delegate to')}>
{accType.type === 'address'
? (
<InputAddress
key='address'
label={t('delegate to address')}
onChange={setToAccount}
type='allPlus'
/>
)
: accType.type === 'validators'
? (
<InputAddress
defaultValue={allVals?.[0].value}
key='validators'
label={t('delegate to validator')}
onChange={setToAccount}
options={allVals}
type='allPlus'
/>
)
: accType.type === 'fellows'
? (
<InputAddress
defaultValue={allFell?.[0].value}
key='fellows'
label={t('delegate to fellow')}
onChange={setToAccount}
options={allFell}
type='allPlus'
/>
)
: null
}
<Activity
palletReferenda={palletReferenda}
trackId={isAllTracks ? -1 : trackId}
tracks={tracks}
value={
accType.type === 'fellows'
? activityFell && !!toAccount && activityFell[toAccount]
: accType.type === 'validators'
? activityVals && !!toAccount && activityVals[toAccount]
: activityTo
}
/>
</Modal.Columns>
</Modal.Content>
)}
<Modal.Actions>
{(step !== 1) && (
<Button
icon='step-backward'
label={t('Prev')}
onClick={prevStep}
/>
)}
{(step !== 2) && (
<Button
activeOnEnter
icon='step-forward'
isDisabled={
step === 1
? !isStep1Valid
: !isStep2Valid
}
label={t('Next')}
onClick={nextStep}
/>
)}
<TxButton
accountId={accountId}
extrinsic={extrinsics}
icon='code-merge'
isDisabled={
!isStep1Valid ||
!isStep2Valid ||
step !== 2
}
label={t('Delegate')}
onStart={toggleOpen}
/>
</Modal.Actions>
</Modal>
)}
<Button
icon='code-merge'
isDisabled={!hasAccounts}
label={t('Delegate')}
onClick={toggleOpen}
/>
</>
);
}
export default React.memo(Delegate);
@@ -0,0 +1,27 @@
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { PalletConvictionVotingVoteVoting } from '@pezkuwi/types/lookup';
import type { BN } from '@pezkuwi/util';
export interface LockResultItem {
classId: BN;
}
export type LockResult = Record<string, LockResultItem[]>;
export interface VoteResultCasting {
refId: BN;
}
export interface VoteResultDelegating {
conviction: PalletConvictionVotingVoteVoting['asDelegating']['conviction']['type'];
targetId: string;
}
export interface VoteResultItem extends LockResultItem {
casting?: VoteResultCasting[];
delegating?: VoteResultDelegating;
}
export type VoteResult = Record<string, VoteResultItem[]>;
@@ -0,0 +1,18 @@
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { PalletVote } from '../../types.js';
import type { VoteResult } from './types.js';
import { createNamedHook } from '@pezkuwi/react-hooks';
import useSuperIds from './useSuperIds.js';
import useVotingFor from './useVotingFor.js';
function useActivityImpl (palletVote: PalletVote, accountIds?: string[] | null): VoteResult | null | undefined {
const identities = useSuperIds(accountIds);
return useVotingFor(palletVote, identities);
}
export default createNamedHook('useActivity', useActivityImpl);
@@ -0,0 +1,27 @@
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { PalletVote } from '../../types.js';
import type { VoteResultItem } from './types.js';
import { useMemo } from 'react';
import { createNamedHook } from '@pezkuwi/react-hooks';
import useVotingFor from './useVotingFor.js';
function useActivityAccountImpl (palletVote: PalletVote, accountId?: string | null): VoteResultItem[] | null | undefined {
const params = useMemo(
() => (accountId && [accountId]) || null,
[accountId]
);
const votingFor = useVotingFor(palletVote, params);
// for a single account (which we assume is user-specified), we
// do not lookup the actual parent identity, use as-is
return votingFor && accountId
? votingFor[accountId] || []
: null;
}
export default createNamedHook('useActivityAccount', useActivityAccountImpl);
@@ -0,0 +1,18 @@
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { PalletVote } from '../../types.js';
import type { VoteResult } from './types.js';
import { createNamedHook } from '@pezkuwi/react-hooks';
import useActivity from './useActivity.js';
import useFellows from './useFellows.js';
function useActivityFellowsImpl (palletVote: PalletVote): VoteResult | null | undefined {
const fellows = useFellows();
return useActivity(palletVote, fellows);
}
export default createNamedHook('useActivityFellows', useActivityFellowsImpl);
@@ -0,0 +1,18 @@
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { PalletVote } from '../../types.js';
import type { VoteResult } from './types.js';
import { createNamedHook } from '@pezkuwi/react-hooks';
import useActivity from './useActivity.js';
import useNominators from './useNominators.js';
function useActivityNominatorsImpl (palletVote: PalletVote): VoteResult | null | undefined {
const nominators = useNominators();
return useActivity(palletVote, nominators);
}
export default createNamedHook('useActivityNominators', useActivityNominatorsImpl);
@@ -0,0 +1,29 @@
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { StorageKey } from '@pezkuwi/types';
import type { AccountId } from '@pezkuwi/types/interfaces';
import { useMemo } from 'react';
import { createNamedHook, useApi, useMapKeys } from '@pezkuwi/react-hooks';
import { isFunction } from '@pezkuwi/util';
const MEMBERS_OPT = {
transform: (keys: StorageKey<[AccountId]>[]): string[] =>
keys.map(({ args: [id] }) => id.toString())
};
function useFellowsImpl (): string[] | null | undefined {
const { api } = useApi();
const members = useMapKeys(api.query.fellowshipCollective?.members, [], MEMBERS_OPT);
return useMemo(
() => isFunction(api.query.fellowshipCollective?.members)
? members
: [],
[api, members]
);
}
export default createNamedHook('useFellows', useFellowsImpl);
@@ -0,0 +1,46 @@
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { Option } from '@pezkuwi/types';
import type { PalletStakingNominations } from '@pezkuwi/types/lookup';
import { useMemo } from 'react';
import { createNamedHook, useAccounts, useApi, useCall } from '@pezkuwi/react-hooks';
import { isFunction } from '@pezkuwi/util';
const NOMINATORS_OPT = {
transform: (optNominators: Option<PalletStakingNominations>[]): string[] =>
optNominators.reduce<string[]>((all, o) =>
o.isSome
? all.concat(
o.unwrap().targets
.map((w) => w.toString())
.filter((w) => !all.includes(w))
)
: all, []
)
};
// A list of all validators that any of our accounts nominate
// (deduped across accounts)
function useNominatorsImpl (): string[] | null | undefined {
const { api } = useApi();
const { allAccounts } = useAccounts();
const nomineesParam = useMemo(
() => [allAccounts],
[allAccounts]
);
const nominees = useCall(!!allAccounts.length && nomineesParam && api.query.staking?.nominators?.multi, nomineesParam, NOMINATORS_OPT);
return useMemo(
() => isFunction(api.query.staking?.nominators) && allAccounts.length
? nominees
: [],
[allAccounts, api, nominees]
);
}
export default createNamedHook('useNominators', useNominatorsImpl);
@@ -0,0 +1,57 @@
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { Option } from '@pezkuwi/types';
import type { AccountId } from '@pezkuwi/types/interfaces';
import type { ITuple } from '@pezkuwi/types/types';
import { useMemo } from 'react';
import { createNamedHook, useApi, useCall } from '@pezkuwi/react-hooks';
import { isFunction } from '@pezkuwi/util';
const SUPEROF_OPT = {
transform: ([[ids], optSupers]: [[string[]], Option<ITuple<[AccountId]>>[]]): string[] =>
optSupers
.map((opt, index) =>
// if we have a super, use that, otherwise we default to
// the actual passed-in identity (which is top-level)
opt.isSome
? opt.unwrap()[0].toString()
: ids[index]
)
.reduce((all: string[], who): string[] => {
// deupe all entries since we may have multiple nominees
if (!all.includes(who)) {
all.push(who);
}
return all;
}, []),
withParamsTransform: true
};
function useSuperIdsImpl (accountIds?: string[] | null): string[] | null | undefined {
const { apiIdentity } = useApi();
// for the supplied accounts, retrieve the de-dupes parent identity
const identityParam = useMemo(
() => accountIds && [accountIds],
[accountIds]
);
const identities = useCall(identityParam && !!identityParam[0].length && apiIdentity.query.identity?.superOf?.multi, identityParam, SUPEROF_OPT);
return useMemo(
() => identityParam
? identityParam[0].length
? isFunction(apiIdentity.query.identity?.superOf)
? identities
: accountIds
: []
: null,
[apiIdentity, accountIds, identities, identityParam]
);
}
export default createNamedHook('useSuperIds', useSuperIdsImpl);
@@ -0,0 +1,91 @@
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { PalletConvictionVotingVoteVoting } from '@pezkuwi/types/lookup';
import type { BN } from '@pezkuwi/util';
import type { PalletVote } from '../../types.js';
import type { LockResult, VoteResult, VoteResultItem } from './types.js';
import { useMemo } from 'react';
import { createNamedHook, useApi, useCall } from '@pezkuwi/react-hooks';
import { isFunction } from '@pezkuwi/util';
import useVotingLocks from './useVotingLocks.js';
type ForParam = [accountId: string, classId: BN];
const FOR_OPT = {
transform: ([[ids], votes]: [[ForParam[]], PalletConvictionVotingVoteVoting[]]): VoteResult =>
ids.sort((a, b) => a[1].cmp(b[1])).reduce<VoteResult>((all, [accountId, classId], index) => {
if (!all[accountId]) {
all[accountId] = [];
}
let casting: VoteResultItem['casting'] | undefined;
let delegating: VoteResultItem['delegating'] | undefined;
if (votes[index].isCasting) {
casting = votes[index].asCasting.votes.map(([refId]) => ({ refId }));
} else if (votes[index].isDelegating) {
const { conviction, target } = votes[index].asDelegating;
delegating = { conviction: conviction.type, targetId: target.toString() };
} else {
// failsafe log... just in-case
console.error(`Unable to handle PalletConvictionVotingVoteVoting type ${votes[index].type}`);
}
all[accountId].push({ casting, classId, delegating });
return all;
}, {}),
withParamsTransform: true
};
function getParams (locks?: LockResult | null): ForParam[] | null | undefined {
return locks && Object.entries(locks).reduce<ForParam[]>((all, [accountId, items]) => {
return items.reduce<ForParam[]>((all, { classId }) => {
all.push(([accountId, classId]));
return all;
}, all);
}, []);
}
function combineResult (locks: LockResult, votes: VoteResult): VoteResult {
return Object.keys(locks).reduce<VoteResult>((all, accountId) => {
if (!all[accountId]) {
// when it appears in the original keys, but not here
// we just add an empty value to keep track of this one
all[accountId] = [];
}
return all;
}, votes);
}
function useVotingForImpl (palletVote: PalletVote, accountIds?: string[] | null): VoteResult | null | undefined {
const { api } = useApi();
const locks = useVotingLocks(palletVote, accountIds);
const forParam = useMemo(
() => [getParams(locks)],
[locks]
);
const votes = useCall(forParam?.[0] && api.query[palletVote]?.votingFor?.multi, forParam, FOR_OPT);
return useMemo(
() => locks && forParam
? forParam[0]
? isFunction(api.query[palletVote]?.votingFor)
? votes && combineResult(locks, votes)
: locks
: {}
: null,
[api, locks, forParam, palletVote, votes]
);
}
export default createNamedHook('useVotingFor', useVotingForImpl);
@@ -0,0 +1,51 @@
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { INumber } from '@pezkuwi/types/types';
import type { PalletVote } from '../../types.js';
import type { LockResult } from './types.js';
import { useMemo } from 'react';
import { createNamedHook, useApi, useCall } from '@pezkuwi/react-hooks';
import { isFunction } from '@pezkuwi/util';
const LOCKS_OPT = {
transform: ([[ids], locks]: [[string[]], [INumber, INumber][][]]): LockResult =>
ids.reduce<LockResult>((all, accountId, index) => ({
...all,
[accountId]: locks[index].map(([classId, value]) => ({
balance: value.toBn(),
classId: classId.toBn()
}))
}), {}),
withParamsTransform: true
};
function useVotingLocksImpl (palletVote: PalletVote, accountIds?: string[] | null): LockResult | null | undefined {
const { api } = useApi();
const locksParam = useMemo(
() => accountIds
? accountIds.length
? [accountIds]
: []
: undefined,
[accountIds]
);
const locks = useCall(locksParam?.[0] && api.query[palletVote]?.classLocksFor?.multi, locksParam, LOCKS_OPT);
return useMemo(
() => locksParam
? locksParam[0]
? isFunction(api.query[palletVote]?.classLocksFor)
? locks
: locksParam[0].reduce<LockResult>((all, accountId) => ({ ...all, [accountId]: [] }), {})
: {}
: null,
[api, locks, locksParam, palletVote]
);
}
export default createNamedHook('useVotingLocks', useVotingLocksImpl);
@@ -0,0 +1,88 @@
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { PalletReferendaTrackDetails } from '@pezkuwi/types/lookup';
import type { BN } from '@pezkuwi/util';
import type { PalletReferenda } from '../../types.js';
import React, { useState } from 'react';
import { Button, InputAddress, InputBalance, InputNumber, Modal, TxButton } from '@pezkuwi/react-components';
import { useApi, useToggle } from '@pezkuwi/react-hooks';
import { Available } from '@pezkuwi/react-query';
import { useTranslation } from '../../translate.js';
interface Props {
className?: string;
id: BN;
palletReferenda: PalletReferenda;
track: PalletReferendaTrackDetails;
}
function Deposit ({ className = '', id, palletReferenda, track }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
const { api } = useApi();
const [isOpen, toggleOpen] = useToggle();
const [accountId, setAccountId] = useState<string | null>(null);
return (
<>
{isOpen && (
<Modal
className={className}
header={t('Place decision deposit')}
onClose={toggleOpen}
size='large'
>
<Modal.Content>
<Modal.Columns hint={t('The deposit will be registered from this account and the balance lock will be applied here.')}>
<InputAddress
label={t('deposit from account')}
labelExtra={
<Available
label={<span className='label'>{t('transferable')}</span>}
params={accountId}
/>
}
onChange={setAccountId}
type='account'
/>
</Modal.Columns>
<Modal.Columns hint={t('The referendum this deposit would apply to.')}>
<InputNumber
defaultValue={id}
isDisabled
label={t('referendum id')}
/>
</Modal.Columns>
<Modal.Columns hint={t('The deposit for this proposal will be locked for the referendum duration.')}>
<InputBalance
defaultValue={track.decisionDeposit}
isDisabled
label={t('decision deposit')}
/>
</Modal.Columns>
</Modal.Content>
<Modal.Actions>
<TxButton
accountId={accountId}
icon='plus'
label={t('Place deposit')}
onStart={toggleOpen}
params={[id]}
tx={api.tx[palletReferenda as 'referenda'].placeDecisionDeposit}
/>
</Modal.Actions>
</Modal>
)}
<Button
icon='plus'
label={t('Decision deposit')}
onClick={toggleOpen}
/>
</>
);
}
export default React.memo(Deposit);
@@ -0,0 +1,72 @@
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { BN } from '@pezkuwi/util';
import type { PalletReferenda } from '../../types.js';
import React, { useState } from 'react';
import { Button, InputAddress, InputNumber, Modal, TxButton } from '@pezkuwi/react-components';
import { useApi, useToggle } from '@pezkuwi/react-hooks';
import { useTranslation } from '../../translate.js';
interface Props {
className?: string;
id: BN;
palletReferenda: PalletReferenda;
}
function Refund ({ className = '', id, palletReferenda }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
const { api } = useApi();
const [isOpen, toggleOpen] = useToggle();
const [accountId, setAccountId] = useState<string | null>(null);
return (
<>
{isOpen && (
<Modal
className={className}
header={t('Refund decision deposit')}
onClose={toggleOpen}
size='large'
>
<Modal.Content>
<Modal.Columns hint={t('The transaction will be submitted from this account.')}>
<InputAddress
label={t('refund from account')}
onChange={setAccountId}
type='account'
/>
</Modal.Columns>
<Modal.Columns hint={t('The referendum this deposit would apply to.')}>
<InputNumber
defaultValue={id}
isDisabled
label={t('referendum id')}
/>
</Modal.Columns>
</Modal.Content>
<Modal.Actions>
<TxButton
accountId={accountId}
icon='minus'
label={t('Refund deposit')}
onStart={toggleOpen}
params={[id]}
tx={api.tx[palletReferenda as 'referenda'].refundDecisionDeposit}
/>
</Modal.Actions>
</Modal>
)}
<Button
icon='minus'
label={t('Refund deposit')}
onClick={toggleOpen}
/>
</>
);
}
export default React.memo(Refund);
@@ -0,0 +1,77 @@
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { PalletReferendaDeposit, PalletReferendaTrackDetails } from '@pezkuwi/types/lookup';
import type { BN } from '@pezkuwi/util';
import type { PalletReferenda } from '../../types.js';
import React from 'react';
import { AddressMini, styled } from '@pezkuwi/react-components';
import Place from './Place.js';
import Refund from './Refund.js';
interface Props {
canDeposit?: boolean;
canRefund?: boolean;
className?: string;
decision: PalletReferendaDeposit | null;
id: BN;
noMedia?: boolean;
palletReferenda: PalletReferenda;
submit: PalletReferendaDeposit | null;
track?: PalletReferendaTrackDetails;
}
function Deposits ({ canDeposit, canRefund, className = '', decision, id, noMedia, palletReferenda, submit, track }: Props): React.ReactElement<Props> {
return (
<StyledTd className={`${className} address ${noMedia ? '' : 'media--1000-noPad'}`}>
{submit && (
<AddressMini
balance={submit.amount}
className={noMedia ? '' : 'media--1000'}
value={submit.who}
withBalance
/>
)}
{decision
? (
<>
<AddressMini
balance={decision.amount}
className={noMedia ? '' : 'media--1000'}
value={decision.who}
withBalance
/>
{canRefund && (
<div className={noMedia ? '' : 'media--1000'}>
<Refund
id={id}
palletReferenda={palletReferenda}
/>
</div>
)}
</>
)
: canDeposit && track && (
<div className={noMedia ? '' : 'media--1000'}>
<Place
id={id}
palletReferenda={palletReferenda}
track={track}
/>
</div>
)
}
</StyledTd>
);
}
const StyledTd = styled.td`
.ui--AddressMini+.ui--Button {
margin-top: 0.25rem;
}
`;
export default React.memo(Deposits);
@@ -0,0 +1,93 @@
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { BN } from '@pezkuwi/util';
import type { PalletReferenda, PalletVote, ReferendaGroup, TrackDescription } from '../types.js';
import React, { useMemo } from 'react';
import { ExpandButton, Table } from '@pezkuwi/react-components';
import { useApi, useToggle } from '@pezkuwi/react-hooks';
import { useTranslation } from '../translate.js';
import { getTrackInfo } from '../util.js';
import Referendum from './Referendum.js';
interface Props extends ReferendaGroup {
activeIssuance?: BN;
className?: string;
isMember: boolean;
members?: string[];
palletReferenda: PalletReferenda;
palletVote: PalletVote;
ranks?: BN[];
tracks: TrackDescription[];
}
function Group ({ activeIssuance, className, isMember, members, palletReferenda, palletVote, ranks, referenda, trackId, trackName, tracks }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api, specName } = useApi();
const [isExpanded, toggleExpanded] = useToggle();
const trackInfo = useMemo(
() => getTrackInfo(api, specName, palletReferenda, tracks, trackId?.toNumber()),
[api, specName, palletReferenda, tracks, trackId]
);
const [headerButton, headerChildren] = useMemo(
() => [
false && trackInfo && (
<ExpandButton
expanded={isExpanded}
onClick={toggleExpanded}
/>
),
isExpanded && trackInfo && (
<tr>
<th colSpan={8} />
</tr>
)
],
[isExpanded, toggleExpanded, trackInfo]
);
const [header, key] = useMemo(
(): [([React.ReactNode?, string?, number?] | null)[], string] => [
[
[trackName ? <>{trackName}<div className='sub'>{trackInfo?.text}</div></> : t('referenda'), 'start', 8],
[headerButton]
],
trackName
? `track:${trackName}`
: 'untracked'
],
[headerButton, t, trackInfo, trackName]
);
return (
<Table
className={className}
empty={referenda && t('No active referenda')}
header={header}
headerChildren={headerChildren}
isSplit={!trackId}
key={key}
>
{referenda?.map((r) => (
<Referendum
activeIssuance={activeIssuance}
isMember={isMember}
key={r.key}
members={members}
palletReferenda={palletReferenda}
palletVote={palletVote}
ranks={ranks}
trackInfo={trackInfo}
value={r}
/>
))}
</Table>
);
}
export default React.memo(Group);
@@ -0,0 +1,41 @@
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { BN } from '@pezkuwi/util';
import React from 'react';
import { useBestNumberRelay, useStakingAsyncApis } from '@pezkuwi/react-hooks';
import { BlockToTime } from '@pezkuwi/react-query';
import { formatNumber } from '@pezkuwi/util';
interface Props {
className?: string;
label: string;
when: BN | null;
}
function RefEnd ({ className = '', label, when }: Props): React.ReactElement<Props> {
const bestNumber = useBestNumberRelay();
const { isStakingAsync, rcApi } = useStakingAsyncApis();
return (
<td className={`${className} number`}>
{bestNumber && when && (
<>
<div>{label}</div>
{/* Remaining period should be decided based on Relay chain */}
{when.gt(bestNumber) && (
<BlockToTime
api={isStakingAsync ? rcApi : undefined}
value={when.sub(bestNumber)}
/>
)}
<div>#{formatNumber(when)}</div>
</>
)}
</td>
);
}
export default React.memo(RefEnd);
@@ -0,0 +1,32 @@
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ReferendumProps as Props } from '../types.js';
import React, { useMemo } from 'react';
import RefEnd from './RefEnd.js';
function RefOther ({ value: { info } }: Props): React.ReactElement<Props> {
const when = useMemo(
() => info.isKilled
? info.asKilled
: null,
[info]
);
return (
<>
<td
className='all no-pad'
colSpan={5}
/>
<RefEnd
label={info.type}
when={when}
/>
</>
);
}
export default React.memo(RefOther);
@@ -0,0 +1,154 @@
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ApiPromise } from '@pezkuwi/api';
import type { Hash } from '@pezkuwi/types/interfaces';
import type { PalletConvictionVotingTally, PalletRankedCollectiveTally, PalletReferendaDeposit, PalletReferendaReferendumStatusConvictionVotingTally, PalletReferendaReferendumStatusRankedCollectiveTally, PalletReferendaTrackDetails } from '@pezkuwi/types/lookup';
import type { BN } from '@pezkuwi/util';
import type { HexString } from '@pezkuwi/util/types';
import type { Referendum, ReferendumProps as Props } from '../types.js';
import React, { useMemo } from 'react';
import { Progress } from '@pezkuwi/react-components';
import { useApi, usePreimage } from '@pezkuwi/react-hooks';
import { getPreimageHash } from '@pezkuwi/react-hooks/usePreimage';
import { CallExpander } from '@pezkuwi/react-params';
import { useTranslation } from '../translate.js';
import Deposits from './Deposits/index.js';
import Vote from './Vote/index.js';
import RefEnd from './RefEnd.js';
import { unwrapDeposit } from './util.js';
import Votes from './Votes.js';
interface Expanded {
decisionDeposit: PalletReferendaDeposit | null;
periods: {
periodEnd: BN | null;
prepareEnd: BN | null;
decideEnd: BN | null;
confirmEnd: BN | null;
};
ongoing: PalletReferendaReferendumStatusConvictionVotingTally | PalletReferendaReferendumStatusRankedCollectiveTally;
proposalHash?: HexString;
submissionDeposit: PalletReferendaDeposit | null;
tally: PalletConvictionVotingTally | PalletRankedCollectiveTally;
tallyTotal: BN;
}
function expandOngoing (api: ApiPromise, info: Referendum['info'], track?: PalletReferendaTrackDetails): Expanded {
const ongoing = info.asOngoing;
const proposalHash = getPreimageHash(api, ongoing.proposal || (ongoing as unknown as { proposalHash: Hash }).proposalHash).proposalHash;
let prepareEnd: BN | null = null;
let decideEnd: BN | null = null;
let confirmEnd: BN | null = null;
if (track) {
const { deciding, submitted } = ongoing;
if (deciding.isSome) {
const { confirming, since } = deciding.unwrap();
if (confirming.isSome) {
// we are confirming with the specific end block
confirmEnd = confirming.unwrap();
} else {
// we are still deciding, start + length
decideEnd = since.add(track.decisionPeriod);
}
} else {
// we are still preparing, start + length
prepareEnd = submitted.add(track.preparePeriod);
}
}
return {
decisionDeposit: unwrapDeposit(ongoing.decisionDeposit),
ongoing,
periods: {
confirmEnd,
decideEnd,
periodEnd: confirmEnd || decideEnd || prepareEnd,
prepareEnd
},
proposalHash,
submissionDeposit: unwrapDeposit(ongoing.submissionDeposit),
tally: ongoing.tally,
tallyTotal: ongoing.tally.ayes.add(ongoing.tally.nays)
};
}
function Ongoing ({ isMember, members, palletReferenda, palletVote, ranks, trackInfo, value: { id, info, isConvictionVote, track } }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api } = useApi();
const { decisionDeposit, ongoing, periods: { confirmEnd, decideEnd, periodEnd }, submissionDeposit, tally, tallyTotal } = useMemo(
() => expandOngoing(api, info, track),
[api, info, track]
);
const preimage = usePreimage(ongoing.proposal || (ongoing as unknown as { proposalHash: Hash }).proposalHash);
return (
<>
<td className='all'>
{preimage?.proposal
? (
<CallExpander
labelHash={t('preimage')}
value={preimage.proposal}
withHash
/>
)
: <div className='shortHash'>{preimage?.proposalHash}</div>
}
</td>
<Deposits
canDeposit
decision={decisionDeposit}
id={id}
palletReferenda={palletReferenda}
submit={submissionDeposit}
track={track}
/>
<RefEnd
label={
confirmEnd
? t('Confirming')
: decideEnd
? t('Deciding')
: t('Preparing')
}
when={periodEnd}
/>
<Votes
id={id}
isConvictionVote={isConvictionVote}
palletVote={palletVote}
tally={tally}
/>
<td className='middle chart media--1300-noPad'>
<Progress
className='media--1300'
total={tallyTotal}
value={tally.ayes}
/>
</td>
<td className='actions button'>
<Vote
id={id}
isConvictionVote={isConvictionVote}
isMember={isMember}
members={members}
palletVote={palletVote}
preimage={preimage}
ranks={ranks}
trackInfo={trackInfo}
/>
</td>
</>
);
}
export default React.memo(Ongoing);
@@ -0,0 +1,74 @@
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { PalletReferendaDeposit } from '@pezkuwi/types/lookup';
import type { BN } from '@pezkuwi/util';
import type { Referendum, ReferendumProps as Props } from '../types.js';
import React, { useMemo } from 'react';
import Deposits from './Deposits/index.js';
import RefEnd from './RefEnd.js';
import { unwrapDeposit } from './util.js';
interface Expanded {
decision: PalletReferendaDeposit | null;
submit: PalletReferendaDeposit | null;
when: BN | null;
}
function expandTuple (info: Referendum['info']): Expanded {
const data = info.isApproved
? info.asApproved
: info.isRejected
? info.asRejected
: info.isCancelled
? info.asCancelled
: info.isTimedOut
? info.asTimedOut
: null;
return data
? {
decision: unwrapDeposit(data[2]),
submit: unwrapDeposit(data[1]),
when: data[0]
}
: {
decision: null,
submit: null,
when: null
};
}
function Tuple ({ palletReferenda, value: { id, info, track } }: Props): React.ReactElement<Props> {
const { decision, submit, when } = useMemo(
() => expandTuple(info),
[info]
);
return (
<>
<td
className='no-pad'
colSpan={4}
/>
<Deposits
canRefund
className='all'
decision={decision}
id={id}
noMedia
palletReferenda={palletReferenda}
submit={submit}
track={track}
/>
<RefEnd
label={info.type}
when={when}
/>
</>
);
}
export default React.memo(Tuple);
@@ -0,0 +1,494 @@
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ChartOptions, ChartTypeRegistry, TooltipItem } from 'chart.js';
import type { PalletConvictionVotingTally, PalletRankedCollectiveTally, PalletReferendaReferendumInfoConvictionVotingTally, PalletReferendaReferendumInfoRankedCollectiveTally, PalletReferendaTrackDetails } from '@pezkuwi/types/lookup';
import type { BN } from '@pezkuwi/util';
import type { CurveGraph, ReferendumProps as Props } from '../types.js';
import React, { useMemo } from 'react';
import { Chart, Columar, LinkExternal, styled, Table } from '@pezkuwi/react-components';
import { useBestNumberRelay, useBlockInterval, useStakingAsyncApis, useToggle } from '@pezkuwi/react-hooks';
import { calcBlockTime } from '@pezkuwi/react-hooks/useBlockTime';
import { BN_MILLION, BN_THOUSAND, bnMax, bnToBn, formatNumber, objectSpread } from '@pezkuwi/util';
import { useTranslation } from '../translate.js';
import Killed from './RefKilled.js';
import Ongoing from './RefOngoing.js';
import Tuple from './RefTuple.js';
const COMPONENTS: Record<string, React.ComponentType<Props>> = {
Killed,
Ongoing
};
const VAL_COLORS = ['#ff8c00', '#9c3333', '#339c33'];
const BOX_COLORS = {
conf: 'rgba(255, 140, 0, 0.1)',
enac: 'rgba(0, 0, 140, 0.1)',
fail: 'rgba(140, 0, 0, 0.02)',
pass: 'rgba(0, 140, 0, 0.02)',
past: 'rgba(140, 140, 140, 0.2)'
};
const PT_CUR = 0;
const PT_NEG = 1;
const PT_POS = 2;
const OPTIONS: ChartOptions = {
aspectRatio: 2.25,
maintainAspectRatio: true,
scales: {
y: {
beginAtZero: true
}
}
};
interface ChartResult {
progress: {
percent: number;
value: BN;
total: BN;
};
labels: string[];
values: number[][];
}
interface ChartResultExt extends ChartResult {
changeX: number;
currentY: number;
endConfirm: BN | null;
points: BN[];
since: BN;
}
interface ChartProps extends ChartResult {
colors: string[];
options: typeof OPTIONS;
}
function createTitleCallback (t: (key: string, options?: { replace: Record<string, unknown> }) => string, bestNumber: BN, blockInterval: BN, extraFn: (blockNumber: BN) => string): (items: TooltipItem<keyof ChartTypeRegistry>[]) => string | string[] {
return ([{ label }]: TooltipItem<keyof ChartTypeRegistry>[]): string | string[] => {
try {
const blockNumber = bnToBn(label.replace(/,/g, ''));
const extraTitle = extraFn(blockNumber);
if (blockNumber.gt(bestNumber)) {
const blocks = blockNumber.sub(bestNumber);
const when = new Date(Date.now() + blocks.mul(blockInterval).toNumber()).toLocaleString();
const calc = calcBlockTime(blockInterval, blocks, t);
const result = [`#${label}`, t('{{when}} (est.)', { replace: { when } }), calc[1]];
if (extraTitle) {
result.push(extraTitle);
}
return result;
}
if (extraTitle) {
return [`#${label}`, extraTitle];
}
} catch {
// ignore
}
return `#${label}`;
};
}
function getChartResult (totalEligible: BN, isConvictionVote: boolean, info: PalletReferendaReferendumInfoConvictionVotingTally | PalletReferendaReferendumInfoRankedCollectiveTally, track: PalletReferendaTrackDetails, trackGraph: CurveGraph): ChartResultExt[] | null {
if (totalEligible && isConvictionVote && info.isOngoing) {
const ongoing = info.asOngoing;
if (ongoing.deciding.isSome) {
const { approval, support, x } = trackGraph;
const { deciding, tally } = ongoing;
const { confirming, since } = deciding.unwrap();
const endConfirm = confirming.unwrapOr(null);
const currentSupport = isConvictionVote
? (tally as PalletConvictionVotingTally).support
: (tally as PalletRankedCollectiveTally).bareAyes;
const labels: string[] = [];
const values: number[][][] = [[[], [], []], [[], [], []]];
const supc = totalEligible.isZero()
? 0
: currentSupport.mul(BN_THOUSAND).div(totalEligible).toNumber() / 10;
const appc = tally.ayes.isZero()
? 0
: tally.ayes.mul(BN_THOUSAND).div(tally.ayes.add(tally.nays)).toNumber() / 10;
let appx = -1;
let supx = -1;
const points: BN[] = [];
for (let i = 0; i < approval.length; i++) {
labels.push(formatNumber(since.add(x[i])));
points.push(x[i]);
const appr = approval[i].div(BN_MILLION).toNumber() / 10;
const appn = appc < appr;
values[0][PT_CUR][i] = appr;
values[0][appn ? PT_NEG : PT_POS][i] = appc;
appx = (appn || appx !== -1) ? appx : i;
const supr = support[i].div(BN_MILLION).toNumber() / 10;
const supn = supc < supr;
values[1][PT_CUR][i] = supr;
values[1][supn ? PT_NEG : PT_POS][i] = supc;
supx = (supn || supx !== -1) ? supx : i;
}
// Bringing it to a higher precision.
// Otherwise, graphs with short periods (on dev chains) are invalid.
const stepWithPrecision = x[x.length - 1].sub(x[0]).muln(100).divn(x.length);
const lastIndex = x.length - 1;
const lastBlock = endConfirm?.add(track.minEnactmentPeriod);
// if the confirmation end is later than shown on our graph, we extend it
if (lastBlock?.gt(since.add(x[lastIndex]))) {
let currentBlockWithPrecision = x[lastIndex].add(since).muln(100).add(stepWithPrecision);
let currentBlock = currentBlockWithPrecision.divn(100);
do {
labels.push(formatNumber(currentBlock));
points.push(currentBlock.sub(since));
// adjust approvals (no curve adjustment)
// values[0][0].push(values[0][0][lastIndex]);
values[0][1].push(values[0][1][lastIndex]);
values[0][2].push(values[0][2][lastIndex]);
// // adjust support
// values[1][0].push(values[1][0][lastIndex]);
values[1][1].push(values[1][1][lastIndex]);
values[1][2].push(values[1][2][lastIndex]);
currentBlockWithPrecision = currentBlockWithPrecision.add(stepWithPrecision);
currentBlock = currentBlockWithPrecision.divn(100);
} while (currentBlock.lt(lastBlock));
}
return [
{ changeX: appx, currentY: appc, endConfirm, labels, points, progress: { percent: appc, total: ongoing.tally.ayes.add(ongoing.tally.nays), value: ongoing.tally.ayes }, since, values: values[0] },
{ changeX: supx, currentY: supc, endConfirm, labels, points, progress: { percent: supc, total: totalEligible, value: currentSupport }, since, values: values[1] }
];
}
}
return null;
}
function getChartProps (bestNumber: BN, blockInterval: BN, chartProps: ChartResultExt[], refId: BN, track: PalletReferendaTrackDetails, t: (key: string, options?: { replace: Record<string, unknown> }) => string): ChartProps[] {
const changeXMax = chartProps.reduce((max, { changeX }) =>
max === -1 || changeX === -1
? -1
: Math.max(max, changeX),
0);
return chartProps.map(({ changeX, currentY, endConfirm, labels, points, progress, since, values }, index): ChartProps => {
const maxX = labels.length;
const maxY = index === 0
? 100
: 50;
const blockToX = (value: BN) =>
Math.max(0, Math.min(maxX, maxX * (
value.sub(since).toNumber() / points[points.length - 1].toNumber()
)));
const swapX = changeX === -1
? -1
: maxX * (changeX / points.length);
const enactX = changeXMax !== -1 && bnMax(bestNumber, points[changeXMax].add(since));
const confirmX = endConfirm
? [endConfirm.sub(track.confirmPeriod), endConfirm, endConfirm.add(track.minEnactmentPeriod)]
: enactX
? [enactX, enactX.add(track.confirmPeriod), enactX.add(track.confirmPeriod).add(track.minEnactmentPeriod)]
: null;
const title = createTitleCallback(t, bestNumber, blockInterval, (blockNumber) =>
confirmX && blockNumber.gte(confirmX[0])
? blockNumber.lte(confirmX[1])
? t('Confirmation period')
: blockNumber.lte(confirmX[2])
? t('Enactment period')
: ''
: ''
);
return {
colors: VAL_COLORS,
labels,
options: objectSpread({
plugins: {
annotation: {
annotations: objectSpread(
{
past: {
backgroundColor: BOX_COLORS.past,
borderWidth: 0,
type: 'box',
xMax: blockToX(bestNumber),
xMin: 0,
yMax: maxY,
yMin: 0
}
},
confirmX
? {
conf: {
backgroundColor: BOX_COLORS.conf,
borderWidth: 0,
type: 'box',
xMax: blockToX(confirmX[1]),
xMin: blockToX(confirmX[0]),
yMax: maxY,
yMin: 0
},
enac: {
backgroundColor: BOX_COLORS.enac,
borderWidth: 0,
type: 'box',
xMax: blockToX(confirmX[2]),
xMin: blockToX(confirmX[1]),
yMax: maxY,
yMin: 0
}
}
: {},
{
fail: {
backgroundColor: BOX_COLORS.fail,
borderWidth: 0,
type: 'box',
xMax: swapX === -1
? maxX
: swapX,
xMin: 0,
yMax: currentY,
yMin: 0
}
},
swapX !== -1
? {
pass: {
backgroundColor: BOX_COLORS.pass,
borderWidth: 0,
type: 'box',
xMax: maxX,
xMin: swapX,
yMax: currentY,
yMin: 0
}
}
: {}
)
},
crosshair: {
sync: {
group: refId.toNumber()
}
},
tooltip: {
callbacks: {
title
}
}
}
}, OPTIONS),
progress,
values
};
});
}
function extractInfo (info: PalletReferendaReferendumInfoConvictionVotingTally | PalletReferendaReferendumInfoRankedCollectiveTally, track?: PalletReferendaTrackDetails): { confirmEnd: BN | null, enactAt: { at: boolean, blocks: BN, end: BN | null } | null, nextAlarm: null | BN, submittedIn: null | BN } {
let confirmEnd: BN | null = null;
let enactAt: { at: boolean, blocks: BN, end: BN | null } | null = null;
let nextAlarm: BN | null = null;
let submittedIn: BN | null = null;
if (info.isOngoing) {
const { alarm, deciding, enactment, submitted } = info.asOngoing;
enactAt = {
at: enactment.isAt,
blocks: enactment.isAt
? enactment.asAt
: enactment.asAfter,
end: null
};
nextAlarm = alarm.unwrapOr([null])[0];
submittedIn = submitted;
if (deciding.isSome) {
const { confirming } = deciding.unwrap();
if (confirming.isSome) {
// we are confirming with the specific end block
confirmEnd = confirming.unwrap();
if (track) {
// add our track data
const fastEnd = confirmEnd.add(track.minEnactmentPeriod);
enactAt.end = enactment.isAt
? bnMax(fastEnd, enactment.asAt)
: fastEnd.add(enactment.asAfter);
}
}
}
}
return { confirmEnd, enactAt, nextAlarm, submittedIn };
}
function Referendum (props: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { isStakingAsync, rcApi } = useStakingAsyncApis();
const bestNumber = useBestNumberRelay();
const blockInterval = useBlockInterval(isStakingAsync ? rcApi : undefined);
const { activeIssuance, className = '', palletReferenda, value: { id, info, isConvictionVote, track, trackGraph } } = props;
const [isExpanded, toggleExpanded] = useToggle(false);
const Component = useMemo(
() => COMPONENTS[info.type] || Tuple,
[info]
);
const chartResult = useMemo(
() => activeIssuance && track && trackGraph &&
getChartResult(activeIssuance, isConvictionVote, info, track, trackGraph),
[activeIssuance, info, isConvictionVote, track, trackGraph]
);
const chartProps = useMemo(
() => bestNumber && chartResult && isExpanded && track &&
getChartProps(bestNumber, blockInterval, chartResult, id, track, t),
[bestNumber, blockInterval, chartResult, id, isExpanded, t, track]
);
const { confirmEnd, enactAt, nextAlarm, submittedIn } = useMemo(
() => extractInfo(info, track),
[info, track]
);
const chartLegend = useMemo(
() => [
[
t('minimum approval'),
t('current approval (failing)'),
t('current approval (passing)')
],
[
t('minimum support'),
t('current support (failing)'),
t('current support (passing)')
]
],
[t]
);
return (
<>
<StyledTr className={`${className} isExpanded isFirst ${isExpanded ? '' : 'isLast'}`}>
<Table.Column.Id value={id} />
<Component {...props} />
<Table.Column.Expand
isExpanded={isExpanded}
toggle={toggleExpanded}
/>
</StyledTr>
<StyledTr className={`${className} ${isExpanded ? 'isExpanded isLast' : 'isCollapsed'}`}>
<td />
<td
className='columar'
colSpan={6}
>
{chartProps && (
<Columar>
<Columar.Column>
<Chart.Line
legends={chartLegend[0]}
title={t('approval / {{percent}}%', { replace: { percent: chartProps[0].progress.percent.toFixed(1) } })}
{...chartProps[0]}
/>
</Columar.Column>
<Columar.Column>
<Chart.Line
legends={chartLegend[1]}
title={t('support / {{percent}}%', { replace: { percent: chartProps[1].progress.percent.toFixed(1) } })}
{...chartProps[1]}
/>
</Columar.Column>
</Columar>
)}
<Columar size='tiny'>
<Columar.Column>
{submittedIn && (
<>
<h5>{t('Submitted at')}</h5>
#{formatNumber(submittedIn)}
</>
)}
{nextAlarm && (
<>
<h5>{t('Next alarm')}</h5>
#{formatNumber(nextAlarm)}
</>
)}
</Columar.Column>
<Columar.Column>
{enactAt && (
<>
<h5>{enactAt.at ? t('Enact at') : t('Enact after')}</h5>
{enactAt.at && '#'}{t('{{blocks}} blocks', { replace: { blocks: formatNumber(enactAt.blocks) } })}
</>
)}
{confirmEnd && (
<>
<h5>{t('Confirm end')}</h5>
#{formatNumber(confirmEnd)}
</>
)}
{enactAt?.end && (
<>
<h5>{t('Enact end')}</h5>
#{formatNumber(enactAt.end)}
</>
)}
</Columar.Column>
</Columar>
<Columar
is100
size='tiny'
>
<Columar.Column>
<LinkExternal
data={id}
type={palletReferenda}
withTitle
/>
</Columar.Column>
</Columar>
</td>
<td />
</StyledTr>
</>
);
}
const StyledTr = styled.tr`
.shortHash {
max-width: var(--width-shorthash);
min-width: 3em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: var(--width-shorthash);
}
`;
export default React.memo(Referendum);
@@ -0,0 +1,47 @@
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { BN } from '@pezkuwi/util';
import type { PalletReferenda, TrackDescription } from '../../types.js';
import React from 'react';
import { Dropdown, styled } from '@pezkuwi/react-components';
import { useTranslation } from '../../translate.js';
import useTrackOptions from './useTrackOptions.js';
interface Props {
className?: string;
exclude?: (BN | number)[];
include?: (BN | number)[];
onChange: (trackId: number) => void;
palletReferenda: PalletReferenda;
tracks: TrackDescription[];
}
function TrackDropdown ({ className, exclude, include, onChange, palletReferenda, tracks }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
const trackOpts = useTrackOptions(palletReferenda, tracks, include, exclude);
return (
<Dropdown
className={className}
defaultValue={trackOpts[0].value}
label={t('submission track')}
onChange={onChange}
options={trackOpts}
/>
);
}
export default React.memo(styled(TrackDropdown)`
.trackOption {
.faded {
font-size: var(--font-size-small);
font-weight: var(--font-weight-normal);
margin-top: 0.125rem;
opacity: 0.6;
}
}
`);
@@ -0,0 +1,307 @@
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { RawParam } from '@pezkuwi/react-params/types';
import type { BN } from '@pezkuwi/util';
import type { HexString } from '@pezkuwi/util/types';
import type { PalletReferenda, TrackDescription } from '../../types.js';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Button, Dropdown, Input, InputAddress, InputBalance, InputNumber, Modal, styled, ToggleGroup, TxButton } from '@pezkuwi/react-components';
import { useApi, useBestNumber, usePreimage, useToggle } from '@pezkuwi/react-hooks';
import Params from '@pezkuwi/react-params';
import { Available } from '@pezkuwi/react-query';
import { getTypeDef } from '@pezkuwi/types/create';
import { BN_HUNDRED, BN_ONE, BN_THOUSAND, BN_ZERO, isHex } from '@pezkuwi/util';
import { useTranslation } from '../../translate.js';
import { getTrackInfo } from '../../util.js';
import TrackDropdown from './TrackDropdown.js';
interface Props {
className?: string;
isMember: boolean;
members?: string[];
palletReferenda: PalletReferenda;
tracks: TrackDescription[];
}
interface HashState {
imageHash?: HexString | null;
isImageHashValid: boolean;
}
interface ImageState {
imageLen: BN;
imageLenDefault?: BN;
isImageLenValid: boolean;
}
function Submit ({ className = '', isMember, members, palletReferenda, tracks }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
const { api, specName } = useApi();
const bestNumber = useBestNumber();
const [isOpen, toggleOpen] = useToggle();
const [accountId, setAccountId] = useState<string | null>(null);
const [trackId, setTrack] = useState<number | undefined>(undefined);
const [origin, setOrigin] = useState<RawParam['value']>(null);
const [{ imageHash, isImageHashValid }, setImageHash] = useState<HashState>({ imageHash: null, isImageHashValid: false });
const [{ imageLen, imageLenDefault, isImageLenValid }, setImageLen] = useState<ImageState>({ imageLen: BN_ZERO, isImageLenValid: false });
const [enactIndex, setEnactIndex] = useState(0);
const [afterBlocks, setAfterBlocks] = useState<BN | undefined>(BN_HUNDRED);
const [atBlock, setAtBlock] = useState<BN | undefined>(BN_ONE);
const [initialAt, setInitialAt] = useState<BN | undefined>();
const preimage = usePreimage(imageHash);
useEffect((): void => {
bestNumber && setInitialAt((prev) =>
prev || bestNumber.add(BN_THOUSAND)
);
}, [bestNumber]);
useEffect((): void => {
preimage?.proposalLength && setImageLen((prev) => ({
imageLen: prev.imageLen,
imageLenDefault: preimage.proposalLength,
isImageLenValid: prev.isImageLenValid
}));
}, [preimage]);
const trackInfo = useMemo(
() => getTrackInfo(api, specName, palletReferenda, tracks, trackId),
[api, palletReferenda, specName, trackId, tracks]
);
const isInvalidAt = useMemo(
() => !bestNumber || (
enactIndex === 0
? afterBlocks?.lt(BN_ONE)
: atBlock?.lt(bestNumber)
),
[afterBlocks, atBlock, bestNumber, enactIndex]
);
const originType = useMemo(
() => [{
name: 'origin',
type: getTypeDef(api.tx[palletReferenda as 'referenda'].submit.meta.args[0].type.toString())
}],
[api, palletReferenda]
);
const originOptions = useMemo(
() => trackInfo && Array.isArray(trackInfo.origin)
? trackInfo.origin.map((records, index) => ({
text: Object.values(records)[0],
value: index + 1
}))
: null,
[trackInfo]
);
const selectedOrigin = useMemo(
() => !trackInfo?.origin || Array.isArray(trackInfo?.origin)
? origin
: trackInfo.origin,
[origin, trackInfo]
);
const enactOpts = useMemo(
() => [
{ text: t('After delay'), value: 'after' },
{ text: t('At block'), value: 'at' }
],
[t]
);
const _onChangeOriginMulti = useCallback(
(value: number) => setOrigin(
trackInfo && Array.isArray(trackInfo.origin)
? trackInfo.origin[value - 1]
: null
),
[trackInfo]
);
const _onChangeOrigin = useCallback(
([{ isValid, value }]: RawParam[]) =>
setOrigin(isValid ? value : null),
[]
);
const _onChangeImageHash = useCallback(
(h?: string) =>
setImageHash({
imageHash: h as HexString,
isImageHashValid: isHex(h, 256)
}),
[]
);
const _onChangeImageLen = useCallback(
(value?: BN): void => {
value && setImageLen((prev) => ({
imageLen: value,
imageLenDefault: prev.imageLenDefault,
isImageLenValid: !value.isZero()
}));
},
[]
);
return (
<>
{isOpen && (
<StyledModal
className={className}
header={t('Submit proposal')}
onClose={toggleOpen}
size='large'
>
<Modal.Content>
<Modal.Columns hint={t('The proposal will be registered from this account and the balance lock will be applied here.')}>
<InputAddress
filter={members}
label={t('propose from account')}
labelExtra={
<Available
label={<span className='label'>{t('transferable')}</span>}
params={accountId}
/>
}
onChange={setAccountId}
type='account'
/>
</Modal.Columns>
<Modal.Columns hint={t('The origin (and by extension track) that you wish to submit for, each has a different period, different root and acceptance criteria.')}>
<TrackDropdown
onChange={setTrack}
palletReferenda={palletReferenda}
tracks={tracks}
/>
{!trackInfo?.origin && (
<Params
className='originSelect'
onChange={_onChangeOrigin}
params={originType}
/>
)}
{originOptions && (
<Dropdown
defaultValue={originOptions[0].value}
label={t('track origin')}
onChange={_onChangeOriginMulti}
options={originOptions}
/>
)}
</Modal.Columns>
<Modal.Columns
hint={
<>
<p>{t('The hash of the preimage for the proposal as previously submitted or intended.')}</p>
<p>{t('The length value will be auto-populated from the on-chain value if it is found.')}</p>
</>
}
>
<Input
autoFocus
isError={!isImageHashValid}
label={t('preimage hash')}
onChange={_onChangeImageHash}
value={imageHash || ''}
/>
<InputNumber
defaultValue={imageLenDefault}
isDisabled={!!preimage?.proposalLength && !preimage?.proposalLength.isZero() && isImageHashValid && isImageLenValid}
isError={!isImageLenValid}
key='inputLength'
label={t('preimage length')}
onChange={_onChangeImageLen}
value={imageLen}
/>
</Modal.Columns>
<Modal.Columns
align='center'
hint={t('The moment of enactment, either at a specific block, or after a specific number of blocks.')}
>
<ToggleGroup
onChange={setEnactIndex}
options={enactOpts}
value={enactIndex}
/>
</Modal.Columns>
{enactIndex === 0
? (
<Modal.Columns hint={t('The number of blocks to delay enactment after proposal approval.')}>
<InputNumber
defaultValue={BN_HUNDRED}
isError={isInvalidAt}
label={t('after number of blocks')}
onChange={setAfterBlocks}
value={afterBlocks}
/>
</Modal.Columns>
)
: (
<Modal.Columns hint={t('A specific block to enact the proposal at.')}>
<InputNumber
defaultValue={initialAt}
isError={isInvalidAt}
label={t('at specific block')}
onChange={setAtBlock}
value={atBlock}
/>
</Modal.Columns>
)
}
<Modal.Columns hint={t('The deposit for this proposal will be locked for the referendum duration.')}>
<InputBalance
defaultValue={api.consts[palletReferenda as 'referenda'].submissionDeposit}
isDisabled
label={t('submission deposit')}
/>
</Modal.Columns>
</Modal.Content>
<Modal.Actions>
<TxButton
accountId={accountId}
icon='plus'
isDisabled={!selectedOrigin || !isImageHashValid || !isImageLenValid || !accountId || isInvalidAt || !preimage?.proposalHash}
label={t('Submit proposal')}
onStart={toggleOpen}
params={[
selectedOrigin,
{
Lookup: preimage
? { hash: preimage.proposalHash, len: imageLen }
: { hash: imageHash, len: imageLen }
},
enactIndex === 0
? { After: afterBlocks }
: { At: atBlock }
]}
tx={api.tx[palletReferenda as 'referenda'].submit}
/>
</Modal.Actions>
</StyledModal>
)}
<Button
icon='plus'
isDisabled={!isMember}
label={t('Submit proposal')}
onClick={toggleOpen}
/>
</>
);
}
const StyledModal = styled(Modal)`
.originSelect, .timeSelect {
> .ui--Params-Content {
padding-left: 0;
}
}
`;
export default React.memo(Submit);
@@ -0,0 +1,9 @@
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type React from 'react';
export interface TrackOption {
text: React.ReactNode;
value: number;
}
@@ -0,0 +1,57 @@
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ApiPromise } from '@pezkuwi/api';
import type { BN } from '@pezkuwi/util';
import type { TrackDescription } from '../../types.js';
import type { TrackOption } from './types.js';
import React, { useMemo } from 'react';
import { createNamedHook, useApi } from '@pezkuwi/react-hooks';
import { bnToBn } from '@pezkuwi/util';
import { getTrackInfo, getTrackName } from '../../util.js';
function getTrackOptions (api: ApiPromise, specName: string, palletReferenda: string, tracks: TrackDescription[], include?: (BN | number)[], exclude?: (BN | number)[]): TrackOption[] {
const includeBn = include?.map((v) => bnToBn(v));
const excludeBn = exclude?.map((v) => bnToBn(v));
return tracks
.filter(({ id }) =>
(
!includeBn ||
includeBn.some((v) => v.eq(id))
) && (
!excludeBn ||
!excludeBn.some((v) => v.eq(id))
)
)
.map(({ id, info }): TrackOption => {
const trackInfo = getTrackInfo(api, specName, palletReferenda, tracks, id.toNumber());
const trackName = getTrackName(id, info);
return {
text: trackInfo?.text
? (
<div className='trackOption'>
<div className='normal'>{trackName}</div>
<div className='faded'>{trackInfo.text}</div>
</div>
)
: trackName,
value: id.toNumber()
};
});
}
function useTrackOptionsImpl (palletReferenda: string, tracks: TrackDescription[], include?: (BN | number)[], exclude?: (BN | number)[]): TrackOption[] {
const { api, specName } = useApi();
return useMemo(
() => getTrackOptions(api, specName, palletReferenda, tracks, include, exclude),
[api, exclude, include, palletReferenda, specName, tracks]
);
}
export default createNamedHook('useTrackOptions', useTrackOptionsImpl);
@@ -0,0 +1,84 @@
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { BN } from '@pezkuwi/util';
import type { Summary as SummaryType } from '../types.js';
import React from 'react';
import { CardSummary, SummaryBox } from '@pezkuwi/react-components';
import { useApi } from '@pezkuwi/react-hooks';
import { FormatBalance } from '@pezkuwi/react-query';
import { formatNumber, isFunction } from '@pezkuwi/util';
import { useTranslation } from '../translate.js';
interface Props {
className?: string;
issuanceActive?: BN;
issuanceInactive?: BN;
issuanceTotal?: BN;
summary: SummaryType;
withIssuance?: boolean;
}
function Summary ({ className, issuanceActive, issuanceInactive, issuanceTotal, summary: { refActive, refCount }, withIssuance }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api } = useApi();
return (
<SummaryBox className={className}>
<section>
<CardSummary label={t('active')}>
{refActive === undefined
? <span className='--tmp'>99</span>
: formatNumber(refActive)
}
</CardSummary>
<CardSummary label={t('total')}>
{refCount === undefined
? <span className='--tmp'>99</span>
: formatNumber(refCount)
}
</CardSummary>
</section>
{withIssuance && (
<section>
<CardSummary label={t('total issuance')}>
<FormatBalance
className={issuanceTotal ? '' : '--tmp'}
value={issuanceTotal || 1}
withSi
/>
</CardSummary>
{isFunction(api.query.balances.inactiveIssuance) && (
<>
<CardSummary
className='media--1000'
label={t('inactive issuance')}
>
<FormatBalance
className={issuanceInactive ? '' : '--tmp'}
value={issuanceInactive || 1}
withSi
/>
</CardSummary>
<CardSummary
className='media--800'
label={t('active issuance')}
>
<FormatBalance
className={issuanceActive ? '' : '--tmp'}
value={issuanceActive || 1}
withSi
/>
</CardSummary>
</>
)}
</section>
)}
</SummaryBox>
);
}
export default React.memo(Summary);
@@ -0,0 +1,54 @@
// Copyright 2017-2025 @pezkuwi/app-democracy authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { BN } from '@pezkuwi/util';
import type { VoteTypeProps as Props } from '../types.js';
import React, { useEffect, useState } from 'react';
import { Modal, VoteValue } from '@pezkuwi/react-components';
import { useTranslation } from '../../translate.js';
function VoteAbstain ({ accountId, id, onChange }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const [balanceAbstain, setBalanceAbstain] = useState<BN | undefined>();
const [balanceAye, setBalanceAye] = useState<BN | undefined>();
const [balanceNay, setBalanceNay] = useState<BN | undefined>();
useEffect((): void => {
onChange([id, {
SplitAbstain: {
abstain: balanceAbstain,
aye: balanceAye,
nay: balanceNay
}
}]);
}, [balanceAbstain, balanceAye, balanceNay, id, onChange]);
return (
<Modal.Columns hint={t('The value of the balance that is to be split to the abstain, aye and nay parts of the vote')}>
<VoteValue
accountId={accountId}
autoFocus
label={t('abstain vote value')}
onChange={setBalanceAbstain}
/>
<VoteValue
accountId={accountId}
autoFocus
label={t('aye vote value')}
noDefault
onChange={setBalanceAye}
/>
<VoteValue
accountId={accountId}
label={t('nay vote value')}
noDefault
onChange={setBalanceNay}
/>
</Modal.Columns>
);
}
export default React.memo(VoteAbstain);
@@ -0,0 +1,45 @@
// Copyright 2017-2025 @pezkuwi/app-democracy authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { BN } from '@pezkuwi/util';
import type { VoteTypeProps as Props } from '../types.js';
import React, { useEffect, useState } from 'react';
import { Modal, VoteValue } from '@pezkuwi/react-components';
import { useTranslation } from '../../translate.js';
function VoteSplit ({ accountId, id, onChange }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const [balanceAye, setBalanceAye] = useState<BN | undefined>();
const [balanceNay, setBalanceNay] = useState<BN | undefined>();
useEffect((): void => {
onChange([id, {
Split: {
aye: balanceAye,
nay: balanceNay
}
}]);
}, [balanceAye, balanceNay, id, onChange]);
return (
<Modal.Columns hint={t('The value of the balance that is to be split to the aye and nay parts of the vote')}>
<VoteValue
accountId={accountId}
autoFocus
label={t('aye vote value')}
onChange={setBalanceAye}
/>
<VoteValue
accountId={accountId}
label={t('nay vote value')}
noDefault
onChange={setBalanceNay}
/>
</Modal.Columns>
);
}
export default React.memo(VoteSplit);
@@ -0,0 +1,64 @@
// Copyright 2017-2025 @pezkuwi/app-democracy authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { BN } from '@pezkuwi/util';
import type { VoteTypeProps } from '../types.js';
import React, { useEffect, useState } from 'react';
import { ConvictionDropdown, Modal, VoteValue } from '@pezkuwi/react-components';
import { useTranslation } from '../../translate.js';
interface Props extends VoteTypeProps {
voteLockingPeriod: BN;
}
function VoteStandard ({ accountId, id, isAye, onChange, voteLockingPeriod }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const [balance, setBalance] = useState<BN | undefined>();
const [conviction, setConviction] = useState(1);
useEffect((): void => {
onChange([id, {
Standard: {
balance,
vote: {
aye: isAye,
conviction
}
}
}]);
}, [balance, conviction, id, isAye, onChange]);
return (
<Modal.Columns
hint={
<>
<p>{t('The balance associated with the vote will be locked as per the conviction specified and will not be available for transfer during this period.')}</p>
<p>{t('Conviction locks do overlap and are not additive, meaning that funds locked during a previous vote can be locked again.')}</p>
</>
}
>
<VoteValue
accountId={accountId}
autoFocus
isReferenda={true}
label={
isAye
? t('aye vote value')
: t('nay vote value')
}
onChange={setBalance}
/>
<ConvictionDropdown
label={t('conviction')}
onChange={setConviction}
value={conviction}
voteLockingPeriod={voteLockingPeriod}
/>
</Modal.Columns>
);
}
export default React.memo(VoteStandard);
@@ -0,0 +1,231 @@
// Copyright 2017-2025 @pezkuwi/app-democracy authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ApiPromise } from '@pezkuwi/api';
import type { Preimage } from '@pezkuwi/react-hooks/types';
import type { BN } from '@pezkuwi/util';
import type { PalletVote, TrackInfo } from '../../types.js';
import React, { useMemo, useState } from 'react';
import { Button, Modal, styled, ToggleGroup, TxButton, VoteAccount } from '@pezkuwi/react-components';
import { useAccounts, useApi, useToggle } from '@pezkuwi/react-hooks';
import { ProposedAction } from '@pezkuwi/react-params';
import { useTranslation } from '../../translate.js';
import VoteAbstain from './VoteAbstain.js';
import VoteSplit from './VoteSplit.js';
import VoteStandard from './VoteStandard.js';
interface Props {
className?: string;
id: BN;
isConvictionVote: boolean;
isMember: boolean;
members?: string[];
palletVote: PalletVote;
preimage?: Preimage;
ranks?: BN[];
trackInfo?: TrackInfo;
}
function filterMembers (allAccounts: string[], members?: string[], ranks?: BN[], trackInfo?: TrackInfo): string[] | undefined {
if (members) {
const accounts = members.filter((a) => allAccounts.includes(a));
if (ranks && trackInfo?.compare) {
const cmp = trackInfo.compare;
return accounts.filter((_, i) => cmp(ranks[i]));
}
return accounts;
}
return members;
}
function createVoteOpts (api: ApiPromise, t: (key: string, options?: { replace: Record<string, unknown> }) => string): { text: string, value: string }[] {
let hasAbstain = false;
try {
hasAbstain = !!api.createType('PalletConvictionVotingVoteAccountVote', { SplitAbstain: { abstain: 1 } }).isSplitAbstain;
} catch {
hasAbstain = false;
}
return hasAbstain
? [
{ text: t('Aye'), value: 'aye' },
{ text: t('Nay'), value: 'nay' },
{ text: t('Split'), value: 'split' },
{ text: t('Abstain'), value: 'abstain' }
]
: [
{ text: t('Aye'), value: 'aye' },
{ text: t('Nay'), value: 'nay' },
{ text: t('Split'), value: 'split' }
];
}
function Voting ({ className, id, isConvictionVote, isMember, members, palletVote, preimage, ranks, trackInfo }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
const { api } = useApi();
const { allAccounts, hasAccounts } = useAccounts();
const [accountId, setAccountId] = useState<string | null>(null);
const [isOpen, toggleOpen] = useToggle();
const [voteTypeIndex, setVoteTypeIndex] = useState(0);
const [params, setParams] = useState<unknown[] | undefined>();
// Create the options for the vote toggle using the type of vote and also inspecting
// the actual support of the on-chain runtime (e.g. Abstentions)
const voteTypeOpts = useMemo(
() => createVoteOpts(api, t),
[api, t]
);
const filteredMembers = useMemo(
() => filterMembers(allAccounts, members, ranks, trackInfo),
[allAccounts, members, ranks, trackInfo]
);
const isDisabled = useMemo(
() => !isMember || (
members && filteredMembers
? !filteredMembers.length
: false
),
[filteredMembers, isMember, members]
);
if (!hasAccounts) {
return null;
}
return (
<>
{isOpen && (
<StyledModal
className={className}
header={t('Vote on referendum')}
onClose={toggleOpen}
size='large'
>
<Modal.Content>
{preimage && (
<Modal.Columns hint={t('If this proposal is passed, the changes will be applied via dispatch and the deposit returned.')}>
<ProposedAction
idNumber={id}
proposal={preimage.proposal}
/>
</Modal.Columns>
)}
<Modal.Columns hint={t('The vote will be recorded for this account. If another account delegated to this one, the delegated votes will also be counted.')}>
<VoteAccount
filter={filteredMembers}
onChange={setAccountId}
/>
</Modal.Columns>
{isConvictionVote && (
<>
<Modal.Columns
className='centerVoteType'
hint={t('The type of vote that you wish to cast on the referendum.')}
>
<ToggleGroup
onChange={setVoteTypeIndex}
options={voteTypeOpts}
value={voteTypeIndex}
/>
</Modal.Columns>
{voteTypeIndex === 0
? (
<VoteStandard
accountId={accountId}
id={id}
isAye
onChange={setParams}
voteLockingPeriod={api.consts[palletVote as 'convictionVoting'].voteLockingPeriod}
/>
)
: voteTypeIndex === 1
? (
<VoteStandard
accountId={accountId}
id={id}
onChange={setParams}
voteLockingPeriod={api.consts[palletVote as 'convictionVoting'].voteLockingPeriod}
/>
)
: voteTypeIndex === 2
? (
<VoteSplit
accountId={accountId}
id={id}
onChange={setParams}
/>
)
: (
<VoteAbstain
accountId={accountId}
id={id}
onChange={setParams}
/>
)
}
</>
)}
</Modal.Content>
<Modal.Actions>
{isConvictionVote
? (
<TxButton
accountId={accountId}
icon='check-to-slot'
label={t('Vote')}
onStart={toggleOpen}
params={params}
tx={api.tx[palletVote].vote}
/>
)
: (
<>
<TxButton
accountId={accountId}
icon='ban'
label={t('Vote Nay')}
onStart={toggleOpen}
params={[id, false]}
tx={api.tx[palletVote].vote}
/>
<TxButton
accountId={accountId}
icon='check'
label={t('Vote Aye')}
onStart={toggleOpen}
params={[id, true]}
tx={api.tx[palletVote].vote}
/>
</>
)
}
</Modal.Actions>
</StyledModal>
)}
<Button
icon='check-to-slot'
isDisabled={isDisabled}
label={t('Vote')}
onClick={toggleOpen}
/>
</>
);
}
const StyledModal = styled(Modal)`
.ui--Modal-Columns.centerVoteType > div:first-child {
text-align: center;
}
`;
export default React.memo(Voting);
@@ -0,0 +1,110 @@
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { PalletConvictionVotingTally, PalletRankedCollectiveTally, PalletRankedCollectiveVoteRecord } from '@pezkuwi/types/lookup';
import type { BN } from '@pezkuwi/util';
import type { PalletVote } from '../types.js';
import React, { useCallback, useMemo } from 'react';
import { AddressMini, Expander } from '@pezkuwi/react-components';
import { FormatBalance } from '@pezkuwi/react-query';
import { formatNumber } from '@pezkuwi/util';
import { useTranslation } from '../translate.js';
import useVotes from './useVotes.js';
interface Props {
className?: string;
id: BN;
isConvictionVote: boolean;
palletVote: PalletVote;
tally: PalletConvictionVotingTally | PalletRankedCollectiveTally;
}
function renderMini (list?: [string, BN][]): React.ReactNode[] | undefined {
return list
?.sort(([, a], [, b]) => b.cmp(a))
.map(([a]) => (
<AddressMini
key={a}
value={a}
/>
));
}
function extractVotes (votes: Record<string, PalletRankedCollectiveVoteRecord> = {}): [[string, BN][]?, [string, BN][]?] {
const ayes: [string, BN][] = [];
const nays: [string, BN][] = [];
const entries = Object.entries(votes);
for (let i = 0, count = entries.length; i < count; i++) {
const [accountId, vote] = entries[i];
if (vote.isAye) {
ayes.push([accountId, vote.asAye]);
} else {
nays.push([accountId, vote.asNay]);
}
}
return [
ayes.length ? ayes : undefined,
nays.length ? nays : undefined
];
}
function Votes ({ className = '', id, isConvictionVote, palletVote, tally }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const votes = useVotes(palletVote, id, isConvictionVote);
const [ayes, nays] = useMemo(
() => extractVotes(votes),
[votes]
);
const renderAyes = useCallback(
() => renderMini(ayes),
[ayes]
);
const renderNays = useCallback(
() => renderMini(nays),
[nays]
);
return (
<td className={`${className} expand media--1200-noPad`}>
<Expander
className='media--1200'
renderChildren={ayes && renderAyes}
summary={
isConvictionVote
? (
<>
{t('Aye')}
<div><FormatBalance value={tally.ayes} /></div>
</>
)
: t('Aye {{count}}', { replace: { count: formatNumber(tally.ayes) } })
}
/>
<Expander
className='media--1200'
renderChildren={nays && renderNays}
summary={
isConvictionVote
? (
<>
{t('Nay')}
<div><FormatBalance value={tally.nays} /></div>
</>
)
: t('Nay {{count}}', { replace: { count: formatNumber(tally.nays) } })
}
/>
</td>
);
}
export default React.memo(Votes);
@@ -0,0 +1,136 @@
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { BN } from '@pezkuwi/util';
import type { PalletReferenda, PalletVote, ReferendaGroup } from '../types.js';
import React, { useMemo, useState } from 'react';
import AddPreimage from '@pezkuwi/app-preimages/Preimages/Add';
import { Button, Dropdown, styled } from '@pezkuwi/react-components';
import { useAccounts, useApi, useCall } from '@pezkuwi/react-hooks';
import { BN_ZERO } from '@pezkuwi/util';
import { useTranslation } from '../translate.js';
import useReferenda from '../useReferenda.js';
import useSummary from '../useSummary.js';
import Delegate from './Delegate/index.js';
import Submit from './Submit/index.js';
import Group from './Group.js';
import Summary from './Summary.js';
export { useCounterNamed as useCounter } from '../useCounter.js';
interface Props {
className?: string;
isConvictionVote?: boolean;
members?: string[];
palletReferenda: PalletReferenda;
palletVote: PalletVote;
ranks?: BN[];
}
function Referenda ({ className, isConvictionVote, members, palletReferenda, palletVote, ranks }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api } = useApi();
const totalIssuance = useCall<BN | undefined>(api.query.balances.totalIssuance);
const inactiveIssuance = useCall<BN | undefined>(api.query.balances.inactiveIssuance);
const { allAccounts } = useAccounts();
const [grouped, tracks] = useReferenda(palletReferenda);
const summary = useSummary(palletReferenda, grouped);
const [trackSelected, setTrackSelected] = useState(-1);
const activeIssuance = useMemo(
() => totalIssuance?.sub(inactiveIssuance || BN_ZERO),
[inactiveIssuance, totalIssuance]
);
const trackOpts = useMemo(
() => [{ text: t('All active/available tracks'), value: -1 }].concat(
grouped
.map(({ trackId, trackName }) => ({
text: trackName,
value: trackId ? trackId.toNumber() : -1
}))
.filter((v): v is { text: string, value: number } => !!v.text)
),
[grouped, t]
);
const filtered = useMemo(
() => (
trackSelected === -1
? grouped
: grouped.filter(({ trackId }) => !!trackId && trackId.eqn(trackSelected))
) || [{ referenda: [] }],
[grouped, trackSelected]
);
const isMember = useMemo(
() => !members || allAccounts.some((a) => members.includes(a)),
[allAccounts, members]
);
return (
<StyledDiv className={className}>
<Summary
issuanceActive={activeIssuance}
issuanceInactive={inactiveIssuance}
issuanceTotal={totalIssuance}
summary={summary}
withIssuance={isConvictionVote}
/>
<Button.Group>
<Dropdown
className='topDropdown media--800'
label={t('selected track')}
onChange={setTrackSelected}
options={trackOpts}
value={trackSelected}
/>
{isConvictionVote && (
<Delegate
palletReferenda={palletReferenda}
palletVote={palletVote}
tracks={tracks}
/>
)}
<AddPreimage />
<Submit
isMember={isMember}
members={members}
palletReferenda={palletReferenda}
tracks={tracks}
/>
</Button.Group>
{filtered.map(({ key, referenda, trackId, trackName }: ReferendaGroup) => (
<Group
activeIssuance={activeIssuance}
isMember={isMember}
key={key}
members={members}
palletReferenda={palletReferenda}
palletVote={palletVote}
ranks={ranks}
referenda={referenda}
trackId={trackId}
trackName={trackName}
tracks={tracks}
/>
))}
</StyledDiv>
);
}
const StyledDiv = styled.div`
.ui--Dropdown.topDropdown {
min-width: 25rem;
padding-left: 0;
> label {
left: 1.55rem !important;
}
}
`;
export default React.memo(Referenda);
@@ -0,0 +1,11 @@
// Copyright 2017-2025 @pezkuwi/app-democracy authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { BN } from '@pezkuwi/util';
export interface VoteTypeProps {
accountId: string | null;
id: BN | number;
isAye?: boolean;
onChange: (params: unknown[]) => void;
}
@@ -0,0 +1,65 @@
// Copyright 2017-2025 @pezkuwi/app-preimages authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ApiPromise } from '@pezkuwi/api';
import type { Changes } from '@pezkuwi/react-hooks/useEventChanges';
import type { Option, StorageKey, u32 } from '@pezkuwi/types';
import type { AccountId, EventRecord } from '@pezkuwi/types/interfaces';
import type { PalletRankedCollectiveVoteRecord } from '@pezkuwi/types/lookup';
import type { BN } from '@pezkuwi/util';
import type { PalletVote } from '../types.js';
import { useMemo } from 'react';
import { createNamedHook, useApi, useCall, useEventChanges, useMapKeys } from '@pezkuwi/react-hooks';
const OPT_ACCOUNTID = {
transform: (keys: StorageKey<[u32, AccountId]>[]): AccountId[] =>
keys.map(({ args: [, accountId] }) => accountId)
};
const OPT_VOTES = {
transform: ([[params], votes]: [[[[BN, AccountId][]]], Option<PalletRankedCollectiveVoteRecord>[]]): Record<string, PalletRankedCollectiveVoteRecord> =>
params.reduce<Record<string, PalletRankedCollectiveVoteRecord>>((all, [, a], i) => {
if (votes[i] && votes[i].isSome) {
all[a.toString()] = votes[i].unwrap();
}
return all;
}, {}),
withParamsTransform: true
};
function filterEvents (records: EventRecord[], _: ApiPromise, id?: BN): Changes<AccountId> {
const added: AccountId[] = [];
records.forEach(({ event: { data: [who, pollId] } }): void => {
if (id && pollId.eq(id)) {
added.push(who as AccountId);
}
});
return { added };
}
function useVotesImpl (palletVote: PalletVote, id: BN, isConvictionVote: boolean): Record<string, PalletRankedCollectiveVoteRecord> | undefined {
const { api } = useApi();
// After v1.4.0 runtime upgrade, Relay chains i.e. Dicle and Pezkuwi, or other teyrchains chains, replaced `voting` method with `votingFor`.
// Adding a safety check here so that app doesn't break
const query = useMemo(() => api.query[palletVote].voting ?? api.query[palletVote].votingFor, [api.query, palletVote]);
const startAccounts = useMapKeys(isConvictionVote === false && query, [id], OPT_ACCOUNTID);
const allAccounts = useEventChanges([
api.events[palletVote].Voted
], filterEvents, startAccounts, id);
const params = useMemo(
() => allAccounts?.map((a) => [id, a]),
[allAccounts, id]
);
return useCall(params && query?.multi, [params], OPT_VOTES);
}
export default createNamedHook('useVotes', useVotesImpl);
@@ -0,0 +1,24 @@
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { PalletReferendaDeposit } from '@pezkuwi/types/lookup';
import type { Referendum } from '../types.js';
import { Option } from '@pezkuwi/types';
export function unwrapDeposit (value: PalletReferendaDeposit | Option<PalletReferendaDeposit>): PalletReferendaDeposit | null {
return value instanceof Option
? value.unwrapOr(null)
: value;
}
export function getNumDeciding (referenda?: Referendum[]): number {
if (!referenda) {
return 0;
}
return referenda.filter(({ info }) =>
info.isOngoing &&
info.asOngoing.deciding.isSome
).length;
}
+44
View File
@@ -0,0 +1,44 @@
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React, { useRef } from 'react';
import { Tabs } from '@pezkuwi/react-components';
import Referenda from './Referenda/index.js';
import { useTranslation } from './translate.js';
export { default as useCounter } from './useCounter.js';
interface Props {
basePath: string;
className?: string;
}
function App ({ basePath, className }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const tabsRef = useRef([
{
isRoot: true,
name: 'overview',
text: t('Overview')
}
]);
return (
<main className={className}>
<Tabs
basePath={basePath}
items={tabsRef.current}
/>
<Referenda
isConvictionVote
palletReferenda='referenda'
palletVote='convictionVoting'
/>
</main>
);
}
export default React.memo(App);
+8
View File
@@ -0,0 +1,8 @@
// Copyright 2017-2025 @pezkuwi/app-referenda 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-referenda');
}
+83
View File
@@ -0,0 +1,83 @@
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { PalletReferendaReferendumInfoConvictionVotingTally, PalletReferendaReferendumInfoRankedCollectiveTally, PalletReferendaTrackDetails } from '@pezkuwi/types/lookup';
import type { BN } from '@pezkuwi/util';
export type PalletReferenda = 'referenda' | 'rankedPolls' | 'fellowshipReferenda'| 'ambassadorReferenda';
export type PalletVote = 'convictionVoting' | 'rankedCollective' | 'fellowshipCollective' | 'ambassadorCollective';
export interface ReferendaGroup {
key: string;
track?: PalletReferendaTrackDetails;
trackGraph?: CurveGraph;
trackId?: BN;
trackName?: string;
referenda?: Referendum[];
}
export interface ReferendaGroupKnown extends ReferendaGroup {
referenda: Referendum[];
}
export interface Referendum {
decidingEnd?: BN;
id: BN;
info: PalletReferendaReferendumInfoConvictionVotingTally | PalletReferendaReferendumInfoRankedCollectiveTally;
isConvictionVote: boolean;
key: string;
track?: PalletReferendaTrackDetails;
trackId?: BN;
trackGraph?: CurveGraph;
}
export interface ReferendumProps {
className?: string;
activeIssuance?: BN;
isMember: boolean;
members?: string[];
onExpand?: () => void;
palletReferenda: PalletReferenda;
palletVote: PalletVote;
ranks?: BN[];
trackInfo?: TrackInfo;
value: Referendum;
}
export interface Summary {
deciding?: BN;
refActive?: number;
refCount?: BN;
}
export interface CurveGraph {
approval: BN[];
support: BN[];
x: BN[];
}
export interface TrackDescription {
graph: CurveGraph;
id: BN;
info: PalletReferendaTrackDetails;
}
export interface TrackInfo {
compare?: (input: BN) => boolean;
origin: Record<string, string> | Record<string, string>[];
text?: string;
}
export interface TrackInfoExt extends TrackInfo {
track: TrackDescription;
trackName: string;
}
export interface Lock {
classId: BN;
endBlock: BN;
locked: string;
refId: BN;
total: BN;
}
@@ -0,0 +1,177 @@
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ApiPromise } from '@pezkuwi/api';
import type { Option } from '@pezkuwi/types';
import type { PalletConvictionVotingVoteCasting, PalletConvictionVotingVoteVoting, PalletReferendaReferendumInfoConvictionVotingTally } from '@pezkuwi/types/lookup';
import type { BN } from '@pezkuwi/util';
import type { Lock, PalletReferenda, PalletVote } from './types.js';
import { useMemo } from 'react';
import { CONVICTIONS } from '@pezkuwi/react-components/ConvictionDropdown';
import { createNamedHook, useApi, useCall } from '@pezkuwi/react-hooks';
import { BN_MAX_INTEGER } from '@pezkuwi/util';
const OPT_CLASS = {
transform: (locks: [BN, BN][]): BN[] =>
locks.map(([classId]) => classId)
};
const OPT_VOTES = {
transform: ([[params], votes]: [[[string, BN][]], PalletConvictionVotingVoteVoting[]]): [classId: BN, refIds: BN[], casting: PalletConvictionVotingVoteCasting][] =>
votes
.map((v, index): null | [BN, BN[], PalletConvictionVotingVoteCasting] => {
if (!v.isCasting) {
return null;
}
const casting = v.asCasting;
return [
params[index][1],
casting.votes.map(([refId]) => refId),
casting
];
})
.filter((v): v is [BN, BN[], PalletConvictionVotingVoteCasting] => !!v),
withParamsTransform: true
};
const OPT_REFS = {
transform: ([[params], optTally]: [[BN[]], Option<PalletReferendaReferendumInfoConvictionVotingTally>[]]): [BN, PalletReferendaReferendumInfoConvictionVotingTally][] =>
optTally
.map((v, index): null | [BN, PalletReferendaReferendumInfoConvictionVotingTally] =>
v.isSome
? [params[index], v.unwrap()]
: null
)
.filter((v): v is [BN, PalletReferendaReferendumInfoConvictionVotingTally] => !!v),
withParamsTransform: true
};
function getVoteParams (accountId: string, lockClasses?: BN[]): [[accountId: string, classId: BN][]] | undefined {
if (lockClasses) {
return [lockClasses.map((classId) => [accountId, classId])];
}
return undefined;
}
function getRefParams (votes?: [classId: BN, refIds: BN[], casting: PalletConvictionVotingVoteCasting][]): [BN[]] | undefined {
if (votes?.length) {
const refIds = votes.reduce<BN[]>((all, [, refIds]) => all.concat(refIds), []);
if (refIds.length) {
return [refIds];
}
}
return undefined;
}
function getLocks (api: ApiPromise, palletVote: PalletVote, votes: [classId: BN, refIds: BN[], casting: PalletConvictionVotingVoteCasting][], referenda: [BN, PalletReferendaReferendumInfoConvictionVotingTally][]): Lock[] {
const lockPeriod = api.consts[palletVote].voteLockingPeriod as BN;
const locks: Lock[] = [];
for (let i = 0, voteCount = votes.length; i < voteCount; i++) {
const [classId,, casting] = votes[i];
for (let i = 0, castCount = casting.votes.length; i < castCount; i++) {
const [refId, accountVote] = casting.votes[i];
const refInfo = referenda.find(([id]) => id.eq(refId));
if (refInfo) {
const [, tally] = refInfo;
let total: BN | undefined;
let endBlock: BN| undefined;
let convictionIndex = 0;
let locked = 'None';
const durationIndex = 1;
if (accountVote.isStandard) {
const { balance, vote } = accountVote.asStandard;
total = balance;
if ((tally.isApproved && vote.isAye) || (tally.isRejected && vote.isNay)) {
convictionIndex = vote.conviction.index;
locked = vote.conviction.type;
}
} else if (accountVote.isSplit) {
const { aye, nay } = accountVote.asSplit;
total = aye.add(nay);
} else if (accountVote.isSplitAbstain) {
const { abstain, aye, nay } = accountVote.asSplitAbstain;
total = aye.add(nay).add(abstain);
} else {
console.error(`Unable to handle ${accountVote.type}`);
}
if (tally.isOngoing) {
endBlock = BN_MAX_INTEGER;
} else if (tally.isKilled) {
endBlock = tally.asKilled;
} else if (tally.isCancelled || tally.isTimedOut) {
endBlock = tally.isCancelled
? tally.asCancelled[0]
: tally.asTimedOut[0];
} else if (tally.isApproved || tally.isRejected) {
endBlock = lockPeriod
.muln(convictionIndex ? CONVICTIONS[convictionIndex - 1][durationIndex] : 0)
.add(
tally.isApproved
? tally.asApproved[0]
: tally.asRejected[0]
);
} else {
console.error(`Unable to handle ${tally.type}`);
}
if (total && endBlock) {
locks.push({ classId, endBlock, locked, refId, total });
}
}
}
}
return locks;
}
function useAccountLocksImpl (palletReferenda: PalletReferenda, palletVote: PalletVote, accountId: string): Lock[] | undefined {
const { api } = useApi();
// retrieve the locks for the account (all classes) via the accountId
const lockParams = useMemo(
() => [accountId],
[accountId]
);
const lockClasses = useCall<BN[] | undefined>(lockParams && api.query[palletVote]?.classLocksFor, lockParams, OPT_CLASS);
// retrieve the specific votes casted over the classes & accountId
const voteParams = useMemo(
() => getVoteParams(accountId, lockClasses),
[accountId, lockClasses]
);
const votes = useCall<[BN, BN[], PalletConvictionVotingVoteCasting][] | undefined>(voteParams && api.query[palletVote]?.votingFor.multi, voteParams, OPT_VOTES);
// retrieve the referendums that were voted on
const refParams = useMemo(
() => getRefParams(votes),
[votes]
);
const referenda = useCall(refParams && api.query[palletReferenda]?.referendumInfoFor.multi, refParams, OPT_REFS);
// combine the referenda outcomes and the votes into locks
return useMemo(
() => votes && referenda && getLocks(api, palletVote, votes, referenda),
[api, palletVote, referenda, votes]
);
}
export default createNamedHook('useAccountLocks', useAccountLocksImpl);
+32
View File
@@ -0,0 +1,32 @@
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { PalletReferenda } from './types.js';
import { useMemo } from 'react';
import { createNamedHook } from '@pezkuwi/react-hooks';
import useReferenda from './useReferenda.js';
export function useCounterNamed (palletReferenda: PalletReferenda): number {
const [grouped] = useReferenda(palletReferenda);
return useMemo(
() => grouped
? grouped.reduce((total, { referenda }) =>
total + (
referenda
? referenda.filter(({ info }) => info.isOngoing).length
: 0
), 0)
: 0,
[grouped]
);
}
function useCounterImpl (): number {
return useCounterNamed('referenda');
}
export default createNamedHook('useCounter', useCounterImpl);
+164
View File
@@ -0,0 +1,164 @@
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { Option } from '@pezkuwi/types';
import type { BN } from '@pezkuwi/util';
import type { PalletReferenda, ReferendaGroup, ReferendaGroupKnown, Referendum, TrackDescription } from './types.js';
import { useMemo } from 'react';
import { createNamedHook, useApi, useCall } from '@pezkuwi/react-hooks';
import useReferendaIds from './useReferendaIds.js';
import useTracks from './useTracks.js';
import { calcDecidingEnd, getTrackName, isConvictionVote } from './util.js';
function sortOngoing (a: Referendum, b: Referendum): number {
const ao = a.info.asOngoing;
const bo = b.info.asOngoing;
return ao.track.cmp(bo.track) || (
ao.deciding.isSome === bo.deciding.isSome
? ao.deciding.isSome
? a.info.asOngoing.deciding.unwrap().since.cmp(
b.info.asOngoing.deciding.unwrap().since
)
: 0
: ao.deciding.isSome
? -1
: 1
);
}
function sortReferenda (a: Referendum, b: Referendum): number {
return (
a.info.isOngoing === b.info.isOngoing
? a.info.isOngoing
? sortOngoing(a, b)
: 0
: a.info.isOngoing
? -1
: 1
) || b.id.cmp(a.id);
}
function sortGroups (a: ReferendaGroupKnown, b: ReferendaGroupKnown): number {
return a.trackId && b.trackId
? a.trackId.cmp(b.trackId)
: a.trackId
? -1
: 1;
}
const OPT_MULTI = {
transform: ([[ids], all]: [[BN[]], Option<Referendum['info']>[]]) =>
ids.map((id, i) => {
const infoOpt = all[i];
const info = infoOpt?.isSome ? infoOpt.unwrap() : undefined;
return {
id,
info,
isConvictionVote: info ? isConvictionVote(info) : false,
key: id.toString()
};
}).filter((r) => r.info !== undefined),
withParamsTransform: true
};
function group (tracks: TrackDescription[], totalIssuance?: BN, referenda?: Referendum[]): ReferendaGroup[] {
if (!referenda || !totalIssuance) {
// return an empty group when we have no referenda
return [{ key: 'empty' }];
} else if (!tracks) {
// if we have no tracks, we just return the referenda sorted
return [{ key: 'referenda', referenda: referenda.sort(sortReferenda) }];
}
const grouped: ReferendaGroupKnown[] = [];
const other: ReferendaGroupKnown = { key: 'referenda', referenda: [] };
// sort the referenda by track inside groups
for (let i = 0, count = referenda.length; i < count; i++) {
const ref = referenda[i];
if (!ref.info || !ref.info.isOngoing) {
// info is undefined or not ongoing — can't get track
other.referenda.push(ref);
continue;
}
const trackInfo = tracks.find(({ id }) =>
id?.eq && id.eq(ref.info.asOngoing.track)
);
if (trackInfo) {
ref.trackGraph = trackInfo.graph;
ref.trackId = trackInfo.id;
ref.track = trackInfo.info;
if (ref.isConvictionVote) {
const { deciding, tally } = ref.info.asOngoing;
if (deciding.isSome) {
const { since } = deciding.unwrap();
ref.decidingEnd = calcDecidingEnd(totalIssuance, tally, trackInfo.info, since);
}
}
const group = grouped.find(({ track }) => ref.track === track);
if (!group) {
// we don't have a group as of yet, create one
grouped.push({
key: `track:${ref.trackId.toString()}`,
referenda: [ref],
track: ref.track,
trackGraph: ref.trackGraph,
trackId: ref.trackId,
trackName: getTrackName(ref.trackId, ref.track)
});
} else {
// existing group, just add the referendum
group.referenda.push(ref);
}
} else {
// if we have no track, we just add it to "other"
other.referenda.push(ref);
}
}
// if we do have items in "other", we add it (or if none, then empty other)
if ((other.referenda && other.referenda.length !== 0) || !grouped.length) {
grouped.push(other);
}
// sort referenda per group
for (let i = 0, count = grouped.length; i < count; i++) {
grouped[i].referenda.sort(sortReferenda);
}
// sort all groups
return grouped.sort(sortGroups);
}
function useReferendaImpl (palletReferenda: PalletReferenda): [ReferendaGroup[], TrackDescription[]] {
const { api, isApiReady } = useApi();
const totalIssuance = useCall<BN>(isApiReady && api.query.balances.totalIssuance);
const ids = useReferendaIds(palletReferenda);
const tracks = useTracks(palletReferenda);
const referenda = useCall(isApiReady && ids && ids.length !== 0 && api.query[palletReferenda as 'referenda'].referendumInfoFor.multi, [ids], OPT_MULTI);
return useMemo(
() => [
(ids && ids.length === 0)
? [{ key: 'referenda', referenda: [] }]
: group(tracks, totalIssuance, (referenda as Referendum[])),
tracks
],
[ids, referenda, totalIssuance, tracks]
);
}
export default createNamedHook('useReferenda', useReferendaImpl);
@@ -0,0 +1,41 @@
// Copyright 2017-2025 @pezkuwi/app-referenda 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 type { BN } from '@pezkuwi/util';
import type { PalletReferenda } from './types.js';
import { createNamedHook, useApi, useEventChanges, useMapKeys } from '@pezkuwi/react-hooks';
const OPT_ID = {
transform: (keys: StorageKey<[u32]>[]): u32[] =>
keys.map(({ args: [id] }) => id)
};
function filter (records: EventRecord[]): Changes<u32> {
const added: u32[] = [];
const removed: u32[] = [];
records.forEach(({ event: { data: [id], method } }): void => {
if (method === 'Submitted') {
added.push(id as u32);
} else {
removed.push(id as u32);
}
});
return { added, removed };
}
function useReferendaIdsImpl (palletReferenda: PalletReferenda): BN[] | undefined {
const { api } = useApi();
const startValue = useMapKeys(api.query[palletReferenda].referendumInfoFor, [], OPT_ID);
return useEventChanges([
api.events[palletReferenda].Submitted
], filter, startValue);
}
export default createNamedHook('useReferendaIds', useReferendaIdsImpl);
+33
View File
@@ -0,0 +1,33 @@
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { u32 } from '@pezkuwi/types';
import type { PalletReferenda, ReferendaGroup, Summary } from './types.js';
import { useMemo } from 'react';
import { createNamedHook, useApi, useCall } from '@pezkuwi/react-hooks';
function calcActive (grouped: ReferendaGroup[] = []): number {
return grouped.reduce((total, { referenda = [] }) =>
total + referenda.filter((r) =>
r.info.isOngoing
).length,
0);
}
function useSummaryImpl (palletReferenda: PalletReferenda, grouped?: ReferendaGroup[] | undefined): Summary {
const { api } = useApi();
const refCount = useCall<u32>(api.query[palletReferenda].referendumCount);
const refActive = useMemo(
() => calcActive(grouped),
[grouped]
);
return useMemo(
() => ({ refActive, refCount }),
[refActive, refCount]
);
}
export default createNamedHook('useSummary', useSummaryImpl);
+34
View File
@@ -0,0 +1,34 @@
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { PalletReferendaTrackDetails } from '@pezkuwi/types/lookup';
import type { BN } from '@pezkuwi/util';
import type { PalletReferenda, TrackDescription } from './types.js';
import { useMemo } from 'react';
import { createNamedHook, useApi } from '@pezkuwi/react-hooks';
import { BN_ZERO } from '@pezkuwi/util';
import { calcCurves } from './util.js';
const zeroGraph = { approval: [BN_ZERO], support: [BN_ZERO], x: [BN_ZERO] };
function expandTracks (tracks: [BN, PalletReferendaTrackDetails][]): TrackDescription[] {
return tracks.map(([id, info]) => ({
graph: info.decisionDeposit && info.minApproval && info.minSupport ? calcCurves(info) : zeroGraph,
id,
info
}));
}
function useTracksImpl (palletReferenda: PalletReferenda): TrackDescription[] {
const { api } = useApi();
return useMemo(
() => expandTracks(api.consts[palletReferenda as 'referenda'].tracks),
[api, palletReferenda]
);
}
export default createNamedHook('useTracks', useTracksImpl);
+131
View File
@@ -0,0 +1,131 @@
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
// SPDX-License-Identifier: Apache-2.0
/// <reference types="@pezkuwi/dev-test/globals.d.ts" />
import type { PalletReferendaCurve } from '@pezkuwi/types/lookup';
import type { BN } from '@pezkuwi/util';
import { BN_BILLION, BN_ZERO, bnToBn } from '@pezkuwi/util';
import { curveDelay, curveThreshold } from './util.js';
function curveLinear (ceil: BN | string | number, floor: BN | string | number, length: BN | string | number): PalletReferendaCurve {
return {
asLinearDecreasing: {
ceil: bnToBn(ceil),
floor: bnToBn(floor),
length: bnToBn(length)
},
isLinearDecreasing: true,
isReciprocal: false,
isSteppedDecreasing: false
} as PalletReferendaCurve;
}
function curveReciprocal (factor: BN | string | number, xOffset: BN | string | number, yOffset: BN | string | number): PalletReferendaCurve {
return {
asReciprocal: {
factor: bnToBn(factor),
xOffset: bnToBn(xOffset),
yOffset: bnToBn(yOffset)
},
isLinearDecreasing: false,
isReciprocal: true,
isSteppedDecreasing: false
} as PalletReferendaCurve;
}
// We don't currently have curves on Dicle for this, so needs a check
// function curveStepped (begin: BN | string | number, end: BN | string | number, period: BN | string | number, step: BN | string | number): PalletReferendaCurve {
// return {
// asSteppedDecreasing: {
// begin: bnToBn(begin),
// end: bnToBn(end),
// period: bnToBn(period),
// step: bnToBn(step)
// },
// isLinearDecreasing: false,
// isReciprocal: false,
// isSteppedDecreasing: true
// } as PalletReferendaCurve;
// }
function percentValue (percent: number): BN {
return BN_BILLION.muln(percent).divn(100);
}
describe('curveThreshold', (): void => {
describe('LinearDecreasing', (): void => {
// linear support curve from root track on Dicle
const rootCurve = curveLinear(BN_BILLION.divn(2), BN_ZERO, BN_BILLION);
it('has a correct starting point', (): void => {
expect(
curveThreshold(rootCurve, bnToBn(0), bnToBn(672)).toString()
).toEqual(percentValue(50).toString());
});
it('has the correct midpoint', (): void => {
expect(
curveThreshold(rootCurve, bnToBn(672 / 2), bnToBn(672)).toString()
).toEqual(percentValue(25).toString());
});
it('has a correct ending point', (): void => {
expect(
curveThreshold(rootCurve, bnToBn(672), bnToBn(672)).toString()
).toEqual(percentValue(0).toString());
});
});
describe('Reciprocal', (): void => {
// linear approval curve from root track on Dicle
const rootCurve = curveReciprocal(222_222_224, 333_333_335, 333_333_332);
it('has a correct starting point', (): void => {
expect(
curveThreshold(rootCurve, bnToBn(0), bnToBn(672)).toString()
).toEqual(percentValue(100).toString());
});
it('has the correct midpoints', (): void => {
expect(
curveThreshold(rootCurve, bnToBn(12), bnToBn(672)).toString()
).toEqual('966101697'); // 96.61%
expect(
curveThreshold(rootCurve, bnToBn(336), bnToBn(672)).toString()
).toEqual(percentValue(60).toString());
});
it('has a correct ending point', (): void => {
expect(
curveThreshold(rootCurve, bnToBn(672), bnToBn(672)).toString()
).toEqual('499999999');
});
});
});
describe('curveDelay', (): void => {
describe('LinearDecreasing', (): void => {
// linear support curve from root track on Dicle
const rootCurve = curveLinear(BN_BILLION.divn(2), BN_ZERO, BN_BILLION);
it('has the correct result for 25%', (): void => {
expect(
curveDelay(rootCurve, bnToBn(25), bnToBn(100)).toString()
).toEqual('500000000'); // 50% through
});
});
describe('Reciprocal', (): void => {
// linear approval curve from root track on Dicle
const rootCurve = curveReciprocal(222_222_224, 333_333_335, 333_333_332);
it('has the correct result for 75%', (): void => {
expect(
curveDelay(rootCurve, bnToBn(75), bnToBn(100)).toString()
).toEqual('200000000'); // 20% through
});
});
});
+257
View File
@@ -0,0 +1,257 @@
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ApiPromise } from '@pezkuwi/api';
import type { PalletConvictionVotingTally, PalletRankedCollectiveTally, PalletReferendaCurve, PalletReferendaReferendumInfoConvictionVotingTally, PalletReferendaReferendumInfoRankedCollectiveTally, PalletReferendaTrackDetails } from '@pezkuwi/types/lookup';
import type { CurveGraph, TrackDescription, TrackInfoExt } from './types.js';
import { getGovernanceTracks } from '@pezkuwi/apps-config';
import { BN, BN_BILLION, BN_ONE, BN_ZERO, bnMax, bnMin, formatNumber, objectSpread, stringPascalCase } from '@pezkuwi/util';
const CURVE_LENGTH = 500;
export function getTrackName (trackId: BN, { name }: PalletReferendaTrackDetails): string {
return `${
formatNumber(trackId)
} / ${
name
.replace(/_/g, ' ')
.split(' ')
.map(stringPascalCase)
.join(' ')
}`;
}
export function getTrackInfo (api: ApiPromise, specName: string, palletReferenda: string, tracks: TrackDescription[], trackId?: number): TrackInfoExt | undefined {
let info: TrackInfoExt | undefined;
if (tracks && trackId !== undefined && trackId !== -1) {
const originMap = getGovernanceTracks(api, specName, palletReferenda);
const track = tracks.find(({ id }) => id.eqn(trackId));
if (track && originMap) {
const trackName = track.info.name.toString();
const base = originMap.find(({ id, name }) =>
id === trackId &&
name === trackName
);
if (base) {
info = objectSpread<TrackInfoExt>({
track,
trackName: getTrackName(track.id, track.info)
}, base);
}
}
}
return info;
}
export function isConvictionTally (tally: PalletRankedCollectiveTally | PalletConvictionVotingTally): tally is PalletConvictionVotingTally {
return !!(tally as PalletConvictionVotingTally).support && !(tally as PalletRankedCollectiveTally).bareAyes;
}
export function isConvictionVote (info: PalletReferendaReferendumInfoConvictionVotingTally | PalletReferendaReferendumInfoRankedCollectiveTally): info is PalletReferendaReferendumInfoConvictionVotingTally {
return info.isOngoing && isConvictionTally(info.asOngoing.tally);
}
export function curveThreshold (curve: PalletReferendaCurve, input: BN, div: BN): BN {
// if divisor is zero, we return the max
if (div.isZero()) {
return BN_BILLION;
}
const x = input.mul(BN_BILLION).div(div);
if (curve.isLinearDecreasing) {
const { ceil, floor, length } = curve.asLinearDecreasing;
// if divisor is zero, we return the max
if (length.isZero()) {
return BN_BILLION;
}
// *ceil - (x.min(*length).saturating_div(*length, Down) * (*ceil - *floor))
// NOTE: We first multiply, then divide (since we work with fractions)
return ceil.sub(
bnMin(x, length)
.mul(ceil.sub(floor))
.div(length)
);
} else if (curve.isSteppedDecreasing) {
const { begin, end, period, step } = curve.asSteppedDecreasing;
// (*begin - (step.int_mul(x.int_div(*period))).min(*begin)).max(*end)
return bnMax(
end,
begin.sub(
bnMin(
begin,
step
.mul(x)
.div(period)
)
)
);
} else if (curve.isReciprocal) {
const { factor, xOffset, yOffset } = curve.asReciprocal;
const div = x.add(xOffset);
if (div.isZero()) {
return BN_BILLION;
}
// factor
// .checked_rounding_div(FixedI64::from(x) + *x_offset, Low)
// .map(|yp| (yp + *y_offset).into_clamped_perthing())
// .unwrap_or_else(Perbill::one)
return bnMin(
BN_BILLION,
factor
.mul(BN_BILLION)
.div(div)
.add(yOffset)
);
}
throw new Error(`Unknown curve found ${curve.type}`);
}
export function curveDelay (curve: PalletReferendaCurve, input: BN, div: BN): BN {
try {
// if divisor is zero, we return the max
if (div.isZero()) {
return BN_BILLION;
}
const y = input.mul(BN_BILLION).div(div);
if (curve.isLinearDecreasing) {
const { ceil, floor, length } = curve.asLinearDecreasing;
// if y < *floor {
// Perbill::one()
// } else if y > *ceil {
// Perbill::zero()
// } else {
// (*ceil - y).saturating_div(*ceil - *floor, Up).saturating_mul(*length)
// }
return y.lt(floor)
? BN_BILLION
: y.gt(ceil)
? BN_ZERO
: bnMin(
BN_BILLION,
bnMax(
BN_ZERO,
ceil
.sub(y)
.mul(length)
.div(ceil.sub(floor))
)
);
} else if (curve.isSteppedDecreasing) {
const { begin, end, period, step } = curve.asSteppedDecreasing;
// if y < *end {
// Perbill::one()
// } else {
// period.int_mul((*begin - y.min(*begin) + step.less_epsilon()).int_div(*step))
// }
return y.lt(end)
? BN_BILLION
: bnMin(
BN_BILLION,
bnMax(
BN_ZERO,
period
.mul(
begin
.sub(bnMin(y, begin))
.add(
step.isZero()
? step
: step.sub(BN_ONE)
)
)
.div(step)
)
);
} else if (curve.asReciprocal) {
const { factor, xOffset, yOffset } = curve.asReciprocal;
const div = y.sub(yOffset);
if (div.isZero()) {
return BN_BILLION;
}
// let y = FixedI64::from(y);
// let maybe_term = factor.checked_rounding_div(y - *y_offset, High);
// maybe_term
// .and_then(|term| (term - *x_offset).try_into_perthing().ok())
// .unwrap_or_else(Perbill::one)
return bnMin(
BN_BILLION,
bnMax(
BN_ZERO,
factor
.mul(BN_BILLION)
.div(div)
.sub(xOffset)
)
);
}
} catch (error) {
console.error(`Failed on curve ${curve.type}:`, curve.inner.toHuman());
throw error;
}
throw new Error(`Unknown curve found ${curve.type}`);
}
export function calcDecidingEnd (totalEligible: BN, tally: PalletRankedCollectiveTally | PalletConvictionVotingTally, { decisionPeriod, minApproval, minSupport }: PalletReferendaTrackDetails, since: BN): BN | undefined {
const support = isConvictionTally(tally)
? tally.support
: tally.bareAyes;
return since.add(
decisionPeriod
.mul(
bnMax(
curveDelay(minApproval, tally.ayes, tally.ayes.add(tally.nays)),
curveDelay(minSupport, support, totalEligible)
)
)
.div(BN_BILLION)
);
}
export function calcCurves ({ decisionPeriod, minApproval, minSupport }: PalletReferendaTrackDetails): CurveGraph {
const approval = new Array<BN>(CURVE_LENGTH);
const support = new Array<BN>(CURVE_LENGTH);
const x = new Array<BN>(CURVE_LENGTH);
const last = CURVE_LENGTH - 1;
// Bringing it to a higher precision before dividing by curve length.
// Otherwise, graphs with short periods (on dev chains) are invalid.
const stepWithPrecision = decisionPeriod.muln(100).divn(CURVE_LENGTH);
let currentWithPrecision = new BN(0);
for (let i = 0; i < last; i++) {
const current = currentWithPrecision.divn(100);
approval[i] = curveThreshold(minApproval, current, decisionPeriod);
support[i] = curveThreshold(minSupport, current, decisionPeriod);
x[i] = current;
currentWithPrecision = currentWithPrecision.add(stepWithPrecision);
}
// since we may be lossy with the step, we explicitly calc the final point at 100%
approval[last] = curveThreshold(minApproval, decisionPeriod, decisionPeriod);
support[last] = curveThreshold(minSupport, decisionPeriod, decisionPeriod);
x[last] = decisionPeriod;
return { approval, support, x };
}