From b7a0ee7900742caa8554d5c957dc1471ed898baf Mon Sep 17 00:00:00 2001 From: Jaco Date: Fri, 16 Dec 2022 13:30:07 +0200 Subject: [PATCH] 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 --- .../page-accounts/src/Accounts/Account.tsx | 78 +++++++- packages/page-referenda/src/types.ts | 8 + .../page-referenda/src/useAccountLocks.ts | 185 ++++++++++++++++++ packages/react-components/src/AddressInfo.tsx | 62 ++++-- packages/react-query/src/BlockToTime.tsx | 2 +- 5 files changed, 315 insertions(+), 20 deletions(-) create mode 100644 packages/page-referenda/src/useAccountLocks.ts diff --git a/packages/page-accounts/src/Accounts/Account.tsx b/packages/page-accounts/src/Accounts/Account.tsx index b529cc6f92..99a43c9485 100644 --- a/packages/page-accounts/src/Accounts/Account.tsx +++ b/packages/page-accounts/src/Accounts/Account.tsx @@ -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 { 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({ democracyUnlockTx: null, ids: [] }); + const convictionLocks = useAccountLocks('referenda', 'convictionVoting', address); + const [{ democracyUnlockTx }, setDemocracyUnlock] = useState({ democracyUnlockTx: null, ids: [] }); + const [{ referendaUnlockTx }, setReferandaUnlock] = useState({ ids: [], referendaUnlockTx: null }); const [vestingVestTx, setVestingTx] = useState | 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 && ( + + ), isFunction(api.api.tx.vesting?.vest) && vestingVestTx && ( ]) ].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 diff --git a/packages/page-referenda/src/types.ts b/packages/page-referenda/src/types.ts index d628f6ec5b..5424a74d15 100644 --- a/packages/page-referenda/src/types.ts +++ b/packages/page-referenda/src/types.ts @@ -73,3 +73,11 @@ export interface TrackInfo { origin: Record | Record[]; text?: string; } + +export interface Lock { + classId: BN; + endBlock: BN; + locked: string; + refId: BN; + total: BN; +} diff --git a/packages/page-referenda/src/useAccountLocks.ts b/packages/page-referenda/src/useAccountLocks.ts new file mode 100644 index 0000000000..b049c59dc8 --- /dev/null +++ b/packages/page-referenda/src/useAccountLocks.ts @@ -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[]]): [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((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(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); diff --git a/packages/react-components/src/AddressInfo.tsx b/packages/react-components/src/AddressInfo.tsx index 590e25e2da..0ee929d0cd 100644 --- a/packages/react-components/src/AddressInfo.tsx +++ b/packages/react-components/src/AddressInfo.tsx @@ -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, 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, 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 ); } - 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( @@ -477,7 +510,7 @@ function createBalanceItems (formatIndex: number, lookup: Record } function renderBalances (props: Props, lookup: Record, 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, 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 }] ) ); diff --git a/packages/react-query/src/BlockToTime.tsx b/packages/react-query/src/BlockToTime.tsx index b6d4f7b0cf..399a2fade6 100644 --- a/packages/react-query/src/BlockToTime.tsx +++ b/packages/react-query/src/BlockToTime.tsx @@ -26,7 +26,7 @@ function BlockToTime ({ api, children, className = '', isInline, label, value }: } return ( -
+
{label || ''}{text.split(' ').map((v, index) =>