diff --git a/centrifuge-app/src/components/PoolChangesBanner.tsx b/centrifuge-app/src/components/PoolChangesBanner.tsx new file mode 100644 index 0000000000..9607820f49 --- /dev/null +++ b/centrifuge-app/src/components/PoolChangesBanner.tsx @@ -0,0 +1,41 @@ +import { Banner, Text } from '@centrifuge/fabric' +import * as React from 'react' +import { useLoanChanges, usePoolChanges } from '../utils/usePools' +import { RouterTextLink } from './TextLink' + +export type PoolChangesBannerProps = { + poolId: string +} +const STORAGE_KEY = 'poolChangesBannerDismissed' + +export function PoolChangesBanner({ poolId }: PoolChangesBannerProps) { + const poolChanges = usePoolChanges(poolId) + const { policyChanges } = useLoanChanges(poolId) + const [isOpen, setIsOpen] = React.useState(false) + + React.useEffect(() => { + const dismissed = !!sessionStorage.getItem(STORAGE_KEY) + const hasReady = policyChanges?.some((change) => change.status === 'ready') || poolChanges?.status === 'ready' + if (!dismissed && hasReady) { + setIsOpen(true) + } + }, [poolChanges, policyChanges]) + + function onClose() { + sessionStorage.setItem(STORAGE_KEY, '1') + setIsOpen(false) + } + + return ( + + There are pending pool changes that can now be enabled{' '} + here + + } + /> + ) +} diff --git a/centrifuge-app/src/pages/IssuerPool/Configuration/EpochAndTranches.tsx b/centrifuge-app/src/pages/IssuerPool/Configuration/EpochAndTranches.tsx index 8cf75fc5aa..1ecfa17f0d 100644 --- a/centrifuge-app/src/pages/IssuerPool/Configuration/EpochAndTranches.tsx +++ b/centrifuge-app/src/pages/IssuerPool/Configuration/EpochAndTranches.tsx @@ -1,6 +1,6 @@ import { CurrencyBalance, Perquintill, PoolMetadata, PoolMetadataInput, Rate } from '@centrifuge/centrifuge-js' -import { useCentrifugeTransaction } from '@centrifuge/centrifuge-react' -import { Button, Grid, NumberInput, Shelf, Stack, Text, Thumbnail } from '@centrifuge/fabric' +import { useCentrifugeConsts, useCentrifugeTransaction } from '@centrifuge/centrifuge-react' +import { Button, Grid, NumberInput, Shelf, Stack, StatusChip, Text, Thumbnail } from '@centrifuge/fabric' import { Form, FormikProvider, useFormik } from 'formik' import * as React from 'react' import { useParams } from 'react-router' @@ -12,7 +12,7 @@ import { LabelValueStack } from '../../../components/LabelValueStack' import { PageSection } from '../../../components/PageSection' import { formatBalance, formatPercentage } from '../../../utils/formatting' import { useSuitableAccounts } from '../../../utils/usePermissions' -import { useConstants, usePool, usePoolMetadata } from '../../../utils/usePools' +import { usePool, usePoolChanges, usePoolMetadata } from '../../../utils/usePools' import { TrancheInput } from '../../IssuerCreatePool/TrancheInput' import { validate } from '../../IssuerCreatePool/validate' @@ -25,7 +25,13 @@ export function EpochAndTranches() { const [isEditing, setIsEditing] = React.useState(false) const pool = usePool(poolId) const { data: metadata } = usePoolMetadata(pool) - const [account] = useSuitableAccounts({ poolId, poolRole: ['PoolAdmin'] }) + const [account] = useSuitableAccounts({ poolId, poolRole: ['PoolAdmin'], proxyType: ['Borrow'] }) + const changes = usePoolChanges(poolId) + + const { execute: executeApply, isLoading: isApplyLoading } = useCentrifugeTransaction( + 'Apply pool update', + (cent) => cent.pools.applyPoolUpdate + ) const columns: Column[] = [ { @@ -85,7 +91,7 @@ export function EpochAndTranches() { [pool, metadata] ) - const consts = useConstants() + const consts = useCentrifugeConsts() const epochHours = Math.floor((pool?.parameters.minEpochTime ?? 0) / 3600) const epochMinutes = Math.floor(((pool?.parameters.minEpochTime ?? 0) / 60) % 60) @@ -177,7 +183,7 @@ export function EpochAndTranches() { ] execute( [poolId, newPoolMetadata, { minEpochTime: epochSeconds, tranches: hasTrancheChanges ? tranches : undefined }], - { account } + { account, forceProxyType: 'Borrow' } ) actions.setSubmitting(false) }, @@ -198,7 +204,7 @@ export function EpochAndTranches() { const hasChanges = Object.entries(form.values).some(([k, v]) => (initialValues as any)[k] !== v) - const delay = consts?.minUpdateDelay ? consts.minUpdateDelay / (60 * 60 * 24) : null + const delay = consts.poolSystem.minUpdateDelay / (60 * 60 * 24) const trancheData = [...tranches].reverse() @@ -206,14 +212,15 @@ export function EpochAndTranches() {
+ Epoch and tranches{' '} + {changes && changes.status !== 'ready' && Pending changes} + } + subtitle={`Changes require ${ + delay < 0.5 ? `${Math.ceil(delay / 24)} hour(s)` : `${Math.round(delay)} day(s)` + } and no oustanding redeem orders before they can be enabled`} headerRight={ isEditing ? ( @@ -232,9 +239,22 @@ export function EpochAndTranches() { ) : ( - + + {changes?.status === 'ready' && ( + + )} + + ) } > diff --git a/centrifuge-app/src/pages/IssuerPool/Configuration/PendingLoanChanges.tsx b/centrifuge-app/src/pages/IssuerPool/Configuration/PendingLoanChanges.tsx deleted file mode 100644 index 983f077b09..0000000000 --- a/centrifuge-app/src/pages/IssuerPool/Configuration/PendingLoanChanges.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { useCentrifugeTransaction } from '@centrifuge/centrifuge-react' -import { Button, Shelf, Stack, Text } from '@centrifuge/fabric' -import { useLoanChanges, usePoolOrders } from '../../../utils/usePools' - -const POOL_CHANGE_DELAY = 1000 * 60 * 60 * 24 * 7 // Currently hard-coded to 1 week on chain, will probably change to a constant we can query - -// Currently only showing write-off policy changes -export function PendingLoanChanges({ poolId }: { poolId: string }) { - const poolOrders = usePoolOrders(poolId) - const hasLockedRedemptions = (poolOrders?.reduce((acc, cur) => acc + cur.activeRedeem.toFloat(), 0) ?? 0) > 0 - const loanChanges = useLoanChanges(poolId) - const policyChanges = loanChanges?.filter(({ change }) => !!change.loan?.policy?.length) - - const { - execute: executeApply, - isLoading: isApplyLoading, - lastCreatedTransaction, - } = useCentrifugeTransaction('Apply write-off policy', (cent) => cent.pools.applyWriteOffPolicyUpdate) - - return ( - - {policyChanges?.map((policy) => { - const waitingPeriodDone = new Date(policy.submittedAt).getTime() + POOL_CHANGE_DELAY < Date.now() - - return ( - - Pending policy update - - {!waitingPeriodDone - ? 'In waiting period' - : hasLockedRedemptions - ? 'Blocked by locked redemptions' - : 'Can be applied'} - - - - ) - })} - - ) -} diff --git a/centrifuge-app/src/pages/IssuerPool/Configuration/WriteOffGroups.tsx b/centrifuge-app/src/pages/IssuerPool/Configuration/WriteOffGroups.tsx index 70692053b6..b8e4e7118a 100644 --- a/centrifuge-app/src/pages/IssuerPool/Configuration/WriteOffGroups.tsx +++ b/centrifuge-app/src/pages/IssuerPool/Configuration/WriteOffGroups.tsx @@ -1,6 +1,6 @@ import { Rate, WriteOffGroup } from '@centrifuge/centrifuge-js' -import { useCentrifugeTransaction } from '@centrifuge/centrifuge-react' -import { Box, Button, Stack } from '@centrifuge/fabric' +import { useCentrifugeConsts, useCentrifugeTransaction } from '@centrifuge/centrifuge-react' +import { Box, Button, Shelf, Stack, StatusChip } from '@centrifuge/fabric' import { FieldArray, Form, FormikErrors, FormikProvider, setIn, useFormik } from 'formik' import * as React from 'react' import { useParams } from 'react-router' @@ -9,8 +9,7 @@ import { Column, DataTable } from '../../../components/DataTable' import { PageSection } from '../../../components/PageSection' import { formatPercentage } from '../../../utils/formatting' import { useSuitableAccounts } from '../../../utils/usePermissions' -import { useConstants, useWriteOffGroups } from '../../../utils/usePools' -import { PendingLoanChanges } from './PendingLoanChanges' +import { useLoanChanges, useWriteOffGroups } from '../../../utils/usePools' import { WriteOffInput } from './WriteOffInput' export type Row = WriteOffGroup @@ -53,8 +52,15 @@ export type WriteOffGroupValues = { writeOffGroups: WriteOffGroupInput[] } export function WriteOffGroups() { const { pid: poolId } = useParams<{ pid: string }>() const [isEditing, setIsEditing] = React.useState(false) - const consts = useConstants() - const [account] = useSuitableAccounts({ poolId, poolRole: ['LoanAdmin'] }) + const consts = useCentrifugeConsts() + const [account] = useSuitableAccounts({ poolId, poolRole: ['PoolAdmin'] }) + const { policyChanges } = useLoanChanges(poolId) + const latestPolicyChange = policyChanges?.at(-1) + + const { execute: executeApply, isLoading: isApplyLoading } = useCentrifugeTransaction( + 'Apply write-off policy', + (cent) => cent.pools.applyWriteOffPolicyUpdate + ) const savedGroups = useWriteOffGroups(poolId) const sortedSavedGroups = [...(savedGroups ?? [])].sort((a, b) => a.overdueDays - b.overdueDays) @@ -160,7 +166,7 @@ export function WriteOffGroups() { }} small key="edit" - disabled={form.values.writeOffGroups.length >= (consts?.maxWriteOffPolicySize ?? 5) || !account} + disabled={form.values.writeOffGroups.length >= consts.loans.maxWriteOffPolicySize || !account} > Add another @@ -172,7 +178,14 @@ export function WriteOffGroups() { + Write-off policy{' '} + {latestPolicyChange && latestPolicyChange.status !== 'ready' && ( + Pending changes + )} + + } subtitle="At least one write-off activity is required" headerRight={ <> @@ -186,15 +199,29 @@ export function WriteOffGroups() { small loading={isLoading || form.isSubmitting} loadingMessage={isLoading || form.isSubmitting ? 'Pending...' : undefined} + disabled={!account} key="done" > Done ) : ( - + + {latestPolicyChange?.status === 'ready' && ( + + )} + + )} } @@ -208,7 +235,6 @@ export function WriteOffGroups() { ) : ( )} - diff --git a/centrifuge-app/src/pages/IssuerPool/Configuration/index.tsx b/centrifuge-app/src/pages/IssuerPool/Configuration/index.tsx index baad4393f9..79985e7cf4 100644 --- a/centrifuge-app/src/pages/IssuerPool/Configuration/index.tsx +++ b/centrifuge-app/src/pages/IssuerPool/Configuration/index.tsx @@ -4,7 +4,7 @@ import { useDebugFlags } from '../../../components/DebugFlags' import { LoadBoundary } from '../../../components/LoadBoundary' import { PageWithSideBar } from '../../../components/PageWithSideBar' import { PendingMultisigs } from '../../../components/PendingMultisigs' -import { usePoolAdmin } from '../../../utils/usePermissions' +import { useCanBorrow, usePoolAdmin } from '../../../utils/usePermissions' import { IssuerPoolHeader } from '../Header' import { Details } from './Details' import { EpochAndTranches } from './EpochAndTranches' @@ -28,10 +28,12 @@ export function IssuerPoolConfigurationPage() { function IssuerPoolConfiguration() { const { pid: poolId } = useParams<{ pid: string }>() const { editPoolConfig } = useDebugFlags() + const isPoolAdmin = !!usePoolAdmin(poolId) + const isBorrower = useCanBorrow(poolId) return ( - {!!usePoolAdmin(poolId) && ( + {(isPoolAdmin || isBorrower) && ( <>
diff --git a/centrifuge-app/src/pages/IssuerPool/index.tsx b/centrifuge-app/src/pages/IssuerPool/index.tsx index 19db03f840..9678d3f3b6 100644 --- a/centrifuge-app/src/pages/IssuerPool/index.tsx +++ b/centrifuge-app/src/pages/IssuerPool/index.tsx @@ -1,5 +1,6 @@ import * as React from 'react' -import { Route, Switch, useRouteMatch } from 'react-router' +import { Route, Switch, useParams, useRouteMatch } from 'react-router' +import { PoolChangesBanner } from '../../components/PoolChangesBanner' import { IssuerPoolAccessPage } from './Access' import { IssuerPoolAssetPage } from './Assets' import { IssuerPoolConfigurationPage } from './Configuration' @@ -10,20 +11,24 @@ import { IssuerPoolLiquidityPage } from './Liquidity' import { IssuerPoolOverviewPage } from './Overview' import { IssuerPoolReportingPage } from './Reporting' -export const IssuerPoolPage: React.FC = () => { +export function IssuerPoolPage() { const { path } = useRouteMatch() + const { pid: poolId } = useParams<{ pid: string }>() return ( - - - - - - - - - - - + <> + + + + + + + + + + + + + ) } diff --git a/centrifuge-app/src/utils/usePools.ts b/centrifuge-app/src/utils/usePools.ts index 116ec7bfb9..cfa343ab62 100644 --- a/centrifuge-app/src/utils/usePools.ts +++ b/centrifuge-app/src/utils/usePools.ts @@ -1,7 +1,7 @@ import Centrifuge, { BorrowerTransaction, Loan, Pool, PoolMetadata } from '@centrifuge/centrifuge-js' -import { useCentrifuge, useCentrifugeQuery, useWallet } from '@centrifuge/centrifuge-react' +import { useCentrifugeConsts, useCentrifugeQuery, useWallet } from '@centrifuge/centrifuge-react' import BN from 'bn.js' -import { useEffect } from 'react' +import { useEffect, useMemo } from 'react' import { useQuery } from 'react-query' import { combineLatest, map, Observable } from 'rxjs' import { Dec } from './Decimal' @@ -239,43 +239,73 @@ export function usePoolMetadata( return typeof pool?.metadata === 'string' ? data : tinlakeData } -export function useConstants() { - const centrifuge = useCentrifuge() - const { data } = useQuery( - ['constants'], - async () => { - const api = await centrifuge.getApiPromise() - return { - minUpdateDelay: Number(api.consts.poolSystem.minUpdateDelay.toHuman()), - maxTranches: Number(api.consts.poolSystem.maxTranches.toHuman()), - challengeTime: Number(api.consts.poolSystem.challengeTime.toHuman()), - maxWriteOffPolicySize: Number(api.consts.loans.maxWriteOffPolicySize.toHuman()), - } - }, - { - staleTime: Infinity, - } - ) - - return data -} - export function useWriteOffGroups(poolId: string) { const [result] = useCentrifugeQuery(['writeOffGroups', poolId], (cent) => cent.pools.getWriteOffPolicy([poolId])) return result } +const POOL_CHANGE_DELAY = 1000 * 60 * 60 * 24 * 7 // Currently hard-coded to 1 week on chain, will probably change to a constant we can query + export function useLoanChanges(poolId: string) { + const poolOrders = usePoolOrders(poolId) + const [result] = useCentrifugeQuery(['loanChanges', poolId], (cent) => cent.pools.getProposedLoanChanges([poolId])) - return result + const policyChanges = useMemo(() => { + const hasLockedRedemptions = (poolOrders?.reduce((acc, cur) => acc + cur.activeRedeem.toFloat(), 0) ?? 0) > 0 + + return result + ?.filter(({ change }) => !!change.loan?.policy?.length) + .map((policy) => { + const waitingPeriodDone = new Date(policy.submittedAt).getTime() + POOL_CHANGE_DELAY < Date.now() + return { + ...policy, + status: !waitingPeriodDone + ? ('waiting' as const) + : hasLockedRedemptions + ? ('blocked' as const) + : ('ready' as const), + } + }) + }, [poolOrders, result]) + + return { policyChanges } } export function usePoolChanges(poolId: string) { + const pool = usePool(poolId) + const poolOrders = usePoolOrders(poolId) + const consts = useCentrifugeConsts() const [result] = useCentrifugeQuery(['poolChanges', poolId], (cent) => cent.pools.getProposedPoolChanges([poolId])) - return result + return useMemo( + () => { + if (!result) return result + const submittedTime = new Date(result.submittedAt).getTime() + const waitingPeriodDone = submittedTime + consts.poolSystem.minUpdateDelay * 1000 < Date.now() + const hasLockedRedemptions = (poolOrders?.reduce((acc, cur) => acc + cur.activeRedeem.toFloat(), 0) ?? 0) > 0 + const isEpochOngoing = pool.epoch.status === 'ongoing' + const epochNeedsClosing = submittedTime > new Date(pool.epoch.lastClosed).getTime() + return { + ...result, + status: !waitingPeriodDone + ? ('waiting' as const) + : hasLockedRedemptions || !isEpochOngoing || epochNeedsClosing + ? ('blocked' as const) + : ('ready' as const), + blockedBy: epochNeedsClosing + ? ('epochNeedsClosing' as const) + : hasLockedRedemptions + ? ('redemptions' as const) + : !isEpochOngoing + ? ('epochIsClosing' as const) + : null, + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [result, poolOrders, pool] + ) } export function usePodUrl(poolId: string) { diff --git a/centrifuge-js/src/modules/pools.ts b/centrifuge-js/src/modules/pools.ts index 08c96dc096..1a705cd6a1 100644 --- a/centrifuge-js/src/modules/pools.ts +++ b/centrifuge-js/src/modules/pools.ts @@ -2899,15 +2899,17 @@ export function getPoolsModule(inst: Centrifuge) { switchMap((api) => api.query.poolSystem.scheduledUpdate(poolId)), map((updateData) => { const update = updateData.toPrimitive() as any - if (!update) return null + if (!update?.changes) return null + const { changes, submittedAt } = update + return { changes: { - tranches: update.tranches.noChange === null ? null : update.tranches.newValue, - trancheMetadata: update.trancheMetadata.noChange === null ? null : update.trancheMetadata.newValue, - minEpochTime: update.minEpochTime.noChange === null ? null : update.minEpochTime.newValue, - maxNavAge: update.maxNavAge.noChange === null ? null : update.maxNavAge.newValue, + tranches: changes.tranches.noChange === null ? null : changes.tranches.newValue, + trancheMetadata: changes.trancheMetadata.noChange === null ? null : changes.trancheMetadata.newValue, + minEpochTime: changes.minEpochTime.noChange === null ? null : changes.minEpochTime.newValue, + maxNavAge: changes.maxNavAge.noChange === null ? null : changes.maxNavAge.newValue, }, - submittedAt: new Date(update.submittedAt * 1000).toISOString(), + submittedAt: new Date(submittedAt * 1000).toISOString(), } }) ) diff --git a/centrifuge-react/src/components/CentrifugeProvider/CentrifugeProvider.tsx b/centrifuge-react/src/components/CentrifugeProvider/CentrifugeProvider.tsx index ccc121fa42..79b8112684 100644 --- a/centrifuge-react/src/components/CentrifugeProvider/CentrifugeProvider.tsx +++ b/centrifuge-react/src/components/CentrifugeProvider/CentrifugeProvider.tsx @@ -70,6 +70,7 @@ export function useCentrifugeConsts() { .add(depositPerByte.mul(new BN(LOAN_NFT_DATA_BYTES))), chainDecimals ), + maxWriteOffPolicySize: Number(api.consts.loans.maxWriteOffPolicySize.toPrimitive()), }, proxy: { proxyDepositBase: new CurrencyBalance(consts.proxy.proxyDepositBase, chainDecimals), @@ -84,6 +85,9 @@ export function useCentrifugeConsts() { }, poolSystem: { poolDeposit: new CurrencyBalance(consts.poolSystem.poolDeposit, chainDecimals), + minUpdateDelay: Number(api.consts.poolSystem.minUpdateDelay.toPrimitive()), + maxTranches: Number(api.consts.poolSystem.maxTranches.toPrimitive()), + challengeTime: Number(api.consts.poolSystem.challengeTime.toPrimitive()), }, keystore: { keyDeposit: CurrencyBalance.fromFloat(100, chainDecimals),