Gov2 lock calcs (per account) (#8537)

* Gov2 lock calcs (per account)

* Lock calcs

* Individual info

* Countdown display

* Adjust

* With conviction

* Optional modules

* Formatting adjustments

* Add abstain to total value

* WithAsbstain override type

* Linting

* Adjust

* Clear expired locks

* Check ids length
This commit is contained in:
Jaco 2022-12-16 13:30:07 +02:00 committed by GitHub
parent d1a4583dbe
commit b7a0ee7900
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 315 additions and 20 deletions

View File

@ -15,6 +15,7 @@ import React, { useCallback, useContext, useEffect, useMemo, useState } from 're
import styled, { ThemeContext } from 'styled-components';
import { ApiPromise } from '@polkadot/api';
import useAccountLocks from '@polkadot/app-referenda/useAccountLocks';
import { AddressInfo, AddressSmall, Badge, Button, ChainLock, CryptoType, ExpandButton, Forget, Icon, LinkExternal, Menu, Popup, StatusContext, Tags } from '@polkadot/react-components';
import { useAccountInfo, useApi, useBalancesAll, useBestNumber, useCall, useLedger, useStakingInfo, useToggle } from '@polkadot/react-hooks';
import { keyring } from '@polkadot/ui-keyring';
@ -53,6 +54,11 @@ interface DemocracyUnlockable {
ids: BN[];
}
interface ReferendaUnlockable {
referendaUnlockTx: SubmittableExtrinsic<'promise'> | null;
ids: [classId: BN, refId: BN][];
}
const BAL_OPTS_DEFAULT = {
available: false,
bonded: false,
@ -100,16 +106,38 @@ function calcUnbonding (stakingInfo?: DeriveStakingAccount) {
return total;
}
function createClearDemocracyTx (api: ApiPromise, address: string, unlockableIds: BN[]): SubmittableExtrinsic<'promise'> | null {
return api.tx.utility
function createClearDemocracyTx (api: ApiPromise, address: string, ids: BN[]): SubmittableExtrinsic<'promise'> | null {
return api.tx.utility && ids.length
? api.tx.utility.batch(
unlockableIds
ids
.map((id) => api.tx.democracy.removeVote(id))
.concat(api.tx.democracy.unlock(address))
)
: null;
}
function createClearReferendaTx (api: ApiPromise, address: string, ids: [BN, BN][], palletReferenda = 'convictionVoting'): SubmittableExtrinsic<'promise'> | null {
if (!api.tx.utility || !ids.length) {
return null;
}
const inner = ids.map(([classId, refId]) => api.tx[palletReferenda].removeVote(classId, refId));
ids
.reduce((all: BN[], [classId]) => {
if (!all.find((id) => id.eq(classId))) {
all.push(classId);
}
return all;
}, [])
.forEach((classId): void => {
inner.push(api.tx[palletReferenda].unlock(classId, address));
});
return api.tx.utility.batch(inner);
}
async function showLedgerAddress (getLedger: () => Ledger, meta: KeyringJson$Meta): Promise<void> {
const ledger = getLedger();
@ -135,7 +163,9 @@ function Account ({ account: { address, meta }, className = '', delegation, filt
const multiInfos = useMultisigApprovals(address);
const proxyInfo = useProxies(address);
const { flags: { isDevelopment, isEditable, isEthereum, isExternal, isHardware, isInjected, isMultisig, isProxied }, genesisHash, identity, name: accName, onSetGenesisHash, tags } = useAccountInfo(address);
const [{ democracyUnlockTx }, setUnlockableIds] = useState<DemocracyUnlockable>({ democracyUnlockTx: null, ids: [] });
const convictionLocks = useAccountLocks('referenda', 'convictionVoting', address);
const [{ democracyUnlockTx }, setDemocracyUnlock] = useState<DemocracyUnlockable>({ democracyUnlockTx: null, ids: [] });
const [{ referendaUnlockTx }, setReferandaUnlock] = useState<ReferendaUnlockable>({ ids: [], referendaUnlockTx: null });
const [vestingVestTx, setVestingTx] = useState<SubmittableExtrinsic<'promise'> | null>(null);
const [isBackupOpen, toggleBackup] = useToggle();
const [isDeriveOpen, toggleDerive] = useToggle();
@ -172,7 +202,7 @@ function Account ({ account: { address, meta }, className = '', delegation, filt
}, [address, api, balancesAll, setBalance, stakingInfo]);
useEffect((): void => {
bestNumber && democracyLocks && setUnlockableIds(
bestNumber && democracyLocks && setDemocracyUnlock(
(prev): DemocracyUnlockable => {
const ids = democracyLocks
.filter(({ isFinished, unlockAt }) => isFinished && bestNumber.gt(unlockAt))
@ -190,6 +220,25 @@ function Account ({ account: { address, meta }, className = '', delegation, filt
);
}, [address, api, bestNumber, democracyLocks]);
useEffect((): void => {
bestNumber && convictionLocks && setReferandaUnlock(
(prev): ReferendaUnlockable => {
const ids = convictionLocks
.filter(({ endBlock }) => endBlock.gt(BN_ZERO) && bestNumber.gt(endBlock))
.map(({ classId, refId }): [classId: BN, refId: BN] => [classId, refId]);
if (JSON.stringify(prev.ids) === JSON.stringify(ids)) {
return prev;
}
return {
ids,
referendaUnlockTx: createClearReferendaTx(api.api, address, ids)
};
}
);
}, [address, api, bestNumber, convictionLocks]);
const isVisible = useMemo(
() => calcVisible(filter, accName, tags),
[accName, filter, tags]
@ -231,6 +280,14 @@ function Account ({ account: { address, meta }, className = '', delegation, filt
[address, democracyUnlockTx, queueExtrinsic]
);
const _clearReferendaLocks = useCallback(
() => referendaUnlockTx && queueExtrinsic({
accountId: address,
extrinsic: referendaUnlockTx
}),
[address, referendaUnlockTx, queueExtrinsic]
);
const _vestingVest = useCallback(
() => vestingVestTx && queueExtrinsic({
accountId: address,
@ -276,6 +333,14 @@ function Account ({ account: { address, meta }, className = '', delegation, filt
onClick={_clearDemocracyLocks}
/>
),
isFunction(api.api.tx.convictionVoting?.unlock) && referendaUnlockTx && (
<Menu.Item
icon='broom'
key='clearReferenda'
label={t('Clear expired referenda locks')}
onClick={_clearReferendaLocks}
/>
),
isFunction(api.api.tx.vesting?.vest) && vestingVestTx && (
<Menu.Item
icon='unlock'
@ -398,7 +463,7 @@ function Account ({ account: { address, meta }, className = '', delegation, filt
/>
])
].filter((i) => i),
[_clearDemocracyLocks, _showOnHardware, _vestingVest, api, delegation, democracyUnlockTx, genesisHash, identity, isDevelopment, isEditable, isEthereum, isExternal, isHardware, isInjected, isMultisig, multiInfos, onSetGenesisHash, proxy, recoveryInfo, t, toggleBackup, toggleDelegate, toggleDerive, toggleForget, toggleIdentityMain, toggleIdentitySub, toggleMultisig, togglePassword, toggleProxyOverview, toggleRecoverAccount, toggleRecoverSetup, toggleUndelegate, vestingVestTx]);
[_clearDemocracyLocks, _clearReferendaLocks, _showOnHardware, _vestingVest, api, delegation, democracyUnlockTx, genesisHash, identity, isDevelopment, isEditable, isEthereum, isExternal, isHardware, isInjected, isMultisig, multiInfos, onSetGenesisHash, proxy, referendaUnlockTx, recoveryInfo, t, toggleBackup, toggleDelegate, toggleDerive, toggleForget, toggleIdentityMain, toggleIdentitySub, toggleMultisig, togglePassword, toggleProxyOverview, toggleRecoverAccount, toggleRecoverSetup, toggleUndelegate, vestingVestTx]);
if (!isVisible) {
return null;
@ -691,6 +756,7 @@ function Account ({ account: { address, meta }, className = '', delegation, filt
<AddressInfo
address={address}
balancesAll={balancesAll}
convictionLocks={convictionLocks}
withBalance={BAL_OPTS_EXPANDED}
withExtended={false}
/>

View File

@ -73,3 +73,11 @@ export interface TrackInfo {
origin: Record<string, string> | Record<string, string>[];
text?: string;
}
export interface Lock {
classId: BN;
endBlock: BN;
locked: string;
refId: BN;
total: BN;
}

View File

@ -0,0 +1,185 @@
// Copyright 2017-2022 @polkadot/app-referenda authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ApiPromise } from '@polkadot/api';
import type { Option } from '@polkadot/types';
import type { PalletConvictionVotingVoteCasting, PalletConvictionVotingVoteVoting, PalletReferendaReferendumInfoConvictionVotingTally } from '@polkadot/types/lookup';
import type { BN } from '@polkadot/util';
import type { Lock, PalletReferenda, PalletVote } from './types';
import { useMemo } from 'react';
import { createNamedHook, useApi, useCall } from '@polkadot/react-hooks';
import { BN_MAX_INTEGER } from '@polkadot/util';
interface WithAbstain {
asSplitAbstain: {
abstain: BN;
aye: BN;
nay: BN;
};
isSplitAbstain: boolean;
}
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 && 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; i < votes.length; i++) {
const [classId,, casting] = votes[i];
for (let i = 0; i < casting.votes.length; 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 conviction = 0;
let locked = 'None';
if (accountVote.isStandard) {
const { balance, vote } = accountVote.asStandard;
total = balance;
if ((tally.isApproved && vote.isAye) || (tally.isRejected && vote.isNay)) {
conviction = vote.conviction.index;
locked = vote.conviction.type;
}
} else if (accountVote.isSplit) {
const { aye, nay } = accountVote.asSplit;
total = aye.add(nay);
} else if ((accountVote as unknown as WithAbstain).isSplitAbstain) {
// FIXME Once we have updated the API with latest metadata, this won't be needed
const { abstain, aye, nay } = (accountVote as unknown as WithAbstain).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(conviction)
.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);

View File

@ -15,7 +15,7 @@ import { withCalls, withMulti } from '@polkadot/react-api/hoc';
import { Expander, Icon, Tooltip } from '@polkadot/react-components';
import { useBestNumber } from '@polkadot/react-hooks';
import { BlockToTime, FormatBalance } from '@polkadot/react-query';
import { BN_ZERO, bnMax, formatBalance, formatNumber, isObject } from '@polkadot/util';
import { BN_MAX_INTEGER, BN_ZERO, bnMax, formatBalance, formatNumber, isObject } from '@polkadot/util';
import CryptoType from './CryptoType';
import DemocracyLocks from './DemocracyLocks';
@ -51,8 +51,8 @@ interface Props {
address: string;
balancesAll?: DeriveBalancesAll;
children?: React.ReactNode;
classLocksFor?: [BN, BN][];
className?: string;
convictionLocks?: RefLock[];
democracyLocks?: DeriveDemocracyLock[];
extraInfo?: [string, string][];
stakingInfo?: DeriveStakingAccount;
@ -65,6 +65,13 @@ interface Props {
withLabel?: boolean;
}
interface RefLock {
endBlock: BN;
locked: string;
refId: BN;
total: BN;
}
const DEFAULT_BALANCES: BalanceActiveType = {
available: true,
bonded: true,
@ -223,7 +230,7 @@ function renderValidatorPrefs ({ stakingInfo, withValidatorPrefs = false }: Prop
);
}
function createBalanceItems (formatIndex: number, lookup: Record<string, string>, t: TFunction, { address, balanceDisplay, balancesAll, bestNumber, classLocksFor, democracyLocks, isAllLocked, otherBonded, ownBonded, stakingInfo, votingOf, withBalanceToggle, withLabel }: { address: string; balanceDisplay: BalanceActiveType; balancesAll?: DeriveBalancesAll | DeriveBalancesAccountData; bestNumber: BlockNumber; classLocksFor?: [BN, BN][]; democracyLocks?: DeriveDemocracyLock[]; isAllLocked: boolean; otherBonded: BN[]; ownBonded: BN; stakingInfo?: DeriveStakingAccount; votingOf?: Voting; withBalanceToggle: boolean, withLabel: boolean }): React.ReactNode {
function createBalanceItems (formatIndex: number, lookup: Record<string, string>, t: TFunction, { address, balanceDisplay, balancesAll, bestNumber, convictionLocks, democracyLocks, isAllLocked, otherBonded, ownBonded, stakingInfo, votingOf, withBalanceToggle, withLabel }: { address: string; balanceDisplay: BalanceActiveType; balancesAll?: DeriveBalancesAll | DeriveBalancesAccountData; bestNumber: BlockNumber; convictionLocks?: RefLock[]; democracyLocks?: DeriveDemocracyLock[]; isAllLocked: boolean; otherBonded: BN[]; ownBonded: BN; stakingInfo?: DeriveStakingAccount; votingOf?: Voting; withBalanceToggle: boolean, withLabel: boolean }): React.ReactNode {
const allItems: React.ReactNode[] = [];
const deriveBalances = balancesAll as DeriveBalancesAll;
@ -432,15 +439,41 @@ function createBalanceItems (formatIndex: number, lookup: Record<string, string>
);
}
if (classLocksFor && (classLocksFor.length !== 0)) {
const max = classLocksFor.reduce((max, [, value]) => bnMax(max, value), BN_ZERO);
if (convictionLocks && convictionLocks.length) {
const max = convictionLocks.reduce((max, { total }) => bnMax(max, total), BN_ZERO);
allItems.push(
<React.Fragment key={9}>
<Label label={t<string>('referenda')} />
<FormatBalance
className='result'
labelPost={<IconVoid />}
labelPost={
<>
<Icon
icon='clock'
tooltip={`${address}-conviction-locks-trigger`}
/>
<Tooltip
text={convictionLocks.map(({ endBlock, locked, refId, total }, index): React.ReactNode => (
<div key={index}>
<div className='nowrap'>#{refId.toString()} {formatBalance(total, { forceUnit: '-' })} {locked}</div>
<div className='faded nowrap'>{
endBlock.eq(BN_MAX_INTEGER)
? t('ongoing referendum')
: bestNumber.gte(endBlock)
? t('lock expired')
: <>{formatNumber(endBlock.sub(bestNumber))} {t('blocks')},&nbsp;
<BlockToTime
isInline
value={endBlock.sub(bestNumber)}
/></>
}</div>
</div>
))}
trigger={`${address}-conviction-locks-trigger`}
/>
</>
}
value={max}
/>
</React.Fragment>
@ -477,7 +510,7 @@ function createBalanceItems (formatIndex: number, lookup: Record<string, string>
}
function renderBalances (props: Props, lookup: Record<string, string>, bestNumber: BlockNumber | undefined, t: TFunction): React.ReactNode[] {
const { address, balancesAll, classLocksFor, democracyLocks, stakingInfo, votingOf, withBalance = true, withBalanceToggle = false, withLabel = false } = props;
const { address, balancesAll, convictionLocks, democracyLocks, stakingInfo, votingOf, withBalance = true, withBalanceToggle = false, withLabel = false } = props;
const balanceDisplay = withBalance === true
? DEFAULT_BALANCES
: withBalance || false;
@ -488,7 +521,7 @@ function renderBalances (props: Props, lookup: Record<string, string>, bestNumbe
const [ownBonded, otherBonded] = calcBonded(stakingInfo, balanceDisplay.bonded);
const isAllLocked = !!balancesAll && balancesAll.lockedBreakdown.some(({ amount }): boolean => amount?.isMax());
const baseOpts = { address, balanceDisplay, bestNumber, classLocksFor, democracyLocks, isAllLocked, otherBonded, ownBonded, votingOf, withBalanceToggle, withLabel };
const baseOpts = { address, balanceDisplay, bestNumber, convictionLocks, democracyLocks, isAllLocked, otherBonded, ownBonded, votingOf, withBalanceToggle, withLabel };
const items = [createBalanceItems(0, lookup, t, { ...baseOpts, balancesAll, stakingInfo })];
withBalanceToggle && balancesAll?.additional.length && balancesAll.additional.forEach((balancesAll, index): void => {
@ -563,6 +596,14 @@ export default withMulti(
justify-content: flex-end;
}
.nowrap {
white-space: nowrap;
.ui--FormatBalance {
display: inline-block;
}
}
.column {
max-width: 260px;
&.column--expander {
@ -644,11 +685,6 @@ export default withMulti(
paramName: 'address',
propName: 'votingOf',
skipIf: skipStakingIf
}],
['query.convictionVoting.classLocksFor', {
paramName: 'address',
propName: 'classLocksFor',
skipIf: skipStakingIf
}]
)
);

View File

@ -26,7 +26,7 @@ function BlockToTime ({ api, children, className = '', isInline, label, value }:
}
return (
<div className={`${className}${isInline ? ' isInline' : ''}`}>
<div className={`.ui--BlockToTime ${className}${isInline ? ' isInline' : ''}`}>
{label || ''}{text.split(' ').map((v, index) =>
<span
className={index % 2 ? 'timeUnits' : undefined}