Staking pools (#7089)

* Staking pools

* Fly-by comment fix

* Some infrastructure

* multi

* Create value

* Retrieve singular params instance

* defaultValue for multi query

* Retrieve list of pool ids

* Drive-by param formatting

* Pool list retrieval

* Retrieve pool info

* Adjust trigger placement

* Dump info

* Join modal

* Fix build

* Allow creation & joining

* caveman typing

* Adjust disabled state

* Adjust

* cowboy types

* Some basic stats

* Created/Destoyed events

* Adjust for latest

* Set metadata on creation

* Basic pools
This commit is contained in:
Jaco 2022-03-28 20:34:08 +02:00 committed by GitHub
parent 105c484e8a
commit 2a7af81d16
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 573 additions and 4 deletions

View File

@ -0,0 +1,119 @@
// Copyright 2017-2022 @polkadot/app-staking authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { BN } from '@polkadot/util';
import type { Params } from './types';
import React, { useMemo, useState } from 'react';
import { Button, Input, InputAddress, InputBalance, InputNumber, Modal, TxButton } from '@polkadot/react-components';
import { useApi, useCall, useToggle } from '@polkadot/react-hooks';
import { BN_ONE, bnMax } from '@polkadot/util';
import { useTranslation } from '../translate';
interface Props {
className?: string;
isDisabled?: boolean;
params: Params;
}
function Create ({ className, isDisabled, params: { minCreateBond, minNominatorBond } }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api } = useApi();
const lastPoolId = useCall<BN>(api.query.nominationPools.lastPoolId);
const [isOpen, toggleOpen] = useToggle();
const [accountId, setAccount] = useState<string | null>(null);
const [amount, setAmount] = useState<BN | undefined>();
const [metadata, setMetadata] = useState('');
const minValue = useMemo(
() => minCreateBond && minNominatorBond && bnMax(minCreateBond, minNominatorBond),
[minCreateBond, minNominatorBond]
);
const nextPoolId = useMemo(
() => lastPoolId && lastPoolId.add(BN_ONE),
[lastPoolId]
);
const isAmountError = useMemo(
() => !amount || !minValue || amount.lt(minValue),
[amount, minValue]
);
const extrinsic = useMemo(
() => accountId && !isAmountError
? api.tx.utility.batch([
api.tx.nominationPools.create(amount, accountId, accountId, accountId),
api.tx.nominationPools.setMetadata(nextPoolId, metadata)
])
: null,
[api, accountId, amount, isAmountError, metadata, nextPoolId]
);
return (
<>
<Button
icon='plus'
isDisabled={isDisabled || !minValue}
label={t<string>('Add pool')}
onClick={toggleOpen}
/>
{isOpen && (
<Modal
className={className}
header={t<string>('Create nomination pool')}
onClose={toggleOpen}
size='large'
>
<Modal.Content>
<Modal.Columns hint={t<string>('The origin account will also be set as the pool admin, nominator and state toggler.')}>
<InputAddress
label={t<string>('create pool from')}
onChange={setAccount}
type='account'
value={accountId}
/>
</Modal.Columns>
<Modal.Columns hint={t<string>('The initial value to assign to the pool. It is set the the maximum of the minimum bond and the minium nomination value.')}>
<InputBalance
autoFocus
defaultValue={minValue}
isError={isAmountError}
label={t<string>('initial value')}
onChange={setAmount}
/>
</Modal.Columns>
<Modal.Columns hint={t<string>('The metadata to set for this pool')}>
<Input
label={t<string>('metadata')}
maxLength={32}
onChange={setMetadata}
/>
</Modal.Columns>
<Modal.Columns hint={t<string>('The id that will be assigned to this nomination pool.')}>
<InputNumber
defaultValue={nextPoolId}
isDisabled
label={t<string>('pool id')}
/>
</Modal.Columns>
</Modal.Content>
<Modal.Actions>
<TxButton
accountId={accountId}
extrinsic={extrinsic}
icon='plus'
isDisabled={!accountId || isAmountError}
label={t<string>('Create')}
onStart={toggleOpen}
/>
</Modal.Actions>
</Modal>
)}
</>
);
}
export default React.memo(Create);

View File

@ -0,0 +1,94 @@
// Copyright 2017-2022 @polkadot/app-staking authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { BN } from '@polkadot/util';
import type { Params } from './types';
import React, { useMemo, 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';
interface Props {
className?: string;
id: BN;
isDisabled?: boolean;
params: Params;
}
function Join ({ className, id, isDisabled, params: { minJoinBond, minNominatorBond } }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
const { api } = useApi();
const [isOpen, toggleOpen] = useToggle();
const [accountId, setAccount] = useState<string | null>(null);
const [amount, setAmount] = useState<BN | undefined>();
const minAmount = useMemo(
() => minJoinBond && minNominatorBond && bnMax(minJoinBond, minNominatorBond),
[minJoinBond, minNominatorBond]
);
const isAmountError = useMemo(
() => !amount || !minAmount || amount.lt(minAmount),
[amount, minAmount]
);
if (isDisabled) {
return null;
}
return (
<>
<Button
icon='plus'
isDisabled={!minAmount}
label={t<string>('Join')}
onClick={toggleOpen}
/>
{isOpen && (
<Modal
className={className}
header={t<string>('Join nomination pool')}
onClose={toggleOpen}
size='large'
>
<Modal.Content>
<Modal.Columns hint={t<string>('The account that is to join the pool.')}>
<InputAddress
label={t<string>('join pool from')}
onChange={setAccount}
type='account'
value={accountId}
/>
</Modal.Columns>
<Modal.Columns hint={t<string>('The initial value to assign to the pool. It is set the the maximum of the minimum bond and the minium nomination value.')}>
<InputBalance
autoFocus
defaultValue={minAmount}
isError={isAmountError}
label={t<string>('initial value')}
onChange={setAmount}
/>
</Modal.Columns>
</Modal.Content>
<Modal.Actions>
<TxButton
accountId={accountId}
icon='plus'
isDisabled={!accountId || isAmountError}
label={t<string>('Join')}
onStart={toggleOpen}
params={[amount, id]}
tx={api.tx.nominationPools.join}
/>
</Modal.Actions>
</Modal>
)}
</>
);
}
export default React.memo(Join);

View File

@ -0,0 +1,55 @@
// Copyright 2017-2022 @polkadot/app-staking authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { BN } from '@polkadot/util';
import type { Params } from './types';
import React, { useMemo } from 'react';
import { FormatBalance } from '@polkadot/react-query';
import { formatNumber, stringify } from '@polkadot/util';
import Join from './Join';
import usePoolInfo from './usePoolInfo';
interface Props {
className?: string;
id: BN;
params: Params;
}
function Pool ({ className, id, params }: Props): React.ReactElement<Props> | null {
const info = usePoolInfo(id);
const metadata = useMemo(
() => info && info.metadata && info.metadata.length
? info.metadata.isUtf8
? info.metadata.toUtf8()
: info.metadata.toString()
: null,
[info]
);
if (!info) {
return null;
}
console.log(stringify(info, 2));
return (
<tr className={className}>
<td className='number'><h1>{formatNumber(id)}</h1></td>
<td className='start'>{metadata}</td>
<td className='number'><FormatBalance value={info.bonded?.points} /></td>
<td className='number'>{formatNumber(info.bonded?.delegatorCounter)}</td>
<td className='button'>
<Join
id={id}
isDisabled={!info.bonded?.state.isOpen}
params={params}
/>
</td>
</tr>
);
}
export default React.memo(Pool);

View File

@ -0,0 +1,47 @@
// Copyright 2017-2022 @polkadot/app-staking authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { BN } from '@polkadot/util';
import type { Params } from './types';
import React, { useMemo } from 'react';
import { Table } from '@polkadot/react-components';
import { useTranslation } from '../translate';
import Pool from './Pool';
interface Props {
className?: string;
ids?: BN[];
params: Params;
}
function Pools ({ className, ids, params }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const header = useMemo(() => [
[t('pools'), 'start', 2],
[t('points')],
[t('delegators')]
], [t]);
return (
<Table
className={className}
empty={ids && t<string>('No available nomination pools')}
emptySpinner={t<string>('Retrieving nomination pools')}
header={header}
>
{ids?.map((id) => (
<Pool
id={id}
key={id.toString()}
params={params}
/>
))}
</Table>
);
}
export default React.memo(Pools);

View File

@ -0,0 +1,45 @@
// Copyright 2017-2022 @polkadot/app-staking authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { Params } from './types';
import React from 'react';
import { CardSummary, SummaryBox } from '@polkadot/react-components';
import { formatNumber } from '@polkadot/util';
import { useTranslation } from '../translate';
interface Props {
className?: string;
params: Params;
poolCount?: number;
}
function Summary ({ className, params: { maxDelegators, maxDelegatorsPool, maxPools }, poolCount }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
return (
<SummaryBox className={className}>
{maxDelegators && (
<CardSummary label={t<string>('pools')}>
{poolCount && <>{formatNumber(poolCount)}&nbsp;/&nbsp;</>}{formatNumber(maxPools)}
</CardSummary>
)}
<section>
{maxDelegatorsPool && (
<CardSummary label={t<string>('max. delegators')}>
{formatNumber(maxDelegators)}
</CardSummary>
)}
{maxPools && (
<CardSummary label={t<string>('max. delegators / pool')}>
{formatNumber(maxDelegatorsPool)}
</CardSummary>
)}
</section>
</SummaryBox>
);
}
export default React.memo(Summary);

View File

@ -0,0 +1,47 @@
// Copyright 2017-2022 @polkadot/app-staking authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React, { useMemo } from 'react';
import { Button } from '@polkadot/react-components';
import Create from './Create';
import Pools from './Pools';
import Summary from './Summary';
import useParams from './useParams';
import usePoolIds from './usePoolIds';
interface Props {
className?: string;
}
function NominationPools ({ className }: Props): React.ReactElement<Props> {
const ids = usePoolIds();
const params = useParams();
const noCreate = useMemo(
() => !ids || (!!params.maxPools && (ids.length > params.maxPools)),
[ids, params]
);
return (
<div className={className}>
<Summary
params={params}
poolCount={ids?.length}
/>
<Button.Group>
<Create
isDisabled={noCreate}
params={params}
/>
</Button.Group>
<Pools
ids={ids}
params={params}
/>
</div>
);
}
export default React.memo(NominationPools);

View File

@ -0,0 +1,38 @@
// Copyright 2017-2022 @polkadot/app-staking authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { Bytes } from '@polkadot/types';
import type { AccountId } from '@polkadot/types/interfaces';
import type { BN } from '@polkadot/util';
export interface Params {
maxDelegators: number;
maxDelegatorsPool: number;
maxPools: number;
minCreateBond?: BN;
minJoinBond?: BN;
minNominatorBond?: BN;
}
// FIXME Once available, use types from lookup
export interface PoolInfo {
bonded: {
points: BN;
state: {
isOpen: boolean;
}
delegatorCounter: BN;
roles: {
depositor: AccountId;
root: AccountId;
nominator: AccountId;
stateToggler: AccountId;
};
} | null;
reward: {
balance: BN;
totalEarnings: BN;
points: BN;
} | null;
metadata: Bytes;
}

View File

@ -0,0 +1,40 @@
// Copyright 2017-2022 @polkadot/app-staking authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { Option, u32 } from '@polkadot/types';
import type { BN } from '@polkadot/util';
import type { Params } from './types';
import { createNamedHook, useApi, useCallMulti } from '@polkadot/react-hooks';
import { BN_ZERO } from '@polkadot/util';
const queryOptions = {
defaultValue: {
maxDelegators: 0,
maxDelegatorsPool: 0,
maxPools: 0
},
transform: ([maxDelegators, maxDelegatorsPerPool, maxPools, minCreateBond, minJoinBond, minNominatorBond]: [Option<u32>, Option<u32>, Option<u32>, BN, BN, BN]): Params => ({
maxDelegators: maxDelegators.unwrapOr(BN_ZERO).toNumber(),
maxDelegatorsPool: maxDelegatorsPerPool.unwrapOr(BN_ZERO).toNumber(),
maxPools: maxPools.unwrapOr(BN_ZERO).toNumber(),
minCreateBond,
minJoinBond,
minNominatorBond
})
};
function useParamsImpl (): Params {
const { api } = useApi();
return useCallMulti<Params>([
api.query.nominationPools.maxDelegators,
api.query.nominationPools.maxDelegatorsPerPool,
api.query.nominationPools.maxPools,
api.query.nominationPools.minCreateBond,
api.query.nominationPools.minJoinBond,
api.query.staking.minNominatorBond
], queryOptions);
}
export default createNamedHook('useParams', useParamsImpl);

View File

@ -0,0 +1,42 @@
// Copyright 2017-2022 @polkadot/app-staking authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { Changes } from '@polkadot/react-hooks/useEventChanges';
import type { StorageKey, u32 } from '@polkadot/types';
import type { EventRecord } from '@polkadot/types/interfaces';
import { createNamedHook, useApi, useEventChanges, useMapKeys } from '@polkadot/react-hooks';
const keyOptions = {
transform: (keys: StorageKey<[u32]>[]): u32[] =>
keys
.map(({ args: [poolId] }) => poolId)
.sort((a, b) => a.cmp(b))
};
function filter (records: EventRecord[]): Changes<u32> {
const added: u32[] = [];
const removed: u32[] = [];
records.forEach(({ event: { data, method } }): void => {
if (method === 'Created') {
added.push(data[1] as u32);
} else {
removed.push(data[0] as u32);
}
});
return { added, removed };
}
function usePoolIdsImpl (): u32[] | undefined {
const { api } = useApi();
const startValue = useMapKeys(api.query.nominationPools.bondedPools, keyOptions);
return useEventChanges([
api.events.nominationPools.Created,
api.events.nominationPools.Destroyed
], filter, startValue);
}
export default createNamedHook('usePoolIds', usePoolIdsImpl);

View File

@ -0,0 +1,29 @@
// Copyright 2017-2022 @polkadot/app-staking authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { Option } from '@polkadot/types';
import type { Codec } from '@polkadot/types/types';
import type { BN } from '@polkadot/util';
import type { PoolInfo } from './types';
import { createNamedHook, useApi, useCallMulti } from '@polkadot/react-hooks';
const options = {
transform: ([bonded, metadata, reward]: [Option<PoolInfo['bonded'] & Codec>, PoolInfo['metadata'], Option<PoolInfo['reward'] & Codec>]): PoolInfo => ({
bonded: bonded.unwrapOr(null),
metadata,
reward: reward.unwrapOr(null)
})
};
function usePoolInfoImpl (id: BN): PoolInfo {
const { api } = useApi();
return useCallMulti([
[api.query.nominationPools.bondedPools, id],
[api.query.nominationPools.metadata, id],
[api.query.nominationPools.rewardPools, id]
], options);
}
export default createNamedHook('usePoolInfo', usePoolInfoImpl);

View File

@ -17,7 +17,7 @@ interface Props {
slash: SlashEra;
}
function Header ({ slash: { era, nominators, reporters, total, validators } }: Props): React.ReactElement<Props> | null {
function Summary ({ slash: { era, nominators, reporters, total, validators } }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
const { api } = useApi();
const sessionInfo = useCall<DeriveSessionProgress>(api.derive.session?.progress);
@ -62,4 +62,4 @@ function Header ({ slash: { era, nominators, reporters, total, validators } }: P
);
}
export default React.memo(Header);
export default React.memo(Summary);

View File

@ -23,6 +23,7 @@ import Bags from './Bags';
import { STORE_FAVS_BASE } from './constants';
import Overview from './Overview';
import Payouts from './Payouts';
import Pools from './Pools';
import Query from './Query';
import Slashes from './Slashes';
import Targets from './Targets';
@ -67,6 +68,11 @@ function StakingApp ({ basePath, className = '' }: Props): React.ReactElement<Pr
[api, hasAccounts]
);
const hasStashes = useMemo(
() => hasAccounts && !!ownStashes && (ownStashes.length !== 0),
[hasAccounts, ownStashes]
);
const ownValidators = useMemo(
() => (ownStashes || []).filter(({ isStashValidating }) => isStashValidating),
[ownStashes]
@ -87,10 +93,14 @@ function StakingApp ({ basePath, className = '' }: Props): React.ReactElement<Pr
name: 'actions',
text: t<string>('Account actions')
},
isFunction(api.query.staking.activeEra) && hasAccounts && ownStashes && (ownStashes.length !== 0) && {
hasStashes && isFunction(api.query.staking.activeEra) && {
name: 'payout',
text: t<string>('Payouts')
},
// hasStashes && isFunction(api.query.nominationPools?.minCreateBond) && {
// name: 'pools',
// text: t<string>('Pools')
// },
{
alias: 'returns',
name: 'targets',
@ -110,7 +120,7 @@ function StakingApp ({ basePath, className = '' }: Props): React.ReactElement<Pr
name: 'query',
text: t<string>('Validator stats')
}
].filter((q): q is { name: string; text: string } => !!q), [api, hasAccounts, ownStashes, slashes, t]);
].filter((q): q is { name: string; text: string } => !!q), [api, hasStashes, slashes, t]);
return (
<main className={`staking--App ${className}`}>
@ -139,6 +149,9 @@ function StakingApp ({ basePath, className = '' }: Props): React.ReactElement<Pr
ownValidators={ownValidators}
/>
</Route>
<Route path={`${basePath}/pools`}>
<Pools />
</Route>
<Route path={[`${basePath}/query/:value`, `${basePath}/query`]}>
<Query />
</Route>