From 47913892cd21fe9b91cd35cc36858a30659ebdef Mon Sep 17 00:00:00 2001 From: Jaco Date: Tue, 26 Apr 2022 17:21:38 +0200 Subject: [PATCH] Expansion to pools logic (#7384) * Expansion to pools logic * Add filtering * Pass through own pools * Allow pool nominate tx * Start with account actions * Asho pool nominees * Nomination operational * Adjust table rows * BondExtra for pools * use availableBalance in calcs (w/ existential) * Re-use isAmountError check * Pass through actual pool info * s/id/poolId/ * Display payout stashes * Display points balance * OCD cleanups * claimPayout * Calculate pool claimable * Unbond * Fix Unbond max * Display remaining witldrawal eras * Stashed/Pooled selection * Withdraw unbonded * StakingRedeemable display * Renames as per metadata * Additional * s/pool/pools/ * PoolInfo display hints * Adjust usePoolInfo hook * Ordering adjustments * Additional dedupes * Combine queries * Combinations & renames * Flatten transform * Non-existent reward * bnMax sub * lint fixes * Bump API --- package.json | 28 +-- packages/apps-config/package.json | 6 +- packages/page-contracts/package.json | 2 +- .../src/Actions/Account/BondExtra.tsx | 2 +- .../src/Actions/Account/Nominate.tsx | 5 +- .../src/Actions/Account/index.tsx | 56 ++--- .../page-staking/src/Actions/Accounts.tsx | 58 +++++ .../page-staking/src/Actions/Pool/Account.tsx | 197 ++++++++++++++++ .../src/Actions/Pool/BondExtra.tsx | 93 ++++++++ .../page-staking/src/Actions/Pool/Unbond.tsx | 97 ++++++++ .../page-staking/src/Actions/Pool/index.tsx | 48 ++++ .../page-staking/src/Actions/Pool/types.ts | 10 + .../src/Actions/Pool/useAccountInfo.ts | 46 ++++ packages/page-staking/src/Actions/Pools.tsx | 60 +++++ packages/page-staking/src/Actions/index.tsx | 136 ++++++----- .../src/Actions/partials/Nominate.tsx | 31 ++- .../src/Actions/partials/PoolInfo.tsx | 44 ++++ .../src/Actions/useSlashingSpans.ts | 22 ++ packages/page-staking/src/Payouts/index.tsx | 11 +- packages/page-staking/src/Pools/Create.tsx | 26 ++- packages/page-staking/src/Pools/Join.tsx | 30 +-- packages/page-staking/src/Pools/Pool.tsx | 61 +++-- packages/page-staking/src/Pools/Pools.tsx | 81 +++++-- packages/page-staking/src/Pools/Summary.tsx | 16 +- packages/page-staking/src/Pools/index.tsx | 20 +- packages/page-staking/src/Pools/types.ts | 47 ++-- .../page-staking/src/Pools/useAmountError.ts | 24 ++ packages/page-staking/src/Pools/useMembers.ts | 108 +++++++++ packages/page-staking/src/Pools/useParams.ts | 17 +- packages/page-staking/src/Pools/usePoolIds.ts | 8 +- .../page-staking/src/Pools/usePoolInfo.ts | 58 +++-- packages/page-staking/src/index.tsx | 6 +- packages/page-staking/src/types.ts | 16 ++ packages/page-staking/src/useOwnPools.ts | 50 ++++ packages/page-staking/src/usePoolAccounts.ts | 35 +++ packages/react-api/package.json | 4 +- .../src/InputAddress/index.tsx | 13 +- .../src/StakingRedeemable.tsx | 33 ++- .../react-components/src/StakingUnbonding.tsx | 18 +- packages/react-components/src/ToggleGroup.tsx | 19 +- packages/react-hooks/src/useCallMulti.ts | 4 +- packages/react-hooks/src/useOwnEraRewards.ts | 8 +- packages/react-hooks/src/useOwnStashes.ts | 24 +- packages/test-support/package.json | 2 +- yarn.lock | 216 +++++++++--------- 45 files changed, 1471 insertions(+), 425 deletions(-) create mode 100644 packages/page-staking/src/Actions/Accounts.tsx create mode 100644 packages/page-staking/src/Actions/Pool/Account.tsx create mode 100644 packages/page-staking/src/Actions/Pool/BondExtra.tsx create mode 100644 packages/page-staking/src/Actions/Pool/Unbond.tsx create mode 100644 packages/page-staking/src/Actions/Pool/index.tsx create mode 100644 packages/page-staking/src/Actions/Pool/types.ts create mode 100644 packages/page-staking/src/Actions/Pool/useAccountInfo.ts create mode 100644 packages/page-staking/src/Actions/Pools.tsx create mode 100644 packages/page-staking/src/Actions/partials/PoolInfo.tsx create mode 100644 packages/page-staking/src/Actions/useSlashingSpans.ts create mode 100644 packages/page-staking/src/Pools/useAmountError.ts create mode 100644 packages/page-staking/src/Pools/useMembers.ts create mode 100644 packages/page-staking/src/useOwnPools.ts create mode 100644 packages/page-staking/src/usePoolAccounts.ts diff --git a/package.json b/package.json index 2208fafb1e..78a6d0df18 100644 --- a/package.json +++ b/package.json @@ -91,24 +91,24 @@ "webpack-cli": "^4.9.2" }, "resolutions": { - "@polkadot/api": "^8.2.2-1", - "@polkadot/api-augment": "^8.2.2-1", - "@polkadot/api-base": "^8.2.2-1", - "@polkadot/api-contract": "^8.2.2-1", - "@polkadot/api-derive": "^8.2.2-1", + "@polkadot/api": "^8.2.2-2", + "@polkadot/api-augment": "^8.2.2-2", + "@polkadot/api-base": "^8.2.2-2", + "@polkadot/api-contract": "^8.2.2-2", + "@polkadot/api-derive": "^8.2.2-2", "@polkadot/hw-ledger": "^9.0.1", "@polkadot/keyring": "^9.0.1", "@polkadot/networks": "^9.0.1", "@polkadot/phishing": "^0.11.91", - "@polkadot/rpc-augment": "^8.2.2-1", - "@polkadot/rpc-core": "^8.2.2-1", - "@polkadot/rpc-provider": "^8.2.2-1", - "@polkadot/types": "^8.2.2-1", - "@polkadot/types-augment": "^8.2.2-1", - "@polkadot/types-codec": "^8.2.2-1", - "@polkadot/types-create": "^8.2.2-1", - "@polkadot/types-known": "^8.2.2-1", - "@polkadot/types-support": "^8.2.2-1", + "@polkadot/rpc-augment": "^8.2.2-2", + "@polkadot/rpc-core": "^8.2.2-2", + "@polkadot/rpc-provider": "^8.2.2-2", + "@polkadot/types": "^8.2.2-2", + "@polkadot/types-augment": "^8.2.2-2", + "@polkadot/types-codec": "^8.2.2-2", + "@polkadot/types-create": "^8.2.2-2", + "@polkadot/types-known": "^8.2.2-2", + "@polkadot/types-support": "^8.2.2-2", "@polkadot/util": "^9.0.1", "@polkadot/util-crypto": "^9.0.1", "@polkadot/wasm-crypto": "^6.0.1", diff --git a/packages/apps-config/package.json b/packages/apps-config/package.json index ce293e7e1b..f8e7ee5c9d 100644 --- a/packages/apps-config/package.json +++ b/packages/apps-config/package.json @@ -29,10 +29,10 @@ "@metaverse-network-sdk/type-definitions": "^0.0.1-13", "@parallel-finance/type-definitions": "1.6.5", "@phala/typedefs": "0.2.30", - "@polkadot/api": "^8.2.2-1", - "@polkadot/api-derive": "^8.2.2-1", + "@polkadot/api": "^8.2.2-2", + "@polkadot/api-derive": "^8.2.2-2", "@polkadot/networks": "^9.0.1", - "@polkadot/types": "^8.2.2-1", + "@polkadot/types": "^8.2.2-2", "@polkadot/util": "^9.0.1", "@polkadot/x-fetch": "^9.0.1", "@polymathnetwork/polymesh-types": "0.0.2", diff --git a/packages/page-contracts/package.json b/packages/page-contracts/package.json index c40566a676..7502f46d65 100644 --- a/packages/page-contracts/package.json +++ b/packages/page-contracts/package.json @@ -14,6 +14,6 @@ "version": "0.113.2-1", "dependencies": { "@babel/runtime": "^7.17.9", - "@polkadot/api-contract": "^8.2.2-1" + "@polkadot/api-contract": "^8.2.2-2" } } diff --git a/packages/page-staking/src/Actions/Account/BondExtra.tsx b/packages/page-staking/src/Actions/Account/BondExtra.tsx index 5f6994c008..b31109542d 100644 --- a/packages/page-staking/src/Actions/Account/BondExtra.tsx +++ b/packages/page-staking/src/Actions/Account/BondExtra.tsx @@ -72,7 +72,7 @@ function BondExtra ({ controllerId, onClose, stakingInfo, stashId }: Props): Rea autoFocus defaultValue={startBalance} help={t('Amount to add to the currently bonded funds. This is adjusted using the available funds on the account.')} - isError={!!amountError?.error || !maxAdditional || maxAdditional.eqn(0)} + isError={!!amountError?.error || !maxAdditional || maxAdditional.isZero()} label={t('additional funds to bond')} labelExtra={ void; + poolId?: BN; stashId: string; targets: SortedTargets; } -function Nominate ({ className = '', controllerId, nominating, onClose, stashId, targets }: Props): React.ReactElement | null { +function Nominate ({ className = '', controllerId, nominating, onClose, poolId, stashId, targets }: Props): React.ReactElement | null { const { t } = useTranslation(); const [{ nominateTx }, setTx] = useState({}); @@ -38,6 +40,7 @@ function Nominate ({ className = '', controllerId, nominating, onClose, stashId, controllerId={controllerId} nominating={nominating} onChange={setTx} + poolId={poolId} stashId={stashId} targets={targets} withSenders diff --git a/packages/page-staking/src/Actions/Account/index.tsx b/packages/page-staking/src/Actions/Account/index.tsx index 35c0862d8f..a204f9d930 100644 --- a/packages/page-staking/src/Actions/Account/index.tsx +++ b/packages/page-staking/src/Actions/Account/index.tsx @@ -3,8 +3,7 @@ import type { DeriveBalancesAll, DeriveStakingAccount } from '@polkadot/api-derive/types'; import type { StakerState } from '@polkadot/react-hooks/types'; -import type { Option } from '@polkadot/types'; -import type { SlashingSpans, UnappliedSlash } from '@polkadot/types/interfaces'; +import type { PalletStakingUnappliedSlash } from '@polkadot/types/lookup'; import type { SortedTargets } from '../../types'; import type { Slash } from '../types'; @@ -12,11 +11,12 @@ import React, { useCallback, useContext, useMemo } from 'react'; import styled from 'styled-components'; import { ApiPromise } from '@polkadot/api'; -import { AddressInfo, AddressMini, AddressSmall, Badge, Button, Checkbox, Menu, Popup, StakingBonded, StakingRedeemable, StakingUnbonding, StatusContext, TxButton } from '@polkadot/react-components'; +import { AddressInfo, AddressMini, AddressSmall, Badge, Button, Menu, Popup, StakingBonded, StakingRedeemable, StakingUnbonding, StatusContext, TxButton } from '@polkadot/react-components'; import { useApi, useCall, useToggle } from '@polkadot/react-hooks'; import { BN, formatNumber, isFunction } from '@polkadot/util'; import { useTranslation } from '../../translate'; +import useSlashingSpans from '../useSlashingSpans'; import BondExtra from './BondExtra'; import InjectKeys from './InjectKeys'; import KickNominees from './KickNominees'; @@ -31,20 +31,18 @@ import Validate from './Validate'; import WarnBond from './WarnBond'; interface Props { - allSlashes?: [BN, UnappliedSlash[]][]; + allSlashes?: [BN, PalletStakingUnappliedSlash[]][]; className?: string; isDisabled?: boolean; - isSelectable: boolean; info: StakerState; minCommission?: BN; next?: string[]; - onSelect: (stashId: string, controllerId: string, isSelected: boolean) => void; stashId: string; targets: SortedTargets; validators?: string[]; } -function extractSlashes (stashId: string, allSlashes: [BN, UnappliedSlash[]][] = []): Slash[] { +function extractSlashes (stashId: string, allSlashes: [BN, PalletStakingUnappliedSlash[]][] = []): Slash[] { return allSlashes .map(([era, all]) => ({ era, @@ -55,23 +53,16 @@ function extractSlashes (stashId: string, allSlashes: [BN, UnappliedSlash[]][] = .filter(({ slashes }) => slashes.length); } -const transformSpan = { - transform: (optSpans: Option): number => - optSpans.isNone - ? 0 - : optSpans.unwrap().prior.length + 1 -}; - function useStashCalls (api: ApiPromise, stashId: string) { const params = useMemo(() => [stashId], [stashId]); const balancesAll = useCall(api.derive.balances?.all, params); - const spanCount = useCall(api.query.staking.slashingSpans, params, transformSpan); const stakingAccount = useCall(api.derive.staking.account, params); + const spanCount = useSlashingSpans(stashId); return { balancesAll, spanCount, stakingAccount }; } -function Account ({ allSlashes, className = '', info: { controllerId, destination, hexSessionIdNext, hexSessionIdQueue, isLoading, isOwnController, isOwnStash, isStashNominating, isStashValidating, nominating, sessionIds, stakingLedger, stashId }, isDisabled, isSelectable, minCommission, onSelect, targets }: Props): React.ReactElement { +function Account ({ allSlashes, className = '', info: { controllerId, destination, hexSessionIdNext, hexSessionIdQueue, isLoading, isOwnController, isOwnStash, isStashNominating, isStashValidating, nominating, sessionIds, stakingLedger, stashId }, isDisabled, minCommission, targets }: Props): React.ReactElement { const { t } = useTranslation(); const { api } = useApi(); const { queueExtrinsic } = useContext(StatusContext); @@ -87,29 +78,20 @@ function Account ({ allSlashes, className = '', info: { controllerId, destinatio const [isValidateOpen, toggleValidate] = useToggle(); const { balancesAll, spanCount, stakingAccount } = useStashCalls(api, stashId); - const _onSelect = useCallback( - (isSelected: boolean) => controllerId && onSelect(stashId, controllerId, isSelected), - [controllerId, stashId, onSelect] - ); - - const [isSelected, , setSelected] = useToggle(false, _onSelect); - const slashes = useMemo( () => extractSlashes(stashId, allSlashes), [allSlashes, stashId] ); const withdrawFunds = useCallback( - () => { - queueExtrinsic({ - accountId: controllerId, - extrinsic: api.tx.staking.withdrawUnbonded.meta.args.length === 1 - ? api.tx.staking.withdrawUnbonded(spanCount || 0) - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore (We are doing toHex here since we have a Vec input) - : api.tx.staking.withdrawUnbonded() - }); - }, + () => queueExtrinsic({ + accountId: controllerId, + extrinsic: api.tx.staking.withdrawUnbonded.meta.args.length === 1 + ? api.tx.staking.withdrawUnbonded(spanCount) + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore (We are doing toHex here since we have a Vec input) + : api.tx.staking.withdrawUnbonded() + }), [api, controllerId, queueExtrinsic, spanCount] ); @@ -377,14 +359,6 @@ function Account ({ allSlashes, className = '', info: { controllerId, destinatio )} - - {isSelectable && controllerId && ( - - )} - ); } diff --git a/packages/page-staking/src/Actions/Accounts.tsx b/packages/page-staking/src/Actions/Accounts.tsx new file mode 100644 index 0000000000..826d24ed2a --- /dev/null +++ b/packages/page-staking/src/Actions/Accounts.tsx @@ -0,0 +1,58 @@ +// Copyright 2017-2022 @polkadot/app-staking authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { StakerState } from '@polkadot/react-hooks/types'; +import type { PalletStakingUnappliedSlash } from '@polkadot/types/lookup'; +import type { SortedTargets } from '../types'; + +import React, { useRef } from 'react'; + +import { Table } from '@polkadot/react-components'; +import { BN } from '@polkadot/util'; + +import { useTranslation } from '../translate'; +import Account from './Account'; + +interface Props { + allSlashes: [BN, PalletStakingUnappliedSlash[]][]; + className?: string; + footer: React.ReactNode; + isInElection?: boolean; + list?: StakerState[]; + minCommission?: BN; + targets: SortedTargets; +} + +function Accounts ({ allSlashes, className, footer, isInElection, list, minCommission, targets }: Props): React.ReactElement { + const { t } = useTranslation(); + + const hdrRef = useRef([ + [t('stashes'), 'start', 2], + [t('controller'), 'address'], + [t('rewards'), 'start media--1200'], + [t('bonded'), 'number'], + [] + ]); + + return ( + ('No funds staked yet. Bond funds to validate or nominate a validator')} + footer={footer} + header={hdrRef.current} + > + {list?.map((info): React.ReactNode => ( + + ))} +
+ ); +} + +export default React.memo(Accounts); diff --git a/packages/page-staking/src/Actions/Pool/Account.tsx b/packages/page-staking/src/Actions/Pool/Account.tsx new file mode 100644 index 0000000000..0331349367 --- /dev/null +++ b/packages/page-staking/src/Actions/Pool/Account.tsx @@ -0,0 +1,197 @@ +// Copyright 2017-2022 @polkadot/app-staking authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { DeriveSessionProgress, DeriveUnlocking } from '@polkadot/api-derive/types'; +import type { PalletNominationPoolsPoolMember, PalletNominationPoolsPoolRoles } from '@polkadot/types/lookup'; +import type { PoolInfo } from '../../Pools/types'; +import type { SortedTargets } from '../../types'; + +import React, { useCallback, useContext, useMemo } from 'react'; + +import { AddressSmall, Menu, Popup, StakingRedeemable, StakingUnbonding, StatusContext } from '@polkadot/react-components'; +import { useApi, useToggle } from '@polkadot/react-hooks'; +import { FormatBalance } from '@polkadot/react-query'; +import { BN, formatNumber } from '@polkadot/util'; + +import { useTranslation } from '../../translate'; +import ListNominees from '../Account/ListNominees'; +import Nominate from '../Account/Nominate'; +import useSlashingSpans from '../useSlashingSpans'; +import BondExtra from './BondExtra'; +import Unbond from './Unbond'; +import useAccountInfo from './useAccountInfo'; + +interface Props { + accountId: string; + className?: string; + info: PoolInfo; + isFirst: boolean; + poolId: BN; + sessionProgress?: DeriveSessionProgress; + targets: SortedTargets; +} + +interface Roles { + isNominator: boolean; +} + +function extractRoles (accountId: string, { nominator, root }: PalletNominationPoolsPoolRoles): Roles { + return { + isNominator: nominator.eq(accountId) || root.eq(accountId) + }; +} + +function calcUnbonding (accountId: string, stashId: string, { activeEra }: DeriveSessionProgress, { unbondingEras }: PalletNominationPoolsPoolMember): { accountId: string, controllerId: string, redeemable: BN, stashId: string, unlocking: DeriveUnlocking[] } { + const unlocking: DeriveUnlocking[] = []; + const redeemable = new BN(0); + + for (const [era, value] of unbondingEras.entries()) { + if (era.lte(activeEra)) { + redeemable.iadd(value); + } else { + unlocking.push({ remainingEras: era.sub(activeEra), value }); + } + } + + return { + accountId, + controllerId: accountId, + redeemable, + stashId, + unlocking + }; +} + +function Pool ({ accountId, className, info: { bonded: { points, roles }, metadata, nominating, reward, rewardClaimable, stashId }, isFirst, poolId, sessionProgress, targets }: Props): React.ReactElement { + const { t } = useTranslation(); + const { api } = useApi(); + const spanCount = useSlashingSpans(stashId); + const { queueExtrinsic } = useContext(StatusContext); + const [isBondOpen, toggleBond] = useToggle(); + const [isNominateOpen, toggleNominate] = useToggle(); + const [isUnbondOpen, toggleUnbond] = useToggle(); + const accInfo = useAccountInfo(accountId, reward, points, rewardClaimable); + + const stakingInfo = useMemo( + () => sessionProgress && accInfo && accInfo.member.unbondingEras && !accInfo.member.unbondingEras.isEmpty + ? calcUnbonding(accountId, stashId, sessionProgress, accInfo.member) + : null, + [accInfo, accountId, stashId, sessionProgress] + ); + + const claimPayout = useCallback( + () => queueExtrinsic({ + accountId, + extrinsic: api.tx.nominationPools.claimPayout() + }), + [api, accountId, queueExtrinsic] + ); + + const withdrawUnbonded = useCallback( + () => queueExtrinsic({ + accountId, + extrinsic: api.tx.nominationPools.withdrawUnbonded(accountId, spanCount) + }), + [api, accountId, spanCount, queueExtrinsic] + ); + + const { isNominator } = useMemo( + () => extractRoles(accountId, roles), + [accountId, roles] + ); + + return ( + +

{isFirst && formatNumber(poolId)}

+ {isFirst && metadata} + + + {accInfo && ( + <> + {!accInfo.member.points.isZero() && } + {stakingInfo && ( + <> + + + + )} + + )} + + {accInfo && !accInfo.claimable.isZero() && } + + {isFirst && nominating && ( + + )} + + + {isBondOpen && ( + + )} + {isNominateOpen && ( + + )} + {accInfo && isUnbondOpen && ( + + )} + + ('Bond more funds')} + onClick={toggleBond} + /> + ('Unbond funds')} + onClick={toggleUnbond} + /> + + ('Withdraw claimable')} + onClick={claimPayout} + /> + ('Withdraw unbonded')} + onClick={withdrawUnbonded} + /> + + ('Set nominees')} + onClick={toggleNominate} + /> + + } + /> + + + ); +} + +export default React.memo(Pool); diff --git a/packages/page-staking/src/Actions/Pool/BondExtra.tsx b/packages/page-staking/src/Actions/Pool/BondExtra.tsx new file mode 100644 index 0000000000..04f2c5409a --- /dev/null +++ b/packages/page-staking/src/Actions/Pool/BondExtra.tsx @@ -0,0 +1,93 @@ +// Copyright 2017-2022 @polkadot/app-staking authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { BN } from '@polkadot/util'; + +import React, { useRef, useState } from 'react'; + +import { Dropdown, InputBalance, Modal, TxButton } from '@polkadot/react-components'; +import { useApi } from '@polkadot/react-hooks'; +import { BalanceFree } from '@polkadot/react-query'; +import { BN_ZERO } from '@polkadot/util'; + +import useAmountError from '../../Pools/useAmountError'; +import { useTranslation } from '../../translate'; +import PoolInfo from '../partials/PoolInfo'; + +interface Props { + className?: string; + controllerId: string; + onClose: () => void; + poolId: BN; +} + +const DEFAULT_TYPE = 'rewards'; + +function BondExtra ({ className, controllerId, onClose, poolId }: Props): React.ReactElement { + const { t } = useTranslation(); + const { api } = useApi(); + const [type, setType] = useState(DEFAULT_TYPE); + const [amount, setAmount] = useState(BN_ZERO); + const isAmountError = useAmountError(controllerId, amount, BN_ZERO); + + const typeRef = useRef([ + { text: t('Free balance'), value: 'free' }, + { text: t('Pool rewards'), value: 'rewards' } + ]); + + return ( + ('Bond extra into pool')} + onClose={onClose} + size='large' + > + + + ('You can either bond a specific amount from your free balance, or all of the accumulated rewards.')}> + ('type of funds to bond')} + onChange={setType} + options={typeRef.current} + /> + {type === 'free' && ( + ('Amount to add to the currently bonded funds. This is adjusted using the available funds on the account.')} + isError={isAmountError} + label={t('additional free funds to bond')} + labelExtra={ + {t('balance')}} + params={controllerId} + /> + } + onChange={setAmount} + /> + )} + + + + ('Bond Extra')} + onStart={onClose} + params={[ + type === 'free' + ? { FreeBalance: amount } + : 'Rewards' + ]} + tx={api.tx.nominationPools.bondExtra} + /> + + + ); +} + +export default React.memo(BondExtra); diff --git a/packages/page-staking/src/Actions/Pool/Unbond.tsx b/packages/page-staking/src/Actions/Pool/Unbond.tsx new file mode 100644 index 0000000000..40d71727cf --- /dev/null +++ b/packages/page-staking/src/Actions/Pool/Unbond.tsx @@ -0,0 +1,97 @@ +// Copyright 2017-2022 @polkadot/app-staking authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { BN } from '@polkadot/util'; + +import React, { useState } from 'react'; + +import { InputBalance, Modal, Static, Toggle, TxButton } from '@polkadot/react-components'; +import { useApi } from '@polkadot/react-hooks'; +import { BlockToTime, FormatBalance } from '@polkadot/react-query'; +import { BN_ZERO } from '@polkadot/util'; + +import { useTranslation } from '../../translate'; +import PoolInfo from '../partials/PoolInfo'; +import useUnbondDuration from '../useUnbondDuration'; + +interface Props { + className?: string; + controllerId: string; + maxUnbond: BN; + onClose: () => void; + poolId: BN; +} + +function Unbond ({ className, controllerId, maxUnbond, onClose, poolId }: Props): React.ReactElement { + const { t } = useTranslation(); + const { api } = useApi(); + const [amount, setAmount] = useState(BN_ZERO); + const [withMax, setWithMax] = useState(false); + const bondedBlocks = useUnbondDuration(); + + const isAmountError = !amount || !maxUnbond || amount.gt(maxUnbond); + + return ( + ('Unbond funds from pool')} + onClose={onClose} + size='large' + > + + + ('The amount to unbond. It should be less or equal to the full bonded amount.')}> + ('Amount to unbond from the current bonded amount.')} + isDisabled={withMax} + isError={isAmountError} + key={`unbondAmount-${withMax.toString()}`} + label={t('amount to unbond')} + labelExtra={ + {t('bonded')}} + value={maxUnbond} + /> + } + maxValue={maxUnbond} + onChange={setAmount} + withMax + > + ('all bonded')} + onChange={setWithMax} + value={withMax} + /> + + {bondedBlocks?.gtn(0) && ( + ('The bonding duration for any staked funds. After this period needs to be withdrawn.')} + label={t('on-chain bonding duration')} + > + + + )} + + + + ('Unbond')} + onStart={onClose} + params={[controllerId, withMax ? maxUnbond : amount]} + tx={api.tx.nominationPools.unbond} + /> + + + ); +} + +export default React.memo(Unbond); diff --git a/packages/page-staking/src/Actions/Pool/index.tsx b/packages/page-staking/src/Actions/Pool/index.tsx new file mode 100644 index 0000000000..ffb8c871be --- /dev/null +++ b/packages/page-staking/src/Actions/Pool/index.tsx @@ -0,0 +1,48 @@ +// Copyright 2017-2022 @polkadot/app-staking authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { DeriveSessionProgress } from '@polkadot/api-derive/types'; +import type { u32 } from '@polkadot/types'; +import type { PalletNominationPoolsPoolMember } from '@polkadot/types/lookup'; +import type { SortedTargets } from '../../types'; + +import React from 'react'; + +import usePoolInfo from '../../Pools/usePoolInfo'; +import Account from './Account'; + +interface Props { + count: number; + className?: string; + members: Record; + poolId: u32; + sessionProgress?: DeriveSessionProgress; + targets: SortedTargets; +} + +function Pool ({ className, count, members, poolId, sessionProgress, targets }: Props): React.ReactElement | null { + const info = usePoolInfo(poolId); + + if (!info) { + return null; + } + + return ( + <> + {Object.keys(members).map((accountId, index) => ( + + ))} + + ); +} + +export default React.memo(Pool); diff --git a/packages/page-staking/src/Actions/Pool/types.ts b/packages/page-staking/src/Actions/Pool/types.ts new file mode 100644 index 0000000000..117ad3dff6 --- /dev/null +++ b/packages/page-staking/src/Actions/Pool/types.ts @@ -0,0 +1,10 @@ +// Copyright 2017-2022 @polkadot/app-staking authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { PalletNominationPoolsPoolMember } from '@polkadot/types/lookup'; +import type { BN } from '@polkadot/util'; + +export interface AccountInfo { + claimable: BN; + member: PalletNominationPoolsPoolMember; +} diff --git a/packages/page-staking/src/Actions/Pool/useAccountInfo.ts b/packages/page-staking/src/Actions/Pool/useAccountInfo.ts new file mode 100644 index 0000000000..d84bc16744 --- /dev/null +++ b/packages/page-staking/src/Actions/Pool/useAccountInfo.ts @@ -0,0 +1,46 @@ +// Copyright 2017-2022 @polkadot/app-staking authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { Option } from '@polkadot/types'; +import type { PalletNominationPoolsPoolMember, PalletNominationPoolsRewardPool } from '@polkadot/types/lookup'; +import type { BN } from '@polkadot/util'; +import type { AccountInfo } from './types'; + +import { useMemo } from 'react'; + +import { createNamedHook, useApi, useCall } from '@polkadot/react-hooks'; +import { BN_ZERO, bnMax } from '@polkadot/util'; + +const OPT_DEL = { + transform: (opt: Option): PalletNominationPoolsPoolMember | null => + opt.unwrapOr(null) +}; + +function createInfo (member: PalletNominationPoolsPoolMember, rewardPool: PalletNominationPoolsRewardPool, poolPoints: BN, currentBalance: BN): AccountInfo { + const lastTotalEarnings = rewardPool.totalEarnings; + const currTotalEarnings = bnMax(BN_ZERO, currentBalance.sub(rewardPool.balance)).add(rewardPool.totalEarnings); + const newEarnings = bnMax(BN_ZERO, currTotalEarnings.sub(lastTotalEarnings)); + const newPoints = poolPoints.mul(newEarnings); + const currentPoints = rewardPool.points.add(newPoints); + const newEarningsSinceLastClaim = bnMax(BN_ZERO, currTotalEarnings.sub(member.rewardPoolTotalEarnings)); + const delegatorVirtualPoints = member.points.mul(newEarningsSinceLastClaim); + + return { + claimable: delegatorVirtualPoints.isZero() || currentPoints.isZero() || currentBalance.isZero() + ? BN_ZERO + : delegatorVirtualPoints.mul(currentBalance).div(currentPoints), + member + }; +} + +function useAccountInfoImpl (accountId: string, rewardPool: PalletNominationPoolsRewardPool, poolPoints: BN, rewardClaimable: BN): AccountInfo | undefined | null { + const { api } = useApi(); + const member = useCall(api.query.nominationPools.poolMembers, [accountId], OPT_DEL); + + return useMemo( + () => member && createInfo(member, rewardPool, poolPoints, rewardClaimable), + [member, poolPoints, rewardPool, rewardClaimable] + ); +} + +export default createNamedHook('useAccountInfo', useAccountInfoImpl); diff --git a/packages/page-staking/src/Actions/Pools.tsx b/packages/page-staking/src/Actions/Pools.tsx new file mode 100644 index 0000000000..1ee8f2661c --- /dev/null +++ b/packages/page-staking/src/Actions/Pools.tsx @@ -0,0 +1,60 @@ +// Copyright 2017-2022 @polkadot/app-staking authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { DeriveSessionProgress } from '@polkadot/api-derive/types'; +import type { PalletStakingUnappliedSlash } from '@polkadot/types/lookup'; +import type { OwnPool, SortedTargets } from '../types'; + +import React, { useRef } from 'react'; + +import { Table } from '@polkadot/react-components'; +import { useApi, useCall } from '@polkadot/react-hooks'; +import { BN } from '@polkadot/util'; + +import { useTranslation } from '../translate'; +import Pool from './Pool'; + +interface Props { + allSlashes: [BN, PalletStakingUnappliedSlash[]][]; + className?: string; + isInElection?: boolean; + list?: OwnPool[]; + minCommission?: BN; + targets: SortedTargets; +} + +function Pools ({ className, list, targets }: Props): React.ReactElement { + const { t } = useTranslation(); + const { api } = useApi(); + const sessionProgress = useCall(api.derive.session.progress); + + const hdrRef = useRef([ + [t('pools'), 'start', 2], + [t('account'), 'address'], + [t('bonded')], + [t('claimable')], + [], + [] + ]); + + return ( + ('Not participating in any pools. Join a pool first.')} + header={hdrRef.current} + > + {list?.map(({ members, poolId }, count): React.ReactNode => ( + + ))} +
+ ); +} + +export default React.memo(Pools); diff --git a/packages/page-staking/src/Actions/index.tsx b/packages/page-staking/src/Actions/index.tsx index 25d2b85017..da28812626 100644 --- a/packages/page-staking/src/Actions/index.tsx +++ b/packages/page-staking/src/Actions/index.tsx @@ -4,26 +4,28 @@ import '@polkadot/api-augment'; import type { StakerState } from '@polkadot/react-hooks/types'; -import type { SortedTargets } from '../types'; +import type { OwnPool, SortedTargets } from '../types'; -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { useMemo, useRef, useState } from 'react'; -import { Button, Table, ToggleGroup } from '@polkadot/react-components'; -import { useAvailableSlashes } from '@polkadot/react-hooks'; +import { Button, ToggleGroup } from '@polkadot/react-components'; +import { useApi, useAvailableSlashes } from '@polkadot/react-hooks'; import { FormatBalance } from '@polkadot/react-query'; import { BN, BN_ZERO } from '@polkadot/util'; import ElectionBanner from '../ElectionBanner'; import { useTranslation } from '../translate'; -import Account from './Account'; +import Accounts from './Accounts'; import NewNominator from './NewNominator'; import NewStash from './NewStash'; import NewValidator from './NewValidator'; +import Pools from './Pools'; interface Props { className?: string; isInElection?: boolean; minCommission?: BN; + ownPools?: OwnPool[]; ownStashes?: StakerState[]; next?: string[]; validators?: string[]; @@ -85,9 +87,9 @@ function extractState (ownStashes?: StakerState[]): State { }; } -function filterStashes (typeIndex: number, stashes: StakerState[]): StakerState[] { +function filterStashes (stashTypeIndex: number, stashes: StakerState[]): StakerState[] { return stashes.filter(({ isStashNominating, isStashValidating }) => { - switch (typeIndex) { + switch (stashTypeIndex) { case 1: return isStashNominating; case 2: return isStashValidating; case 3: return !isStashNominating && !isStashValidating; @@ -96,8 +98,8 @@ function filterStashes (typeIndex: number, stashes: StakerState[]): StakerState[ }); } -function getValue (typeIndex: number, { bondedNoms, bondedNone, bondedTotal, bondedVals }: State): BN | undefined { - switch (typeIndex) { +function getValue (stashTypeIndex: number, { bondedNoms, bondedNone, bondedTotal, bondedVals }: State): BN | undefined { + switch (stashTypeIndex) { case 0: return bondedTotal; case 1: return bondedNoms; case 2: return bondedVals; @@ -106,27 +108,25 @@ function getValue (typeIndex: number, { bondedNoms, bondedNone, bondedTotal, bon } } -function formatTotal (typeIndex: number, state: State): React.ReactNode { - const value = getValue(typeIndex, state); +function formatTotal (stashTypeIndex: number, state: State): React.ReactNode { + const value = getValue(stashTypeIndex, state); return value && ; } -function Actions ({ className = '', isInElection, minCommission, ownStashes, targets }: Props): React.ReactElement { +function Actions ({ className = '', isInElection, minCommission, ownPools, ownStashes, targets }: Props): React.ReactElement { const { t } = useTranslation(); + const { api } = useApi(); const allSlashes = useAvailableSlashes(); - const [typeIndex, setTypeIndex] = useState(0); - const [, setSelected] = useState<[string, string][]>([]); + const [accTypeIndex, setAccTypeIndex] = useState(0); + const [stashTypeIndex, setStashTypeIndex] = useState(0); - const headerRef = useRef([ - [t('stashes'), 'start', 2], - [t('controller'), 'address'], - [t('rewards'), 'start media--1200'], - [t('bonded'), 'number'], - [undefined, undefined, 2] + const accTypes = useRef([ + { text: t('Stashed'), value: 'stash' }, + { text: t('Pooled'), value: 'pool' } ]); - const typeRef = useRef([ + const stashTypes = useRef([ { text: t('All stashes'), value: 'all' }, { text: t('Nominators'), value: 'noms' }, { text: t('Validators'), value: 'vals' }, @@ -138,74 +138,70 @@ function Actions ({ className = '', isInElection, minCommission, ownStashes, tar [ownStashes] ); - const [isSelectable, filtered, footer] = useMemo( + const [filtered, footer] = useMemo( () => [ - false, // [1, 2].includes(typeIndex) - state.foundStashes && filterStashes(typeIndex, state.foundStashes), + state.foundStashes && filterStashes(stashTypeIndex, state.foundStashes), ( - {formatTotal(typeIndex, state)} + {formatTotal(stashTypeIndex, state)} ) ], - [state, typeIndex] - ); - - const onSelectStash = useCallback( - (stashId: string, controllerId: string, isSelected: boolean) => - setSelected((prev) => - isSelected - ? [...prev, [stashId, controllerId]] - : prev.filter(([s]) => s !== stashId) - ), - [] - ); - - useEffect( - () => setSelected([]), - [typeIndex] + [state, stashTypeIndex] ); return (
- - - - + {api.consts.nominationPools && ( + + )} + {accTypeIndex === 0 && ( + <> + + + + + + )} - ('No funds staked yet. Bond funds to validate or nominate a validator')} - footer={footer} - header={headerRef.current} - > - {filtered?.map((info): React.ReactNode => ( - - ))} -
+ ) + : ( + + ) + }
); } diff --git a/packages/page-staking/src/Actions/partials/Nominate.tsx b/packages/page-staking/src/Actions/partials/Nominate.tsx index 3613bd3336..027b7eee1d 100644 --- a/packages/page-staking/src/Actions/partials/Nominate.tsx +++ b/packages/page-staking/src/Actions/partials/Nominate.tsx @@ -1,7 +1,7 @@ // Copyright 2017-2022 @polkadot/app-staking authors & contributors // SPDX-License-Identifier: Apache-2.0 -import type { u32 } from '@polkadot/types-codec'; +import type { BN } from '@polkadot/util'; import type { SortedTargets } from '../../types'; import type { NominateInfo } from './types'; @@ -13,6 +13,7 @@ import { useApi, useFavorites } from '@polkadot/react-hooks'; import { MAX_NOMINATIONS, STORE_FAVS_BASE } from '../../constants'; import { useTranslation } from '../../translate'; +import PoolInfo from './PoolInfo'; import SenderInfo from './SenderInfo'; interface Props { @@ -20,12 +21,13 @@ interface Props { controllerId: string; nominating?: string[]; onChange: (info: NominateInfo) => void; + poolId?: BN; stashId: string; targets: SortedTargets; withSenders?: boolean; } -function Nominate ({ className = '', controllerId, nominating, onChange, stashId, targets: { nominateIds = [] }, withSenders }: Props): React.ReactElement { +function Nominate ({ className = '', controllerId, nominating, onChange, poolId, stashId, targets: { nominateIds = [] }, withSenders }: Props): React.ReactElement { const { t } = useTranslation(); const { api } = useApi(); const [favorites] = useFavorites(STORE_FAVS_BASE); @@ -47,25 +49,36 @@ function Nominate ({ className = '', controllerId, nominating, onChange, stashId try { onChange({ nominateTx: selected && selected.length - ? api.tx.staking.nominate(selected) + ? poolId + ? api.tx.nominationPools.nominate(poolId, selected) + : api.tx.staking.nominate(selected) : null }); } catch { onChange({ nominateTx: null }); } - }, [api, onChange, selected]); + }, [api, onChange, poolId, selected]); const maxNominations = api.consts.staking.maxNominations - ? (api.consts.staking.maxNominations as u32).toNumber() + ? (api.consts.staking.maxNominations as BN).toNumber() : MAX_NOMINATIONS; return (
{withSenders && ( - + poolId + ? ( + + ) + : ( + + ) )} | null { + const { t } = useTranslation(); + + if (!poolId || !controllerId) { + return null; + } + + return ( + ('The pool and pool member that is to be affected. The transaction will be sent from the associated pool member account.')} + > + ('pool id')} + /> + ('member account')} + /> + + ); +} + +export default React.memo(PoolInfo); diff --git a/packages/page-staking/src/Actions/useSlashingSpans.ts b/packages/page-staking/src/Actions/useSlashingSpans.ts new file mode 100644 index 0000000000..cb4ef3901e --- /dev/null +++ b/packages/page-staking/src/Actions/useSlashingSpans.ts @@ -0,0 +1,22 @@ +// Copyright 2017-2022 @polkadot/app-staking authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { Option } from '@polkadot/types'; +import type { PalletStakingSlashingSlashingSpans } from '@polkadot/types/lookup'; + +import { createNamedHook, useApi, useCall } from '@polkadot/react-hooks'; + +const OPT_SPAN = { + transform: (optSpans: Option): number => + optSpans.isNone + ? 0 + : optSpans.unwrap().prior.length + 1 +}; + +function useSlashingSpansImpl (stashId: string): number { + const { api } = useApi(); + + return useCall(api.query.staking.slashingSpans, [stashId], OPT_SPAN) || 0; +} + +export default createNamedHook('useSlashingSpans', useSlashingSpansImpl); diff --git a/packages/page-staking/src/Payouts/index.tsx b/packages/page-staking/src/Payouts/index.tsx index b39b836f52..e565ce40e4 100644 --- a/packages/page-staking/src/Payouts/index.tsx +++ b/packages/page-staking/src/Payouts/index.tsx @@ -4,6 +4,7 @@ import type { TFunction } from 'i18next'; import type { DeriveStakerReward } from '@polkadot/api-derive/types'; import type { StakerState } from '@polkadot/react-hooks/types'; +import type { OwnPool } from '../types'; import type { PayoutStash, PayoutValidator } from './types'; import React, { useMemo, useRef, useState } from 'react'; @@ -23,6 +24,7 @@ import Validator from './Validator'; interface Props { className?: string; isInElection?: boolean; + ownPools?: OwnPool[]; ownValidators: StakerState[]; } @@ -156,7 +158,7 @@ function getOptions (blockTime: BN, eraLength: BN | undefined, historyDepth: BN return eraSelection; } -function Payouts ({ className = '', isInElection, ownValidators }: Props): React.ReactElement { +function Payouts ({ className = '', isInElection, ownPools, ownValidators }: Props): React.ReactElement { const { t } = useTranslation(); const { api } = useApi(); const [hasOwnValidators] = useState(() => ownValidators.length !== 0); @@ -166,12 +168,17 @@ function Payouts ({ className = '', isInElection, ownValidators }: Props): React const historyDepth = useCall(api.query.staking.historyDepth); const blockTime = useBlockInterval(); + const poolStashes = useMemo( + () => ownPools && ownPools.map(({ stashId }) => stashId), + [ownPools] + ); + const eraSelection = useMemo( () => getOptions(blockTime, eraLength, historyDepth, t), [blockTime, eraLength, historyDepth, t] ); - const { allRewards, isLoadingRewards } = useOwnEraRewards(eraSelection[eraSelectionIndex].value, myStashesIndex ? undefined : ownValidators); + const { allRewards, isLoadingRewards } = useOwnEraRewards(eraSelection[eraSelectionIndex].value, myStashesIndex ? undefined : ownValidators, poolStashes); const { stashAvail, stashes, valAvail, validators } = useMemo( () => getAvailable(allRewards), diff --git a/packages/page-staking/src/Pools/Create.tsx b/packages/page-staking/src/Pools/Create.tsx index c6db948ae0..964c316153 100644 --- a/packages/page-staking/src/Pools/Create.tsx +++ b/packages/page-staking/src/Pools/Create.tsx @@ -11,14 +11,19 @@ import { useApi, useToggle } from '@polkadot/react-hooks'; import { BN_ZERO, bnMax } from '@polkadot/util'; import { useTranslation } from '../translate'; +import useAmountError from './useAmountError'; interface Props { className?: string; isDisabled?: boolean; + ownAccounts?: string[]; params: Params; } -function Create ({ className, isDisabled, params: { minCreateBond, minNominatorBond, nextPoolId } }: Props): React.ReactElement { +const MAX_META_LEN = 32; +const MIN_META_LEN = 3; + +function Create ({ className, isDisabled, ownAccounts, params: { minCreateBond, minNominatorBond, nextPoolId } }: Props): React.ReactElement { const { t } = useTranslation(); const { api } = useApi(); const [isOpen, toggleOpen] = useToggle(); @@ -32,13 +37,15 @@ function Create ({ className, isDisabled, params: { minCreateBond, minNominatorB [api, minCreateBond, minNominatorBond] ); - const isAmountError = useMemo( - () => !amount || !minValue || amount.lt(minValue), - [amount, minValue] + const isAmountError = useAmountError(accountId, amount, minValue); + + const isMetaError = useMemo( + () => !metadata || (metadata.length < MIN_META_LEN) || (metadata.length > MAX_META_LEN), + [metadata] ); const extrinsic = useMemo( - () => accountId && !isAmountError + () => amount && accountId && !isAmountError && nextPoolId ? api.tx.utility.batch([ api.tx.nominationPools.create(amount, accountId, accountId, accountId), api.tx.nominationPools.setMetadata(nextPoolId, metadata) @@ -65,13 +72,15 @@ function Create ({ className, isDisabled, params: { minCreateBond, minNominatorB ('The origin account will also be set as the pool admin, nominator and state toggler.')}> ('create pool from')} onChange={setAccount} type='account' value={accountId} + withExclude /> - ('The initial value to assign to the pool. It is set the the maximum of the minimum bond and the minium nomination value.')}> + ('The initial value to assign to the pool. It is set to the maximum of the minimum bond and the minium nomination value.')}> ('The metadata description to set for this pool')}> ('description')} - maxLength={32} + maxLength={MAX_META_LEN} onChange={setMetadata} /> @@ -100,7 +110,7 @@ function Create ({ className, isDisabled, params: { minCreateBond, minNominatorB accountId={accountId} extrinsic={extrinsic} icon='plus' - isDisabled={!accountId || isAmountError} + isDisabled={!accountId || isAmountError || isMetaError} label={t('Create')} onStart={toggleOpen} /> diff --git a/packages/page-staking/src/Pools/Join.tsx b/packages/page-staking/src/Pools/Join.tsx index e9b0570ac7..508a74908c 100644 --- a/packages/page-staking/src/Pools/Join.tsx +++ b/packages/page-staking/src/Pools/Join.tsx @@ -4,37 +4,29 @@ import type { BN } from '@polkadot/util'; import type { Params } from './types'; -import React, { useMemo, useState } from 'react'; +import React, { useState } from 'react'; import { Button, InputAddress, InputBalance, Modal, TxButton } from '@polkadot/react-components'; import { useApi, useToggle } from '@polkadot/react-hooks'; -import { bnMax } from '@polkadot/util'; import { useTranslation } from '../translate'; +import useAmountError from './useAmountError'; interface Props { className?: string; - id: BN; isDisabled?: boolean; + ownAccounts?: string[]; params: Params; + poolId: BN; } -function Join ({ className, id, isDisabled, params: { minJoinBond, minNominatorBond } }: Props): React.ReactElement | null { +function Join ({ className, isDisabled, ownAccounts, params: { minMemberBond }, poolId }: Props): React.ReactElement | null { const { t } = useTranslation(); const { api } = useApi(); const [isOpen, toggleOpen] = useToggle(); const [accountId, setAccount] = useState(null); const [amount, setAmount] = useState(); - - const minAmount = useMemo( - () => minJoinBond && minNominatorBond && bnMax(minJoinBond, minNominatorBond), - [minJoinBond, minNominatorBond] - ); - - const isAmountError = useMemo( - () => !amount || !minAmount || amount.lt(minAmount), - [amount, minAmount] - ); + const isAmountError = useAmountError(accountId, amount, minMemberBond); if (isDisabled) { return null; @@ -44,7 +36,7 @@ function Join ({ className, id, isDisabled, params: { minJoinBond, minNominatorB <>