diff --git a/packages/page-parachains/src/Crowdloan/Fund.tsx b/packages/page-parachains/src/Crowdloan/Fund.tsx index 582ce008bb..d777a9970c 100644 --- a/packages/page-parachains/src/Crowdloan/Fund.tsx +++ b/packages/page-parachains/src/Crowdloan/Fund.tsx @@ -2,7 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 import type BN from 'bn.js'; -import type { BlockNumber } from '@polkadot/types/interfaces'; import type { Campaign } from './types'; import React, { useMemo } from 'react'; @@ -22,7 +21,7 @@ interface Props { value: Campaign; } -function Fund ({ bestNumber, className, isOngoing, value: { info: { cap, depositor, end, firstSlot, lastSlot, raised, retiring }, isEnded, paraId } }: Props): React.ReactElement { +function Fund ({ bestNumber, className, isOngoing, value: { info: { cap, depositor, end, firstSlot, lastSlot, raised, retiring }, isCapped, isEnded, isWinner, paraId } }: Props): React.ReactElement { const { t } = useTranslation(); const { api } = useApi(); const { allAccounts } = useAccounts(); @@ -43,17 +42,14 @@ function Fund ({ bestNumber, className, isOngoing, value: { info: { cap, deposit // TODO Dissolve should look at retirement and the actual period - const [percentage, isCapped] = useMemo( - () => [ - cap.isZero() - ? '100.00%' - : `${(raised.muln(10000).div(cap).toNumber() / 100).toFixed(2)}%`, - cap.sub(raised).lt(api.consts.crowdloan.minContribution as BlockNumber) - ], - [api, cap, raised] + const percentage = useMemo( + () => cap.isZero() + ? '100.00%' + : `${(raised.muln(10000).div(cap).toNumber() / 100).toFixed(2)}%`, + [cap, raised] ); - const canContribute = blocksLeft && !isCapped && retiring.isFalse; + const canContribute = blocksLeft && !isCapped && !isWinner && retiring.isFalse; const canDissolve = raised.isZero(); return ( @@ -63,11 +59,13 @@ function Fund ({ bestNumber, className, isOngoing, value: { info: { cap, deposit {retiring.isTrue ? t('Retiring') - : blocksLeft - ? isCapped - ? t('Capped') - : t('Active') - : t('Ended') + : isWinner + ? t('Winner') + : blocksLeft + ? isCapped + ? t('Capped') + : t('Active') + : t('Ended') } diff --git a/packages/page-parachains/src/Crowdloan/types.ts b/packages/page-parachains/src/Crowdloan/types.ts index 0d214cc154..bdb38f278d 100644 --- a/packages/page-parachains/src/Crowdloan/types.ts +++ b/packages/page-parachains/src/Crowdloan/types.ts @@ -1,11 +1,23 @@ // Copyright 2017-2021 @polkadot/app-parachains authors & contributors // SPDX-License-Identifier: Apache-2.0 -import type { FundInfo, ParaId } from '@polkadot/types/interfaces'; +import type { AccountId, BalanceOf, FundInfo, ParaId } from '@polkadot/types/interfaces'; export interface Campaign { info: FundInfo; + isCapped?: boolean; isEnded?: boolean; + isWinner?: boolean; key: string; paraId: ParaId; } + +export interface LeaseLease { + accountId: AccountId; + balance: BalanceOf; +} + +export interface Lease { + leases: LeaseLease[]; + paraId: ParaId; +} diff --git a/packages/page-parachains/src/Crowdloan/useFunds.ts b/packages/page-parachains/src/Crowdloan/useFunds.ts index b173c12ab1..985baad9e1 100644 --- a/packages/page-parachains/src/Crowdloan/useFunds.ts +++ b/packages/page-parachains/src/Crowdloan/useFunds.ts @@ -2,14 +2,15 @@ // SPDX-License-Identifier: Apache-2.0 import type { Option } from '@polkadot/types'; -import type { BlockNumber, FundInfo, ParaId } from '@polkadot/types/interfaces'; -import type { Campaign } from './types'; +import type { AccountId, BalanceOf, BlockNumber, FundInfo, ParaId } from '@polkadot/types/interfaces'; +import type { ITuple } from '@polkadot/types/types'; +import type { Campaign, Lease } from './types'; import BN from 'bn.js'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useState } from 'react'; -import { useApi, useBestNumber, useCallMulti, useEventTrigger } from '@polkadot/react-hooks'; -import { BN_ZERO } from '@polkadot/util'; +import { useApi, useBestNumber, useCall, useEventTrigger } from '@polkadot/react-hooks'; +import { BN_ZERO, stringToU8a, u8aConcat, u8aEq } from '@polkadot/util'; interface Result { activeCap: BN; @@ -27,18 +28,47 @@ const EMPTY: Result = { totalRaised: BN_ZERO }; -// extract fund info from the available paraIds -function extractCampaigns (optFunds: Option[], paraIds: ParaId[]): Campaign[] | null { - return optFunds && paraIds.length === optFunds.length - ? paraIds - .map((paraId, i) => ({ info: optFunds[i].unwrapOr(null), key: paraId.toString(), paraId })) - .filter((fund): fund is Campaign => !!fund.info) - .sort((a, b) => a.info.firstSlot.cmp(b.info.firstSlot) || a.info.lastSlot.cmp(b.info.lastSlot) || a.paraId.cmp(b.paraId)) - : null; +const PREFIX = stringToU8a('modlpy/cfund'); + +function hasLease (paraId: ParaId, leases: Lease[]): boolean { + const info = leases.find((l) => paraId.eq(l.paraId)); + + if (info) { + const paraAccountId = u8aConcat(PREFIX, paraId.toU8a()); + + return info.leases.some(({ accountId }) => + u8aEq(paraAccountId, accountId.toU8a().slice(0, paraAccountId.length)) + ); + } + + return false; +} + +// map into a campaign +function updateCampaign (bestNumber: BN, minContribution: BN, data: Campaign, leases: Lease[]): Campaign { + if (!data.isEnded && bestNumber.gt(data.info.end)) { + data.isEnded = true; + } + + if (!data.isCapped && data.info.cap.sub(data.info.raised).lt(minContribution)) { + data.isCapped = true; + } + + if (!data.isWinner && hasLease(data.paraId, leases)) { + data.isWinner = true; + } + + return data; +} + +function isFundUpdated (bestNumber: BlockNumber, minContribution: BN, { info: { cap, end, raised }, isCapped, isEnded, isWinner, paraId }: Campaign, leases: Lease[]): boolean { + return (!isEnded && bestNumber.gt(end)) || + (!isCapped && cap.sub(raised).lt(minContribution)) || + (!isWinner && hasLease(paraId, leases)); } // compare the current campaigns against the previous, manually adding ending and calculating the new totals -function createResult (bestNumber: BlockNumber, funds: Campaign[], prev: Result): Result { +function createResult (bestNumber: BlockNumber, minContribution: BN, funds: Campaign[], leases: Lease[], prev: Result): Result { const [activeRaised, activeCap, totalRaised, totalCap] = funds.reduce(([ar, ac, tr, tc], { info: { cap, end, raised } }) => [ bestNumber.gt(end) ? ar : ar.iadd(raised), bestNumber.gt(end) ? ac : ac.iadd(cap), @@ -53,7 +83,8 @@ function createResult (bestNumber: BlockNumber, funds: Campaign[], prev: Result) !prev.funds || prev.funds.length !== funds.length || hasNewActiveCap || hasNewActiveRaised || hasNewTotalCap || hasNewTotalRaised || - funds.some(({ info: { end }, isEnded }) => !isEnded && bestNumber.gt(end)); + funds.some((c) => isFundUpdated(bestNumber, minContribution, c, leases)) || + true; if (!hasChanged) { return prev; @@ -66,13 +97,23 @@ function createResult (bestNumber: BlockNumber, funds: Campaign[], prev: Result) activeRaised: hasNewActiveRaised ? activeRaised : prev.activeRaised, - funds: funds.map((data): Campaign => { - if (!data.isEnded && bestNumber.gt(data.info.end)) { - data.isEnded = true; - } - - return data; - }), + funds: funds + .map((c) => updateCampaign(bestNumber, minContribution, c, leases)) + .sort((a, b) => + a.isWinner !== b.isWinner + ? a.isWinner + ? 1 + : -1 + : a.isCapped !== b.isCapped + ? a.isCapped + ? 1 + : -1 + : a.isEnded !== b.isEnded + ? a.isEnded + ? 1 + : -1 + : 0 + ), totalCap: hasNewTotalCap ? totalCap : prev.totalCap, @@ -82,20 +123,37 @@ function createResult (bestNumber: BlockNumber, funds: Campaign[], prev: Result) }; } +const optFundMulti = { + transform: ([[paraIds], optFunds]: [[ParaId[]], Option[]]): Campaign[] => + paraIds + .map((paraId, i): [ParaId, FundInfo | null] => [paraId, optFunds[i].unwrapOr(null)]) + .filter((v): v is [ParaId, FundInfo] => !!v[1]) + .map(([paraId, info]) => ({ info, key: paraId.toString(), paraId })) + .sort((a, b) => a.info.firstSlot.cmp(b.info.firstSlot) || a.info.lastSlot.cmp(b.info.lastSlot) || a.paraId.cmp(b.paraId)), + withParamsTransform: true +}; + +const optLeaseMulti = { + transform: ([[paraIds], leases]: [[ParaId[]], Option>[][]]): Lease[] => + paraIds.map((paraId, i): Lease => ({ + leases: leases[i] + .map((o) => o.unwrapOr(null)) + .filter((v): v is ITuple<[AccountId, BalanceOf]> => !!v) + .map(([accountId, balance]) => ({ accountId, balance })), + paraId + })), + withParamsTransform: true +}; + export default function useFunds (): Result { const { api } = useApi(); const bestNumber = useBestNumber(); const [paraIds, setParaIds] = useState([]); const trigger = useEventTrigger([api.events.crowdloan.Created]); - const optFunds = useCallMulti[]>(paraIds.map((id) => [api.query.crowdloan.funds, id])); + const campaigns = useCall(api.query.crowdloan.funds.multi, [paraIds], optFundMulti); + const leases = useCall(api.query.slots.leases.multi, [paraIds], optLeaseMulti); const [result, setResult] = useState(EMPTY); - // we actually want to split this further info completed and ongoing - const campaigns = useMemo( - () => extractCampaigns(optFunds, paraIds), - [optFunds, paraIds] - ); - // on event triggers, update the available paraIds useEffect((): void => { trigger && @@ -109,10 +167,10 @@ export default function useFunds (): Result { // here we manually add the actual ending status and calculate the totals useEffect((): void => { - bestNumber && campaigns && setResult((prev) => - createResult(bestNumber, campaigns, prev) + bestNumber && campaigns && leases && setResult((prev) => + createResult(bestNumber, api.consts.crowdloan.minContribution as BlockNumber, campaigns, leases, prev) ); - }, [bestNumber, campaigns]); + }, [api, bestNumber, campaigns, leases]); return result; }