Crowdloan withdrawals (#4966)

* Crowdloan withdrawals

* cleanups

* Modal

* withdraw batch & single
This commit is contained in:
Jaco Greeff 2021-03-29 12:20:51 +03:00 committed by GitHub
parent 3a6693fe0a
commit b341581f51
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 202 additions and 29 deletions

View File

@ -19,7 +19,7 @@ interface Props {
raised: Balance;
}
function FundContribute ({ cap, className, paraId, raised }: Props): React.ReactElement<Props> {
function Contribute ({ cap, className, paraId, raised }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api } = useApi();
const { hasAccounts } = useAccounts();
@ -103,4 +103,4 @@ function FundContribute ({ cap, className, paraId, raised }: Props): React.React
);
}
export default React.memo(FundContribute);
export default React.memo(Contribute);

View File

@ -2,6 +2,7 @@
// SPDX-License-Identifier: Apache-2.0
import type BN from 'bn.js';
import type { StorageKey } from '@polkadot/types';
import type { EventRecord, ParaId } from '@polkadot/types/interfaces';
import type { Campaign, LeasePeriod } from '../types';
@ -14,7 +15,8 @@ import { formatNumber } from '@polkadot/util';
import { encodeAddress } from '@polkadot/util-crypto';
import { useTranslation } from '../translate';
import FundContribute from './FundContribute';
import Contribute from './Contribute';
import Withdraw from './Withdraw';
interface Props {
bestNumber?: BN;
@ -25,16 +27,28 @@ interface Props {
}
interface Contributions {
uniqueCount?: number;
myAccounts?: string[];
uniqueKeys: string[];
myAccounts: string[];
}
const NO_CONTRIB: Contributions = { myAccounts: [], uniqueKeys: [] };
function extractContributors (allAccounts: string[], keys: StorageKey[]): Contributions {
const uniqueKeys = keys.map((k) => k.toHex());
const contributors = keys.map((k) => encodeAddress(k));
return {
myAccounts: contributors.filter((c) => allAccounts.includes(c)),
uniqueKeys
};
}
function Fund ({ bestNumber, className, isOngoing, leasePeriod, value: { childKey, info: { cap, depositor, end, firstSlot, lastSlot, raised, retiring }, isCapped, isEnded, isRetired, isWinner, paraId, retireEnd } }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api } = useApi();
const { allAccounts } = useAccounts();
const [{ myAccounts, uniqueCount }, setContributors] = useState<Contributions>({});
const trigger = useEventTrigger([api.events.crowdloan.Contributed], useCallback(
const { allAccounts, isAccount } = useAccounts();
const [{ myAccounts, uniqueKeys }, setContributors] = useState<Contributions>(NO_CONTRIB);
const trigger = useEventTrigger([api.events.crowdloan.Contributed, api.events.crowdloan.Withdrew], useCallback(
({ event: { data: [, fundIndex] } }: EventRecord) =>
(fundIndex as ParaId).eq(paraId),
[paraId]
@ -44,24 +58,15 @@ function Fund ({ bestNumber, className, isOngoing, leasePeriod, value: { childKe
trigger &&
api.rpc.childstate
.getKeys(childKey, '0x')
.then((keys) => setContributors((): Contributions => {
const contributors = keys.map((k) => encodeAddress(k));
return {
myAccounts: contributors.filter((c) => allAccounts.includes(c)),
uniqueCount: contributors.length
};
}))
.then((keys) => setContributors(
extractContributors(allAccounts, keys))
)
.catch(console.error);
}, [allAccounts, api, childKey, trigger]);
const isDepositor = useMemo(
(): boolean => {
const address = depositor.toString();
return allAccounts.some((a) => a === address);
},
[allAccounts, depositor]
() => isAccount(depositor.toString()),
[depositor, isAccount]
);
const [blocksLeft, retiringLeft] = useMemo(
@ -87,8 +92,14 @@ function Fund ({ bestNumber, className, isOngoing, leasePeriod, value: { childKe
[cap, raised]
);
const isLeaseOver = !!leasePeriod && (
isWinner
? leasePeriod.currentPeriod.gt(lastSlot)
: leasePeriod.currentPeriod.gt(firstSlot)
);
const canContribute = isOngoing && blocksLeft && !isCapped && !isWinner && retiring.isFalse;
const canDissolve = raised.isZero() || (isRetired && (!isWinner || !leasePeriod || lastSlot.lt(leasePeriod.currentPeriod)));
const canDissolve = raised.isZero() || (isRetired && isLeaseOver);
const canWithdraw = canDissolve || (!!(bestNumber && bestNumber.gt(end)) && isLeaseOver);
return (
<tr className={className}>
@ -140,17 +151,24 @@ function Fund ({ bestNumber, className, isOngoing, leasePeriod, value: { childKe
<div>{percentage}</div>
</td>
<td className='number media--1100'>
{uniqueCount && (
formatNumber(uniqueCount)
{uniqueKeys.length !== 0 && (
formatNumber(uniqueKeys.length)
)}
</td>
<td className='badge'>
<Icon
color={myAccounts?.length ? 'green' : 'gray'}
color={myAccounts.length ? 'green' : 'gray'}
icon='asterisk'
/>
</td>
<td className='button'>
{canWithdraw && uniqueKeys.length !== 0 && (
<Withdraw
allAccounts={uniqueKeys}
myAccounts={myAccounts}
paraId={paraId}
/>
)}
{canDissolve && (
<TxButton
accountId={depositor}
@ -166,7 +184,7 @@ function Fund ({ bestNumber, className, isOngoing, leasePeriod, value: { childKe
/>
)}
{isOngoing && canContribute && (
<FundContribute
<Contribute
cap={cap}
paraId={paraId}
raised={raised}

View File

@ -0,0 +1,153 @@
// Copyright 2017-2021 @polkadot/app-parachains authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ApiPromise } from '@polkadot/api';
import type { SubmittableExtrinsic } from '@polkadot/api/types';
import type { ParaId } from '@polkadot/types/interfaces';
import React, { useEffect, useMemo, useState } from 'react';
import { Button, Dropdown, InputAddress, Modal, TxButton } from '@polkadot/react-components';
import { useAccounts, useApi, useToggle } from '@polkadot/react-hooks';
import { isFunction } from '@polkadot/util';
import { useTranslation } from '../translate';
interface Props {
allAccounts: string[];
className?: string;
myAccounts: string[];
paraId: ParaId;
}
function createExtrinsics (api: ApiPromise, addresses: string[], paraId: ParaId, maxPayouts: number): SubmittableExtrinsic<'promise'>[] {
return addresses
.reduce((batches: SubmittableExtrinsic<'promise'>[][], address): SubmittableExtrinsic<'promise'>[][] => {
const tx = api.tx.crowdloan.withdraw(address, paraId);
const batch = batches[batches.length - 1];
if (batch.length >= maxPayouts) {
batches.push([tx]);
} else {
batch.push(tx);
}
return batches;
}, [[]])
.reduce((extrinsics: SubmittableExtrinsic<'promise'>[], batch): SubmittableExtrinsic<'promise'>[] => {
if (batch.length === 1) {
extrinsics.push(batch[0]);
} else if (isFunction(api.tx.utility?.batch)) {
extrinsics.push(api.tx.utility.batch(batch));
} else {
extrinsics.push(...batch);
}
return extrinsics;
}, []);
}
function Withdraw ({ allAccounts, className, myAccounts, paraId }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api } = useApi();
const { hasAccounts } = useAccounts();
const [isOpen, toggleOpen] = useToggle();
const [accountId, setAccountId] = useState<string | null>(null);
const [extrinsics, setExtrinsics] = useState<SubmittableExtrinsic<'promise'>[] | null>(null);
const [batchSize, setBatchSize] = useState(0);
const [withdrawType, setWithdrawType] = useState(0);
const typeOptions = useMemo(
() => [
{
text: t<string>('Withdraw for all contributed accounts'),
value: 0
},
myAccounts.length && {
text: t<string>('Withdraw only for my accounts'),
value: 1
}
].filter((o) => !!o),
[myAccounts, t]
);
useEffect((): void => {
api.tx.crowdloan
.withdraw(allAccounts[0], paraId)
.paymentInfo(allAccounts[0])
.then((info) => setBatchSize(
info.weight.isZero()
? 128
: Math.floor(
api.consts.system.blockWeights.maxBlock
.muln(64) // 65% of the block weight on a single extrinsic (64 for safety)
.div(info.weight)
.toNumber() / 100
)
))
.catch(console.error);
}, [api, allAccounts, paraId]);
useEffect((): void => {
const addresses = withdrawType === 0
? allAccounts
: myAccounts;
console.log('batchSize=', batchSize);
setExtrinsics(() =>
batchSize && addresses.length
? createExtrinsics(api, allAccounts, paraId, batchSize)
: null
);
}, [api, allAccounts, batchSize, myAccounts, paraId, withdrawType]);
return (
<>
<Button
icon='minus'
isDisabled={!hasAccounts}
label={t<string>('Withdraw')}
onClick={toggleOpen}
/>
{isOpen && (
<Modal
className={className}
header={t<string>('Withdraw from fund')}
size='large'
>
<Modal.Content>
<Modal.Columns hint={t<string>('This account will be used to send the transaction.')}>
<InputAddress
label={t<string>('requesting from')}
onChange={setAccountId}
type='account'
value={accountId}
/>
</Modal.Columns>
<Modal.Columns hint={t<string>('The type of withdrawal, all accounts or only own accounts (if available)')}>
<Dropdown
label={t<string>('withdrawal type')}
onChange={setWithdrawType}
options={typeOptions}
value={withdrawType}
/>
</Modal.Columns>
</Modal.Content>
<Modal.Actions onCancel={toggleOpen}>
<TxButton
accountId={accountId}
extrinsic={extrinsics}
icon='credit-card'
isDisabled={!extrinsics || !extrinsics.length || !accountId}
label={t<string>('Withdraw')}
onStart={toggleOpen}
/>
</Modal.Actions>
</Modal>
)}
</>
);
}
export default React.memo(Withdraw);

View File

@ -13,16 +13,18 @@ interface UseAccounts {
isAccount: (address: string) => boolean;
}
const EMPTY: UseAccounts = { allAccounts: [], hasAccounts: false, isAccount: () => false };
export function useAccounts (): UseAccounts {
const mountedRef = useIsMountedRef();
const [state, setState] = useState<UseAccounts>({ allAccounts: [], hasAccounts: false, isAccount: () => false });
const [state, setState] = useState<UseAccounts>(EMPTY);
useEffect((): () => void => {
const subscription = keyring.accounts.subject.subscribe((accounts): void => {
if (mountedRef.current) {
const allAccounts = accounts ? Object.keys(accounts) : [];
const hasAccounts = allAccounts.length !== 0;
const isAccount = (address: string): boolean => allAccounts.includes(address);
const isAccount = (address: string) => allAccounts.includes(address);
setState({ allAccounts, hasAccounts, isAccount });
}