From df6cf97079a8176344350202c591567c7d904aee Mon Sep 17 00:00:00 2001 From: Katty Barroso <51223655+kattylucy@users.noreply.github.com> Date: Wed, 18 Dec 2024 12:14:21 +0100 Subject: [PATCH] Create pool redesign - feature branch (#2537) * Fabric changes * Type updates * Add new create pool index * Add pool structure section * Fix sidebar * Add pool details section * Add pool setup section * Minor UI fix * Pool structure - create pool (#2543) * Add pool structure UI changes * Small UI fix * Avoid deletion on entries if less than one * Add logic to single / multi sign * Fix linter errors * Create pool - functionality (#2545) * Fix ts error and change logic for onboarding values * Add create pool existing functionality * Cleanup types * cleanup * Add deposit banner * Fix linter errors * Add metadata values * Cleanup types * Add onboarding functionality and UI fixes * Add proxies functionality * Fix ts errors * Add create pool dialog * Add dialogs * Add review feedback * wip * Add waiting before redirecting to avoid error * Remove default empty pool fee * Create pool bugs and fixes (#2550) * Bug fixes and add proposal link * Fix ratings creating empty value * Update fabric/src/theme/tokens/colors.ts Co-authored-by: Sophia * Update centrifuge-app/src/pages/IssuerCreatePool/IssuerCategories.tsx Co-authored-by: Sophia * Update centrifuge-app/src/pages/IssuerCreatePool/PoolSetupSection.tsx Co-authored-by: Sophia * Add feedback * feedback * Feedback changes * Add feeback * Fix tranches apy APY should be based on junior token * Add feeback * Fix placeholder color * Replace file upload with image upload * Bug fixes * Code review feedback * Add jay's feedback and cleanup * Update centrifuge-app/src/components/Menu/IssuerMenu.tsx Co-authored-by: Onno Visser * Update centrifuge-app/src/pages/IssuerCreatePool/validate.ts Co-authored-by: Onno Visser * Update centrifuge-app/src/pages/IssuerCreatePool/validate.ts Co-authored-by: Onno Visser * Rebase issue --------- Co-authored-by: Sophia Co-authored-by: Onno Visser --- .../src/components/LayoutBase/styles.tsx | 4 +- .../src/components/Menu/IssuerMenu.tsx | 14 +- .../src/components/Menu/PoolLink.tsx | 2 +- centrifuge-app/src/components/Menu/Toggle.tsx | 2 +- centrifuge-app/src/components/Menu/index.tsx | 28 +- .../src/components/PoolCard/index.tsx | 2 +- .../PoolOverview/TrancheTokenCards.tsx | 7 +- .../PoolOverview/TransactionHistory.tsx | 4 +- centrifuge-app/src/components/Tooltips.tsx | 32 + .../pages/IssuerCreatePool/AdminMultisig.tsx | 2 +- .../IssuerCreatePool/FormAddressInput.tsx | 48 + .../IssuerCreatePool/IssuerCategories.tsx | 113 ++ .../pages/IssuerCreatePool/IssuerInput.tsx | 3 +- .../IssuerCreatePool/PoolDetailsSection.tsx | 329 ++++++ .../pages/IssuerCreatePool/PoolRatings.tsx | 89 ++ .../IssuerCreatePool/PoolSetupSection.tsx | 454 +++++++++ .../IssuerCreatePool/PoolStructureSection.tsx | 443 ++++++++ .../pages/IssuerCreatePool/TrancheInput.tsx | 4 +- .../src/pages/IssuerCreatePool/index.tsx | 962 +++++++----------- .../src/pages/IssuerCreatePool/types.ts | 133 +++ .../src/pages/IssuerCreatePool/utils.ts | 32 + .../src/pages/IssuerCreatePool/validate.ts | 156 ++- .../IssuerPool/Configuration/AddressInput.tsx | 0 .../IssuerPool/Configuration/Details.tsx | 6 +- .../pages/IssuerPool/Configuration/Issuer.tsx | 2 +- .../src/pages/Pool/Assets/index.tsx | 5 +- centrifuge-js/src/modules/pools.ts | 162 +-- fabric/src/components/Checkbox/index.tsx | 84 +- fabric/src/components/Dialog/index.tsx | 16 +- fabric/src/components/FileUpload/index.tsx | 160 +-- fabric/src/components/ImageUpload/index.tsx | 29 +- fabric/src/components/InputUnit/index.tsx | 4 +- fabric/src/components/RadioButton/index.tsx | 21 + fabric/src/components/Stepper/index.tsx | 170 ++-- fabric/src/components/TextInput/index.tsx | 64 +- fabric/src/components/Toast/index.tsx | 2 +- fabric/src/icon-svg/icon-chevron-down.svg | 2 +- fabric/src/icon-svg/icon-upload.svg | 11 +- fabric/src/theme/tokens/colors.ts | 4 +- fabric/src/theme/tokens/theme.ts | 2 +- 40 files changed, 2654 insertions(+), 953 deletions(-) create mode 100644 centrifuge-app/src/pages/IssuerCreatePool/FormAddressInput.tsx create mode 100644 centrifuge-app/src/pages/IssuerCreatePool/IssuerCategories.tsx create mode 100644 centrifuge-app/src/pages/IssuerCreatePool/PoolDetailsSection.tsx create mode 100644 centrifuge-app/src/pages/IssuerCreatePool/PoolRatings.tsx create mode 100644 centrifuge-app/src/pages/IssuerCreatePool/PoolSetupSection.tsx create mode 100644 centrifuge-app/src/pages/IssuerCreatePool/PoolStructureSection.tsx create mode 100644 centrifuge-app/src/pages/IssuerCreatePool/types.ts create mode 100644 centrifuge-app/src/pages/IssuerCreatePool/utils.ts create mode 100644 centrifuge-app/src/pages/IssuerPool/Configuration/AddressInput.tsx diff --git a/centrifuge-app/src/components/LayoutBase/styles.tsx b/centrifuge-app/src/components/LayoutBase/styles.tsx index 9faef617e6..74e6f158b8 100644 --- a/centrifuge-app/src/components/LayoutBase/styles.tsx +++ b/centrifuge-app/src/components/LayoutBase/styles.tsx @@ -199,8 +199,8 @@ export const ContentWrapper = styled.div` @media (min-width: ${({ theme }) => theme.breakpoints['M']}) and (max-width: ${({ theme }) => theme.breakpoints['L']}) { - margin-left: 7vw; - width: calc(100% - 7vw); + margin-left: 6vw; + width: calc(100% - 6vw); margin-top: 10px; } diff --git a/centrifuge-app/src/components/Menu/IssuerMenu.tsx b/centrifuge-app/src/components/Menu/IssuerMenu.tsx index 21398bff3f..6e9c3be433 100644 --- a/centrifuge-app/src/components/Menu/IssuerMenu.tsx +++ b/centrifuge-app/src/components/Menu/IssuerMenu.tsx @@ -1,7 +1,7 @@ import { Box, IconChevronDown, IconChevronRight, IconUser, Menu as Panel, Stack } from '@centrifuge/fabric' import * as React from 'react' import { useMatch } from 'react-router' -import { useTheme } from 'styled-components' +import styled, { useTheme } from 'styled-components' import { useIsAboveBreakpoint } from '../../utils/useIsAboveBreakpoint' import { Toggle } from './Toggle' @@ -11,6 +11,12 @@ type IssuerMenuProps = { children?: React.ReactNode } +const StyledPanel = styled(Panel)` + & > div { + max-height: 50vh; + } +` + export function IssuerMenu({ defaultOpen = false, children }: IssuerMenuProps) { const match = useMatch('/issuer/*') const isActive = !!match @@ -56,9 +62,9 @@ export function IssuerMenu({ defaultOpen = false, children }: IssuerMenuProps) { Issuer {isLarge && (open ? ( - + ) : ( - + ))} @@ -81,7 +87,7 @@ export function IssuerMenu({ defaultOpen = false, children }: IssuerMenuProps) { {children} ) : ( - {children} + {children} )} diff --git a/centrifuge-app/src/components/Menu/PoolLink.tsx b/centrifuge-app/src/components/Menu/PoolLink.tsx index e3eac011a4..517da0732d 100644 --- a/centrifuge-app/src/components/Menu/PoolLink.tsx +++ b/centrifuge-app/src/components/Menu/PoolLink.tsx @@ -33,7 +33,7 @@ export function PoolLink({ pool, path = 'issuer' }: PoolLinkProps) { prefetchRoute(to)} > diff --git a/centrifuge-app/src/components/Menu/Toggle.tsx b/centrifuge-app/src/components/Menu/Toggle.tsx index 8737377fe0..08838a5cfa 100644 --- a/centrifuge-app/src/components/Menu/Toggle.tsx +++ b/centrifuge-app/src/components/Menu/Toggle.tsx @@ -10,7 +10,7 @@ export const Toggle = styled(Text)<{ isActive?: boolean; stacked?: boolean }>` width: 100%; grid-template-columns: ${({ stacked, theme }) => stacked ? '1fr' : `${theme.sizes.iconSmall}px 1fr ${theme.sizes.iconSmall}px`}; - color: ${({ isActive, theme }) => (isActive ? theme.colors.textGold : theme.colors.textInverted)}; + color: ${({ theme }) => theme.colors.textInverted}; border-radius: 4px; background-color: ${({ isActive }) => (isActive ? LIGHT_BACKGROUND : 'transparent')}; diff --git a/centrifuge-app/src/components/Menu/index.tsx b/centrifuge-app/src/components/Menu/index.tsx index c56bf79d1b..5fe10a79d2 100644 --- a/centrifuge-app/src/components/Menu/index.tsx +++ b/centrifuge-app/src/components/Menu/index.tsx @@ -4,12 +4,14 @@ import { IconGlobe, IconInvestments, IconNft, + IconPlus, IconSwitch, IconWallet, MenuItemGroup, Shelf, Stack, } from '@centrifuge/fabric' +import styled from 'styled-components' import { config } from '../../config' import { useAddress } from '../../utils/useAddress' import { useIsAboveBreakpoint } from '../../utils/useIsAboveBreakpoint' @@ -23,6 +25,28 @@ import { NavManagementMenu } from './NavManagementMenu' import { PageLink } from './PageLink' import { PoolLink } from './PoolLink' +const COLOR = '#7C8085' + +const StyledRouterLinkButton = styled(RouterLinkButton)` + width: 100%; + & > span { + background-color: ${COLOR}; + border-color: transparent; + color: white; + margin-bottom: 20px; + + &:hover { + box-shadow: 0px 0px 0px 3px #7c8085b3; + background-color: ${COLOR}; + color: white; + } + + &:active { + border-color: transparent; + } + } +` + export function Menu() { const pools = usePoolsThatAnyConnectedAddressHasPermissionsFor() || [] const isLarge = useIsAboveBreakpoint('L') @@ -130,8 +154,8 @@ export function Menu() { function CreatePool() { return ( - + } to="/issuer/create-pool" small variant="inverted"> Create pool - + ) } diff --git a/centrifuge-app/src/components/PoolCard/index.tsx b/centrifuge-app/src/components/PoolCard/index.tsx index 82b4109a3b..a68974abc1 100644 --- a/centrifuge-app/src/components/PoolCard/index.tsx +++ b/centrifuge-app/src/components/PoolCard/index.tsx @@ -199,7 +199,7 @@ export function PoolCard({ } }) .reverse() - }, [isTinlakePool, metaData?.tranches, tinlakeKey, tranches]) + }, [isTinlakePool, metaData?.tranches, tinlakeKey, tranches, createdAt, poolId]) return ( diff --git a/centrifuge-app/src/components/PoolOverview/TrancheTokenCards.tsx b/centrifuge-app/src/components/PoolOverview/TrancheTokenCards.tsx index be41ed4ab6..927b584c4c 100644 --- a/centrifuge-app/src/components/PoolOverview/TrancheTokenCards.tsx +++ b/centrifuge-app/src/components/PoolOverview/TrancheTokenCards.tsx @@ -42,9 +42,6 @@ export const TrancheTokenCards = ({ return 'mezzanine' } - const getTarget = (tranche: Token) => - (isTinlakePool && tranche.seniority === 0) || poolId === DYF_POOL_ID || poolId === NS3_POOL_ID - const columns = useMemo(() => { return [ { @@ -121,6 +118,8 @@ export const TrancheTokenCards = ({ }, [pool.tranches, metadata, poolId, pool?.currency.symbol]) const dataTable = useMemo(() => { + const getTarget = (tranche: Token) => + (isTinlakePool && tranche.seniority === 0) || poolId === DYF_POOL_ID || poolId === NS3_POOL_ID return trancheTokens.map((tranche) => { const calculateApy = (trancheToken: Token) => { if (isTinlakePool && getTrancheText(trancheToken) === 'senior') return formatPercentage(trancheToken.apy) @@ -145,7 +144,7 @@ export const TrancheTokenCards = ({ isTarget: getTarget(tranche), } }) - }, [trancheTokens, getTarget]) + }, [trancheTokens, daysSinceCreation, isTinlakePool, poolId]) return ( diff --git a/centrifuge-app/src/components/PoolOverview/TransactionHistory.tsx b/centrifuge-app/src/components/PoolOverview/TransactionHistory.tsx index 779cb85df0..27a5227492 100644 --- a/centrifuge-app/src/components/PoolOverview/TransactionHistory.tsx +++ b/centrifuge-app/src/components/PoolOverview/TransactionHistory.tsx @@ -292,7 +292,7 @@ export const TransactionHistoryTable = ({ View all )} - {transactions?.length && ( + {transactions?.length ? ( Download - )} + ) : null} diff --git a/centrifuge-app/src/components/Tooltips.tsx b/centrifuge-app/src/components/Tooltips.tsx index 67c170adbe..0bca55ddcf 100644 --- a/centrifuge-app/src/components/Tooltips.tsx +++ b/centrifuge-app/src/components/Tooltips.tsx @@ -346,6 +346,38 @@ export const tooltipText = { label: 'Total NAV', body: 'Total nav minus accrued fees', }, + oneTranche: { + label: '', + body: 'This pool will only have one investment class where all investors share the same level of risk and return.', + }, + twoTranches: { + label: '', + body: 'This pool will have two classes. Senior tranche which has priority in receiving returns. And Junior tranche which is the last to receive returns (after Senior tranche obligations are met) but receives higher yield as compensation for the higher risk.', + }, + threeTranches: { + label: '', + body: 'This pool will have three classes. Senior tranche is the safest tranche with priority in repayment. Mezzanine tranche has intermediate risk and receives payment after Senior tranche obligations are met. Junior tranche which only receives returns after both Senior and Mezzanine tranches are paid.', + }, + singleMultisign: { + label: '', + body: 'Setup a wallet where only one private key is required to authorise changes to the pool configuration.', + }, + multiMultisign: { + label: '', + body: 'Setup a wallet that requires multiple private keys to authorise changes to the pool configuration.', + }, + centrifugeOnboarding: { + label: '', + body: 'Investors will go through the Centrifuge onboarding provider, Shuftipro, before they can invest in your pool.', + }, + externalOnboarding: { + label: '', + body: 'You can select the provider you want to KYC/onboard your investors.', + }, + noneOnboarding: { + label: '', + body: 'You can directly whitelist the addresses that can invest in the pool.', + }, } export type TooltipsProps = { diff --git a/centrifuge-app/src/pages/IssuerCreatePool/AdminMultisig.tsx b/centrifuge-app/src/pages/IssuerCreatePool/AdminMultisig.tsx index 68476ce014..5ac4c2de4e 100644 --- a/centrifuge-app/src/pages/IssuerCreatePool/AdminMultisig.tsx +++ b/centrifuge-app/src/pages/IssuerCreatePool/AdminMultisig.tsx @@ -1,9 +1,9 @@ import { useWallet } from '@centrifuge/centrifuge-react' import { Button } from '@centrifuge/fabric' import { useFormikContext } from 'formik' -import { CreatePoolValues } from '.' import { PageSection } from '../../components/PageSection' import { MultisigForm } from '../IssuerPool/Access/MultisigForm' +import { CreatePoolValues } from './types' export function AdminMultisigSection() { const form = useFormikContext() diff --git a/centrifuge-app/src/pages/IssuerCreatePool/FormAddressInput.tsx b/centrifuge-app/src/pages/IssuerCreatePool/FormAddressInput.tsx new file mode 100644 index 0000000000..16467788b8 --- /dev/null +++ b/centrifuge-app/src/pages/IssuerCreatePool/FormAddressInput.tsx @@ -0,0 +1,48 @@ +import { evmToSubstrateAddress } from '@centrifuge/centrifuge-js' +import { useCentrifugeUtils } from '@centrifuge/centrifuge-react' +import { TextInput } from '@centrifuge/fabric' +import { useField } from 'formik' +import React from 'react' +import { FieldWithErrorMessage } from '../../../src/components/FieldWithErrorMessage' +import { isEvmAddress } from '../../../src/utils/address' +import { truncate } from '../../utils/web3' + +interface FormAddressInputProps { + name: string + chainId?: number + placeholder?: string +} + +export const FormAddressInput = ({ name, chainId, placeholder }: FormAddressInputProps) => { + const [field, meta, helpers] = useField(name) + const utils = useCentrifugeUtils() + + let truncated: string | undefined + try { + truncated = truncate(utils.formatAddress(field.value)) + } catch (e) { + truncated = undefined + } + + function handleBlur() { + helpers.setTouched(true) + + if (!truncated || meta.error) { + helpers.setError('Invalid address') + return + } + + helpers.setValue(isEvmAddress(field.value) ? evmToSubstrateAddress(field.value, chainId ?? 0) : field.value) + } + + return ( + ) => helpers.setValue(e.target.value)} + onBlur={handleBlur} + errorMessage={meta.touched && meta.error ? meta.error : undefined} + as={TextInput} + /> + ) +} diff --git a/centrifuge-app/src/pages/IssuerCreatePool/IssuerCategories.tsx b/centrifuge-app/src/pages/IssuerCreatePool/IssuerCategories.tsx new file mode 100644 index 0000000000..c797f437ad --- /dev/null +++ b/centrifuge-app/src/pages/IssuerCreatePool/IssuerCategories.tsx @@ -0,0 +1,113 @@ +import { PoolMetadataInput } from '@centrifuge/centrifuge-js' +import { Box, Grid, IconButton, IconTrash, Select, Text, TextInput } from '@centrifuge/fabric' +import { Field, FieldArray, FieldProps, useFormikContext } from 'formik' +import { FieldWithErrorMessage } from '../../../src/components/FieldWithErrorMessage' +import { AddButton } from './PoolDetailsSection' +import { StyledGrid } from './PoolStructureSection' + +const PROVIDERS = [ + { label: 'Please select...', value: '' }, + { label: 'Fund admin', value: 'fundAdmin' }, + { label: 'Trustee', value: 'trustee' }, + { label: 'Pricing oracle provider', value: 'pricingOracleProvider' }, + { label: 'Auditor', value: 'auditor' }, + { label: 'Custodian', value: 'custodian' }, + { label: 'Investment manager', value: 'investmentManager' }, + { label: 'Sub-advisor', value: 'subadvisor' }, + { label: 'Historical default rate', value: 'historicalDefaultRate' }, + { label: 'Other', value: 'other' }, +] + +export const LabelWithDeleteButton = ({ + onDelete, + hideButton, + label, +}: { + onDelete: () => void + hideButton: boolean + label: string +}) => { + return ( + + {label} + {!hideButton && ( + + + + )} + + ) +} + +export const IssuerCategoriesSection = () => { + const form = useFormikContext() + return ( + + Service providers + + + {({ push, remove }) => ( + <> + {form.values.issuerCategories.map((category, index) => ( + <> + + + {({ field, meta }: FieldProps) => ( + Pool type*} />} + onChange={(event) => form.setFieldValue('poolType', event.target.value)} + onBlur={field.onBlur} + errorMessage={meta.touched && meta.error ? meta.error : undefined} + value={field.value} + options={[ + { label: 'Open', value: 'open' }, + { label: 'Closed', value: 'closed' }, + ]} + placeholder="Select..." + /> + )} + + + + + + Issuer + + + + {({ field, meta, form }: FieldProps) => ( + + Legal name of the issuer*} /> + } + onChange={(event: any) => form.setFieldValue('issuerName', event.target.value)} + onBlur={field.onBlur} + value={field.value} + as={TextInput} + placeholder="Type here..." + maxLength={100} + /> + {meta.touched ? ( + {meta.error} + ) : null} + + )} + + + {({ field, meta, form }: FieldProps) => ( + form.setFieldValue('issuerLogo', file)} + accept="image/png, image/jpeg, image/jpg" + placeholder="SVG, PNG, or JPG (max. 1MB; 480x480px)" + label="Issuer logo" + id="issuerLogo" + height={144} + /> + )} + + + + + {({ field, meta, form }: FieldProps) => ( + Legal name of the issuer representative} + /> + } + onChange={(event: any) => form.setFieldValue('issuerRepName', event.target.value)} + value={field.value} + as={TextInput} + placeholder="Type here..." + maxLength={100} + /> + )} + + + {({ field, meta, form }: FieldProps) => ( + form.setFieldValue('issuerShortDescription', event.target.value)} + onBlur={field.onBlur} + errorMessage={meta.touched && meta.error ? meta.error : undefined} + value={field.value} + as={TextAreaInput} + placeholder="Type here..." + maxLength={100} + /> + )} + + + + + {({ field, meta, form }: FieldProps) => ( + + )} + + + + + + + + {({ field, meta, form }: FieldProps) => ( + { + form.setFieldTouched('executiveSummary', true, false) + form.setFieldValue('executiveSummary', file) + }} + accept="application/pdf" + label={createLabel('Executive summary PDF')} + placeholder="Choose file" + onClear={() => form.setFieldValue(`executiveSummary`, null)} + small + /> + )} + + + + ) => + form.setFieldValue('details', { + ...form.values.details, + title: e.target.value, + }) + } + /> + + ) => + form.setFieldValue('details', { + ...form.values.details, + body: e.target.value, + }) + } + /> + + (Max 3000 characters) + + + + + + {/* service providers section */} + + + {/* pool ratings section */} + + + + Pool analysis + + + + + + + {({ field, meta, form }: FieldProps) => ( + { + form.setFieldValue('reportAuthorAvatar', file) + }} + label="Reviewer avatar" + placeholder="Choose file" + accept="image/png, image/jpeg, image/jpg" + onClear={() => form.setFieldValue('reportAuthorAvatar', null)} + small + /> + )} + + + + + ) +} diff --git a/centrifuge-app/src/pages/IssuerCreatePool/PoolRatings.tsx b/centrifuge-app/src/pages/IssuerCreatePool/PoolRatings.tsx new file mode 100644 index 0000000000..fd8ea61eb9 --- /dev/null +++ b/centrifuge-app/src/pages/IssuerCreatePool/PoolRatings.tsx @@ -0,0 +1,89 @@ +import { PoolMetadataInput } from '@centrifuge/centrifuge-js' +import { Box, FileUpload, Text, TextInput, URLInput } from '@centrifuge/fabric' +import { Field, FieldArray, FieldProps, useFormikContext } from 'formik' +import { FieldWithErrorMessage } from '../../../src/components/FieldWithErrorMessage' +import { LabelWithDeleteButton } from './IssuerCategories' +import { AddButton } from './PoolDetailsSection' +import { StyledGrid } from './PoolStructureSection' + +export const PoolRatingsSection = () => { + const form = useFormikContext() + + return ( + + Pool rating + + + {({ push, remove }) => ( + <> + {form.values.poolRatings.map((_, index) => ( + <> + + {({ field, meta }: FieldProps) => ( + + )} + + + + {({ field, meta }: FieldProps) => ( + remove(index)} + hideButton={form.values.poolRatings.length === 1} + label="Rating value" + /> + } + /> + )} + + + + {({ field }: FieldProps) => ( + + )} + + + + {({ field, form, meta }: FieldProps) => ( + { + form.setFieldTouched(`poolRatings.${index}.reportFile`, true, false) + form.setFieldValue(`poolRatings.${index}.reportFile`, file) + }} + accept="application/pdf" + label="Executive summary PDF" + placeholder="Choose file" + small + errorMessage={meta.touched && meta.error ? meta.error : undefined} + onClear={() => form.setFieldValue(`poolRatings.${index}.reportFile`, null)} + /> + )} + + + ))} + + + push({ agency: '', value: '', reportUrl: '' })} /> + + + )} + + + + ) +} diff --git a/centrifuge-app/src/pages/IssuerCreatePool/PoolSetupSection.tsx b/centrifuge-app/src/pages/IssuerCreatePool/PoolSetupSection.tsx new file mode 100644 index 0000000000..5cd4b716f2 --- /dev/null +++ b/centrifuge-app/src/pages/IssuerCreatePool/PoolSetupSection.tsx @@ -0,0 +1,454 @@ +import { PoolMetadataInput } from '@centrifuge/centrifuge-js' +import { useCentEvmChainId, useWallet } from '@centrifuge/centrifuge-react' +import { + Box, + Checkbox, + FileUpload, + Grid, + IconButton, + IconHelpCircle, + IconInfo, + IconTrash, + NumberInput, + Select, + Text, + TextInput, +} from '@centrifuge/fabric' +import { Field, FieldArray, FieldProps, useFormikContext } from 'formik' +import { useEffect } from 'react' +import { useTheme } from 'styled-components' +import { FieldWithErrorMessage } from '../../../src/components/FieldWithErrorMessage' +import { Tooltips } from '../../../src/components/Tooltips' +import { feeCategories } from '../../../src/config' +import { FormAddressInput } from './FormAddressInput' +import { AddButton } from './PoolDetailsSection' +import { CheckboxOption, Line, StyledGrid } from './PoolStructureSection' +import { CreatePoolValues } from './types' +import { validate } from './validate' + +const FEE_TYPES = [ + { label: 'Please select...', value: '' }, + { label: 'Direct charge', value: 'chargedUpTo' }, + { label: 'Fixed %', value: 'fixed' }, +] + +const FEE_POSISTIONS = [ + { label: 'Please select...', value: '' }, + { label: 'Top of waterfall', value: 'Top of waterfall' }, +] + +const TaxDocument = () => { + const form = useFormikContext() + return ( + + + Tax document requirement + + + {({ field }: FieldProps) => ( + form.setFieldValue('onboarding.taxInfoRequired', val.target.checked ? true : false)} + /> + )} + + + ) +} + +export const PoolSetupSection = () => { + const theme = useTheme() + const chainId = useCentEvmChainId() + const form = useFormikContext() + const { values } = form + const { selectedAccount } = useWallet().substrate + + useEffect(() => { + form.setFieldValue('adminMultisig.signers[0]', selectedAccount?.address) + }, []) + console.log(values) + return ( + + + Management setup + + + + Pool managers* + + Pool managers can individually add/block investors and manage the liquidity reserve of the pool. + + + + Security requirement + } + onChange={() => { + form.setFieldValue('adminMultisigEnabled', false) + }} + isChecked={!values.adminMultisigEnabled} + id="singleMultisign" + /> + } + onChange={() => { + form.setFieldValue('adminMultisigEnabled', true) + form.setFieldValue('adminMultisig.signers', [form.values.adminMultisig.signers[0], '']) + }} + isChecked={values.adminMultisigEnabled} + id="multiMultisign" + /> + + + Wallet addresses + + {({ push, remove }) => ( + <> + {values.adminMultisigEnabled ? ( + values.adminMultisig?.signers?.map((_, index) => ( + + + {() => ( + + + {values.adminMultisig.signers.length >= 3 && index >= 2 && ( + remove(index)}> + + + )} + + )} + + + )) + ) : ( + + + {({ field }: FieldProps) => ( + + )} + + + )} + {values.adminMultisigEnabled && ( + + { + if (values.adminMultisig && values.adminMultisig.signers?.length <= 10) { + push('') + } + }} + /> + + )} + + )} + + + + + {values.adminMultisigEnabled && ( + + + + {({ field, meta, form }: FieldProps) => ( + form.setFieldValue(`poolFees.${index}.category`, event.target.value)} + onBlur={field.onBlur} + errorMessage={meta.touched && meta.error ? meta.error : undefined} + value={field.value} + options={feeCategories.map((cat) => ({ label: cat, value: cat }))} + /> + )} + + + {({ field, meta }: FieldProps) => ( + form.setFieldValue(`poolFees.${index}.feeType`, event.target.value)} + onBlur={field.onBlur} + errorMessage={meta.touched && meta.error ? meta.error : undefined} + value={field.value} + options={FEE_TYPES} + placeholder="Please select" + /> + )} + + + + + + + ) + })} + + + push({ name: '', category: '', feePosition: '', feeType: '', percentOfNav: '', walletAddress: '' }) + } + /> + + + )} + + + + Investor onboarding + + + Onboarding experience + } + styles={{ marginTop: 1 }} + /> + } + /> + } + /> + + {values.onboardingExperience === 'centrifuge' && ( + + {values.tranches.map((tranche, index) => ( + + {({ field, meta }: FieldProps) => ( + + { + form.setFieldTouched(`onboarding.tranches.${tranche.tokenName}`, true, false) + form.setFieldValue(`onboarding.tranches.${tranche.tokenName}`, file) + }} + label={`Subscription document for ${tranche.tokenName}`} + errorMessage={meta.touched && meta.error ? meta.error : undefined} + accept="application/pdf" + small + /> + + )} + + ))} + + + )} + {values.onboardingExperience === 'external' && ( + + + {({ field, meta }: FieldProps) => ( + + )} + + + + )} + + + + ) +} diff --git a/centrifuge-app/src/pages/IssuerCreatePool/PoolStructureSection.tsx b/centrifuge-app/src/pages/IssuerCreatePool/PoolStructureSection.tsx new file mode 100644 index 0000000000..4e047c0191 --- /dev/null +++ b/centrifuge-app/src/pages/IssuerCreatePool/PoolStructureSection.tsx @@ -0,0 +1,443 @@ +import { PoolMetadataInput } from '@centrifuge/centrifuge-js' +import { + Box, + CurrencyInput, + Grid, + IconHelpCircle, + InputErrorMessage, + NumberInput, + RadioButton, + Select, + Text, + TextInput, +} from '@centrifuge/fabric' +import { Field, FieldProps, useFormikContext } from 'formik' +import * as React from 'react' +import styled, { useTheme } from 'styled-components' +import { FieldWithErrorMessage } from '../../../src/components/FieldWithErrorMessage' +import { Tooltips, tooltipText } from '../../../src/components/Tooltips' +import { config } from '../../config' +import { createEmptyTranche } from './types' +import { validate } from './validate' + +const apyOptions = [ + { value: 'target', label: 'Target' }, + { value: '7d', label: '7 day' }, + { value: '30d', label: '30 day' }, + { value: '90d', label: '90 day' }, + { value: 'ytd', label: 'Year to date' }, + { value: 'inception', label: 'Since inception' }, +] + +export const StyledGrid = styled(Grid)` + background-color: ${({ theme }) => theme.colors.backgroundSecondary}; + padding: 40px; + border: ${({ theme }) => `1px solid ${theme.colors.borderPrimary}`}; + border-radius: 8px; + gap: 24px; + @media (max-width: ${({ theme }) => theme.breakpoints.S}) { + padding: 12px; + } +` + +const tranches: { [key: number]: { label: string; id: string; length: number } } = { + 0: { label: 'Single tranche', id: 'oneTranche', length: 1 }, + 1: { label: 'Two tranches', id: 'twoTranches', length: 2 }, + 2: { label: 'Three tranches', id: 'threeTranches', length: 3 }, +} + +export const Line = () => { + const theme = useTheme() + return +} + +const ASSET_CLASSES = Object.keys(config.assetClasses).map((key) => ({ + label: key, + value: key, +})) + +export const CheckboxOption = ({ + name, + label, + value, + disabled = false, + icon, + sublabel, + id, + height, + styles, + onChange, + isChecked, +}: { + name: string + label: string + sublabel?: string + value?: string | number | boolean + disabled?: boolean + icon?: React.ReactNode + id?: keyof typeof tooltipText + height?: number + styles?: React.CSSProperties + onChange?: () => void + isChecked?: boolean +}) => { + const theme = useTheme() + + return ( + + {onChange ? ( + + ) : ( + + {({ field, form, meta }: FieldProps) => ( + form.setFieldValue(name, val.target.checked ? value : null)} + onBlur={field.onBlur} + checked={form.values[name] === value} + /> + )} + + )} + {icon && {icon}} />} + {sublabel && ( + + {sublabel} + + )} + + ) +} + +export const PoolStructureSection = () => { + const theme = useTheme() + const form = useFormikContext() + const { values } = form + + const subAssetClasses = + config.assetClasses[form.values.assetClass]?.map((label) => ({ + label, + value: label, + })) ?? [] + + const getTrancheName = (index: number) => { + switch (index) { + case 0: + return 'Junior' + case 1: + return values.tranches.length === 2 ? 'Senior' : 'Mezzanine' + case 2: + return 'Senior' + default: + return '' + } + } + + const handleTrancheNameChange = (e: React.ChangeEvent, index: number, form: any) => { + const newValue = e.target.value + const poolName = values.poolName + const suffix = newValue.startsWith(poolName) ? newValue.substring(poolName.length).trim() : newValue + form.setFieldValue(`tranches.${index}.tokenName`, `${poolName} ${suffix}`) + } + + const handleTrancheCheckboxChange = (selectedValue: number) => { + if (selectedValue === 0) { + // Set to single tranche: Junior + form.setFieldValue('tranches', [createEmptyTranche('Junior')]) + } else if (selectedValue === 1) { + // Set to two tranches: Junior, Senior + form.setFieldValue('tranches', [createEmptyTranche('Junior'), createEmptyTranche('Senior')]) + } else if (selectedValue === 2) { + // Set to three tranches: Junior, Mezzanine, Senior + form.setFieldValue('tranches', [ + createEmptyTranche('Junior'), + createEmptyTranche('Mezzanine'), + createEmptyTranche('Senior'), + ]) + } + } + + return ( + + + Pool Structure + + + + Pool type * + + + + + Define tranche structure * + + {Array.from({ length: 3 }).map((_, index) => { + return ( + } + onChange={() => handleTrancheCheckboxChange(index)} + isChecked={values.tranches.length === tranches[index].length} + /> + ) + })} + + + + Asset setup + + + + {({ field, meta, form }: FieldProps) => ( + Asset denomination*} />} + onChange={(event) => form.setFieldValue('assetDenomination', event.target.value)} + onBlur={field.onBlur} + errorMessage={meta.touched && meta.error ? meta.error : undefined} + value={field.value} + options={[ + { value: 'usdc', label: 'USDC' }, + { value: 'usdt', label: 'USDT (coming soon)', disabled: true }, + { value: 'dai', label: 'DAI (coming soon)', disabled: true }, + ]} + placeholder="Select..." + /> + ) + }} + + + + + + {({ field, meta, form }: FieldProps) => ( + APY} />} + onChange={(event) => form.setFieldValue(field.name, event.target.value)} + onBlur={field.onBlur} + errorMessage={meta.touched && meta.error ? meta.error : undefined} + value={field.value} + options={apyOptions} + placeholder="Select..." + /> + )} + + + + + + ) : ( + Min. subordination} /> + } + placeholder="0.00" + symbol="%" + name={`tranches.${index}.minRiskBuffer`} + validate={validate.minRiskBuffer} + /> + )} + + + + {index !== 0 && ( + <> + + + {({ field, form, meta }: FieldProps) => ( + Fixed interest rate} + /> + } + placeholder="0.00" + errorMessage={meta.touched ? meta.error : undefined} + onBlur={() => form.setFieldTouched(field.name, true)} + symbol="%" + as={NumberInput} + /> + )} + + + + + {({ field }: FieldProps) => ( + + APY + + } + /> + } + name={`tranches.${index}.apy`} + disabled + value={apyOptions.find((option) => option.value === values.tranches[0].apy)?.label} + /> + )} + + + + )} + + + ))} + + + ) +} diff --git a/centrifuge-app/src/pages/IssuerCreatePool/TrancheInput.tsx b/centrifuge-app/src/pages/IssuerCreatePool/TrancheInput.tsx index 2a39aa8a96..2bb5f13b75 100644 --- a/centrifuge-app/src/pages/IssuerCreatePool/TrancheInput.tsx +++ b/centrifuge-app/src/pages/IssuerCreatePool/TrancheInput.tsx @@ -12,10 +12,10 @@ import { } from '@centrifuge/fabric' import { Field, FieldArray, FieldProps, useFormikContext } from 'formik' import * as React from 'react' -import { createEmptyTranche } from '.' import { FieldWithErrorMessage } from '../../components/FieldWithErrorMessage' import { PageSection } from '../../components/PageSection' import { Tooltips } from '../../components/Tooltips' +import { createEmptyTranche } from './types' import { validate } from './validate' const MAX_TRANCHES = 3 @@ -157,7 +157,7 @@ export function TrancheInput({ {...field} label={} placeholder="0.00" - currency={values.currency} + currency={values.assetDenomination} errorMessage={meta.touched ? meta.error : undefined} onChange={(value) => form.setFieldValue(field.name, value)} onBlur={() => form.setFieldTouched(field.name, true)} diff --git a/centrifuge-app/src/pages/IssuerCreatePool/index.tsx b/centrifuge-app/src/pages/IssuerCreatePool/index.tsx index 27c95509dc..09d89dc8b4 100644 --- a/centrifuge-app/src/pages/IssuerCreatePool/index.tsx +++ b/centrifuge-app/src/pages/IssuerCreatePool/index.tsx @@ -1,231 +1,161 @@ -import { CurrencyBalance, isSameAddress, Perquintill, Rate, TransactionOptions } from '@centrifuge/centrifuge-js' import { AddFee, + CurrencyBalance, CurrencyKey, - FeeTypes, FileType, + isSameAddress, + Perquintill, + PoolFeesCreatePool, PoolMetadataInput, - TrancheInput, -} from '@centrifuge/centrifuge-js/dist/modules/pools' + Rate, + TrancheCreatePool, + TransactionOptions, +} from '@centrifuge/centrifuge-js' +import { Box, Button, Dialog, Step, Stepper, Text } from '@centrifuge/fabric' +import { createKeyMulti, sortAddresses } from '@polkadot/util-crypto' +import BN from 'bn.js' +import { Form, FormikProvider, useFormik } from 'formik' +import { useEffect, useRef, useState } from 'react' +import { useNavigate } from 'react-router' +import { combineLatest, firstValueFrom, switchMap, tap } from 'rxjs' +import styled, { useTheme } from 'styled-components' import { - useBalances, + useAddress, useCentrifuge, + useCentrifugeApi, useCentrifugeConsts, useCentrifugeTransaction, useWallet, -} from '@centrifuge/centrifuge-react' -import { - Box, - Button, - CurrencyInput, - FileUpload, - Grid, - Select, - Shelf, - Text, - TextInput, - TextWithPlaceholder, - Thumbnail, -} from '@centrifuge/fabric' -import { createKeyMulti, sortAddresses } from '@polkadot/util-crypto' -import BN from 'bn.js' -import { Field, FieldProps, Form, FormikErrors, FormikProvider, setIn, useFormik } from 'formik' -import * as React from 'react' -import { useNavigate } from 'react-router' -import { combineLatest, firstValueFrom, lastValueFrom, switchMap, tap } from 'rxjs' -import { useDebugFlags } from '../../components/DebugFlags' -import { PreimageHashDialog } from '../../components/Dialogs/PreimageHashDialog' -import { ShareMultisigDialog } from '../../components/Dialogs/ShareMultisigDialog' -import { FieldWithErrorMessage } from '../../components/FieldWithErrorMessage' -import { PageHeader } from '../../components/PageHeader' -import { PageSection } from '../../components/PageSection' -import { Tooltips } from '../../components/Tooltips' -import { config, isTestEnv } from '../../config' -import { isSubstrateAddress } from '../../utils/address' -import { Dec } from '../../utils/Decimal' -import { formatBalance } from '../../utils/formatting' -import { getFileDataURI } from '../../utils/getFileDataURI' -import { useAddress } from '../../utils/useAddress' -import { useCreatePoolFee } from '../../utils/useCreatePoolFee' -import { usePoolCurrencies } from '../../utils/useCurrencies' -import { useFocusInvalidInput } from '../../utils/useFocusInvalidInput' -import { usePools } from '../../utils/usePools' -import { truncate } from '../../utils/web3' -import { AdminMultisigSection } from './AdminMultisig' -import { IssuerInput } from './IssuerInput' -import { PoolFeeSection } from './PoolFeeInput' -import { PoolRatingInput } from './PoolRatingInput' -import { PoolReportsInput } from './PoolReportsInput' -import { TrancheSection } from './TrancheInput' -import { useStoredIssuer } from './useStoredIssuer' -import { validate } from './validate' - -const ASSET_CLASSES = Object.keys(config.assetClasses).map((key) => ({ - label: key, - value: key, -})) - -export default function IssuerCreatePoolPage() { - return -} - -export interface Tranche { - tokenName: string - symbolName: string - interestRate: number | '' - minRiskBuffer: number | '' - minInvestment: number | '' -} -export interface WriteOffGroupInput { - days: number | '' - writeOff: number | '' - penaltyInterest: number | '' -} - -export const createEmptyTranche = (trancheName: string): Tranche => ({ - tokenName: trancheName, - symbolName: '', - interestRate: trancheName === 'Junior' ? '' : 0, - minRiskBuffer: trancheName === 'Junior' ? '' : 0, - minInvestment: 1000, -}) - -export type CreatePoolValues = Omit< - PoolMetadataInput, - 'poolIcon' | 'issuerLogo' | 'executiveSummary' | 'adminMultisig' | 'poolFees' | 'poolReport' | 'poolRatings' -> & { - poolIcon: File | null - issuerLogo: File | null - executiveSummary: File | null - reportAuthorName: string - reportAuthorTitle: string - reportAuthorAvatar: File | null - reportUrl: string - adminMultisigEnabled: boolean - adminMultisig: Exclude - poolFees: { - id?: number - name: string - feeType: FeeTypes - percentOfNav: number | '' - walletAddress: string - feePosition: 'Top of waterfall' - category: string - }[] - poolType: 'open' | 'closed' - investorType: string - issuerShortDescription: string - issuerCategories: { type: string; value: string }[] - poolRatings: { - agency?: string - value?: string - reportUrl?: string - reportFile?: File | null - }[] - poolStructure: string -} - -const initialValues: CreatePoolValues = { - poolIcon: null, - poolName: '', - assetClass: 'Private credit', - subAssetClass: '', - currency: isTestEnv ? 'USDC' : 'Native USDC', - maxReserve: 1000000, - epochHours: 23, // in hours - epochMinutes: 50, // in minutes - listed: !import.meta.env.REACT_APP_DEFAULT_UNLIST_POOLS, - investorType: '', - poolStructure: '', - issuerName: '', - issuerRepName: '', - issuerLogo: null, - issuerDescription: '', - issuerShortDescription: '', - issuerCategories: [], - - executiveSummary: null, - website: '', - forum: '', - email: '', - details: [], - reportAuthorName: '', - reportAuthorTitle: '', - reportAuthorAvatar: null, - reportUrl: '', - - poolRatings: [], - - tranches: [createEmptyTranche('')], - adminMultisig: { - signers: [], - threshold: 1, - }, - adminMultisigEnabled: false, - poolFees: [], - poolType: 'open', +} from '../../../../centrifuge-react' +import { useDebugFlags } from '../../../src/components/DebugFlags' +import { PreimageHashDialog } from '../../../src/components/Dialogs/PreimageHashDialog' +import { ShareMultisigDialog } from '../../../src/components/Dialogs/ShareMultisigDialog' +import { Dec } from '../../../src/utils/Decimal' +import { useCreatePoolFee } from '../../../src/utils/useCreatePoolFee' +import { usePoolCurrencies } from '../../../src/utils/useCurrencies' +import { useIsAboveBreakpoint } from '../../../src/utils/useIsAboveBreakpoint' +import { usePools } from '../../../src/utils/usePools' +import { config } from '../../config' +import { PoolDetailsSection } from './PoolDetailsSection' +import { PoolSetupSection } from './PoolSetupSection' +import { Line, PoolStructureSection } from './PoolStructureSection' +import { CreatePoolValues, initialValues } from './types' +import { pinFileIfExists, pinFiles } from './utils' +import { validateValues } from './validate' + +const PROPOSAL_URL = 'https://centrifuge.subsquare.io/democracy/referenda' + +const StyledBox = styled(Box)` + padding: 48px 80px 0px 80px; + @media (max-width: ${({ theme }) => theme.breakpoints.S}) { + padding: 12px; + } +` + +const stepFields: { [key: number]: string[] } = { + 1: ['assetClass', 'assetDenomination', 'subAssetClass', 'tranches'], + 2: [ + 'poolName', + 'poolIcon', + 'investorType', + 'maxReserve', + 'poolType', + 'issuerName', + 'issuerShortDescription', + 'issuerDescription', + ], + 3: ['assetOriginators', 'adminMultisig'], } -function PoolIcon({ icon, children }: { icon?: File | null; children: string }) { - const [uri, setUri] = React.useState('') - React.useEffect(() => { - ;(async () => { - if (!icon) return - const uri = await getFileDataURI(icon) - setUri(uri) - })() - }, [icon]) - return uri ? : +const txMessage = { + immediate: 'Create pool', + propose: 'Submit pool proposal', + notePreimage: 'Note preimage', } -function CreatePoolForm() { +const IssuerCreatePoolPage = () => { + const theme = useTheme() + const formRef = useRef(null) + const isSmall = useIsAboveBreakpoint('S') const address = useAddress('substrate') - const { - substrate: { addMultisig }, - } = useWallet() - const centrifuge = useCentrifuge() - const currencies = usePoolCurrencies() - const { chainDecimals } = useCentrifugeConsts() - const pools = usePools() const navigate = useNavigate() - const balances = useBalances(address) - const { data: storedIssuer, isLoading: isStoredIssuerLoading } = useStoredIssuer() - const [waitingForStoredIssuer, setWaitingForStoredIssuer] = React.useState(true) - const [isPreimageDialogOpen, setIsPreimageDialogOpen] = React.useState(false) - const [isMultisigDialogOpen, setIsMultisigDialogOpen] = React.useState(false) - const [preimageHash, setPreimageHash] = React.useState('') - const [createdPoolId, setCreatedPoolId] = React.useState('') - const [multisigData, setMultisigData] = React.useState<{ hash: string; callData: string }>() + const currencies = usePoolCurrencies() + const centrifuge = useCentrifuge() + const api = useCentrifugeApi() const { poolCreationType } = useDebugFlags() const consts = useCentrifugeConsts() + const { chainDecimals } = useCentrifugeConsts() + const pools = usePools() const createType = (poolCreationType as TransactionOptions['createType']) || config.poolCreationType || 'immediate' + const { + substrate: { addMultisig }, + } = useWallet() - React.useEffect(() => { - // If the hash can't be found on Pinata the request can take a long time to time out - // During which the name/description can't be edited - // Set a deadline for how long we're willing to wait on a stored issuer - setTimeout(() => setWaitingForStoredIssuer(false), 10000) - }, []) - - React.useEffect(() => { - if (storedIssuer) setWaitingForStoredIssuer(false) - }, [storedIssuer]) + const [step, setStep] = useState(1) + const [stepCompleted, setStepCompleted] = useState({ 1: false, 2: false, 3: false }) + const [multisigData, setMultisigData] = useState<{ hash: string; callData: string }>() + const [isMultisigDialogOpen, setIsMultisigDialogOpen] = useState(false) + const [createdModal, setCreatedModal] = useState(false) + const [preimageHash, setPreimageHash] = useState('') + const [isPreimageDialogOpen, setIsPreimageDialogOpen] = useState(false) + const [proposalId, setProposalId] = useState(null) + const [poolId, setPoolId] = useState(null) + + useEffect(() => { + if (createType === 'notePreimage') { + const $events = centrifuge + .getEvents() + .pipe( + tap(({ api, events }) => { + const event = events.find(({ event }) => api.events.preimage.Noted.is(event)) + const parsedEvent = event?.toJSON() as any + if (!parsedEvent) return false + console.info('Preimage hash: ', parsedEvent.event.data[0]) + setPreimageHash(parsedEvent.event.data[0]) + setIsPreimageDialogOpen(true) + }) + ) + .subscribe() + return () => $events.unsubscribe() + } + }, [centrifuge, createType]) - React.useEffect(() => { - if (createdPoolId && pools?.find((p) => p.id === createdPoolId)) { + useEffect(() => { + if (poolId && pools?.find((p) => p.id === poolId)) { // Redirecting only when we find the newly created pool in the data from usePools // Otherwise the Issue Overview page will throw an error when it can't find the pool // It can take a second for the new data to come in after creating the pool - navigate(`/issuer/${createdPoolId}`) + navigate(`/issuer/${poolId}`) } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [pools, createdPoolId]) + }, [poolId, pools]) + + const { execute: createProxies, isLoading: createProxiesIsPending } = useCentrifugeTransaction( + `${txMessage[createType]} 1/2`, + (cent) => { + return (_: [nextTx: (adminProxy: string, aoProxy: string) => void], options) => + cent.getApi().pipe( + switchMap((api) => { + const submittable = api.tx.utility.batchAll([ + api.tx.proxy.createPure('Any', 0, 0), + api.tx.proxy.createPure('Any', 0, 1), + ]) + return cent.wrapSignAndSend(api, submittable, options) + }) + ) + }, + { + onSuccess: async ([nextTx], result) => { + const api = await centrifuge.getApiPromise() + const events = result.events.filter(({ event }) => api.events.proxy.PureCreated.is(event)) + if (!events) return + const { pure } = (events[0].toHuman() as any).event.data + const { pure: pure2 } = (events[1].toHuman() as any).event.data + + nextTx(pure, pure2) + }, + } + ) - const txMessage = { - immediate: 'Create pool', - propose: 'Submit pool proposal', - notePreimage: 'Note preimage', - } const { execute: createPoolTx, isLoading: transactionIsPending } = useCentrifugeTransaction( `${txMessage[createType]} 2/2`, (cent) => @@ -236,15 +166,15 @@ function CreatePoolForm() { aoProxy: string, adminProxy: string, poolId: string, - tranches: TrancheInput[], + tranches: TrancheCreatePool[], currency: CurrencyKey, maxReserve: BN, metadata: PoolMetadataInput, - poolFees: AddFee['fee'][] + poolFees: PoolFeesCreatePool[] ], options ) => { - const [values, transferToMultisig, aoProxy, adminProxy, , , , , { adminMultisig }] = args + const [values, transferToMultisig, aoProxy, adminProxy, , , , , { adminMultisig, assetOriginators }] = args const multisigAddr = adminMultisig && createKeyMulti(adminMultisig.signers, adminMultisig.threshold) const poolArgs = args.slice(3) as any return combineLatest([ @@ -263,7 +193,9 @@ function CreatePoolForm() { api.tx.balances.transferKeepAlive(adminProxy, consts.proxy.proxyDepositFactor.add(transferToMultisig)), api.tx.balances.transferKeepAlive( aoProxy, - consts.proxy.proxyDepositFactor.add(consts.uniques.collectionDeposit) + consts.proxy.proxyDepositFactor + .add(consts.uniques.collectionDeposit) + .add(consts.proxy.proxyDepositFactor.mul(new BN(assetOriginators.length * 4))) ), adminProxyDelegates.length > 0 && api.tx.proxy.proxy( @@ -279,10 +211,18 @@ function CreatePoolForm() { api.tx.proxy.proxy( aoProxy, undefined, - api.tx.utility.batchAll([ - api.tx.proxy.addProxy(adminProxy, 'Any', 0), - api.tx.proxy.removeProxy(address, 'Any', 0), - ]) + api.tx.utility.batchAll( + [ + api.tx.proxy.addProxy(adminProxy, 'Any', 0), + ...assetOriginators.map((addr) => [ + api.tx.proxy.addProxy(addr, 'Borrow', 0), + api.tx.proxy.addProxy(addr, 'Invest', 0), + api.tx.proxy.addProxy(addr, 'Transfer', 0), + api.tx.proxy.addProxy(addr, 'PodOperation', 0), + ]), + api.tx.proxy.removeProxy(address, 'Any', 0), + ].flat() + ) ), multisigAddr ? api.tx.multisig.asMulti(adminMultisig.threshold, otherMultisigSigners, null, proxiedPoolCreate, 0) @@ -295,214 +235,109 @@ function CreatePoolForm() { ) }, { - onSuccess: (args) => { - if (form.values.adminMultisigEnabled && form.values.adminMultisig.threshold > 1) setIsMultisigDialogOpen(true) + onSuccess: (args, result) => { + if (form.values.adminMultisigEnabled && form.values.adminMultisig.threshold > 1) { + setIsMultisigDialogOpen(true) + } const [, , , , poolId] = args if (createType === 'immediate') { - setCreatedPoolId(poolId) + setPoolId(poolId) + } else { + const event = result.events.find(({ event }) => api.events.democracy.Proposed.is(event)) + if (event) { + const eventData = event.toHuman() as any + const proposalId = eventData.event.data.proposalIndex.replace(/\D/g, '') + setCreatedModal(true) + setProposalId(proposalId) + } } }, } ) - const { execute: createProxies, isLoading: createProxiesIsPending } = useCentrifugeTransaction( - `${txMessage[createType]} 1/2`, - (cent) => { - return (_: [nextTx: (adminProxy: string, aoProxy: string) => void], options) => - cent.getApi().pipe( - switchMap((api) => { - const submittable = api.tx.utility.batchAll([ - api.tx.proxy.createPure('Any', 0, 0), - api.tx.proxy.createPure('Any', 0, 1), - ]) - return cent.wrapSignAndSend(api, submittable, options) - }) - ) - }, - { - onSuccess: async ([nextTx], result) => { - const api = await centrifuge.getApiPromise() - const events = result.events.filter(({ event }) => api.events.proxy.PureCreated.is(event)) - if (!events) return - const { pure } = (events[0].toHuman() as any).event.data - const { pure: pure2 } = (events[1].toHuman() as any).event.data - - nextTx(pure, pure2) - }, - } - ) - const form = useFormik({ initialValues, - validate: (values) => { - let errors: FormikErrors = {} - - const tokenNames = new Set() - const commonTokenSymbolStart = values.tranches[0].symbolName.slice(0, 3) - const tokenSymbols = new Set() - let prevInterest = Infinity - let prevRiskBuffer = 0 - - const juniorInterestRate = parseFloat(values.tranches[0].interestRate as string) - - values.poolFees.forEach((fee, i) => { - if (fee.name === '') { - errors = setIn(errors, `poolFees.${i}.name`, 'Name is required') - } - if (fee.percentOfNav === '' || fee.percentOfNav < 0.0001 || fee.percentOfNav > 10) { - errors = setIn(errors, `poolFees.${i}.percentOfNav`, 'Percentage between 0.0001 and 10 is required') - } - if (fee.walletAddress === '') { - errors = setIn(errors, `poolFees.${i}.walletAddress`, 'Wallet address is required') - } - if (!isSubstrateAddress(fee?.walletAddress)) { - errors = setIn(errors, `poolFees.${i}.walletAddress`, 'Invalid address') - } - }) - - values.tranches.forEach((t, i) => { - if (tokenNames.has(t.tokenName)) { - errors = setIn(errors, `tranches.${i}.tokenName`, 'Tranche names must be unique') - } - tokenNames.add(t.tokenName) - - // matches any character thats not alphanumeric or - - if (/[^a-z^A-Z^0-9^-]+/.test(t.symbolName)) { - errors = setIn(errors, `tranches.${i}.symbolName`, 'Invalid character detected') - } - - if (tokenSymbols.has(t.symbolName)) { - errors = setIn(errors, `tranches.${i}.symbolName`, 'Token symbols must be unique') - } - tokenSymbols.add(t.symbolName) - - if (t.symbolName.slice(0, 3) !== commonTokenSymbolStart) { - errors = setIn(errors, `tranches.${i}.symbolName`, 'Token symbols must all start with the same 3 characters') - } - - if (i > 0 && t.interestRate !== '') { - if (t.interestRate > juniorInterestRate) { - errors = setIn( - errors, - `tranches.${i}.interestRate`, - "Interest rate can't be higher than the junior tranche's target APY" - ) - } - if (t.interestRate > prevInterest) { - errors = setIn(errors, `tranches.${i}.interestRate`, "Can't be higher than a more junior tranche") - } - prevInterest = t.interestRate - } - - if (t.minRiskBuffer !== '') { - if (t.minRiskBuffer < prevRiskBuffer) { - errors = setIn(errors, `tranches.${i}.minRiskBuffer`, "Can't be lower than a more junior tranche") - } - prevRiskBuffer = t.minRiskBuffer - } - }) - - return errors - }, + validate: (values) => validateValues(values), validateOnMount: true, onSubmit: async (values, { setSubmitting }) => { + const poolId = await centrifuge.pools.getAvailablePoolId() + if (!currencies || !address) return const metadataValues: PoolMetadataInput = { ...values } as any - // Handle admin multisig - metadataValues.adminMultisig = - values.adminMultisigEnabled && values.adminMultisig.threshold > 1 - ? { - ...values.adminMultisig, - signers: sortAddresses(values.adminMultisig.signers), - } - : undefined - - // Get the currency for the pool - const currency = currencies.find((c) => c.symbol === values.currency)! + // Find the currency (asset denomination in UI) + const currency = currencies.find((c) => c.symbol.toLowerCase() === values.assetDenomination.toLowerCase())! - // Pool ID and required assets - const poolId = await centrifuge.pools.getAvailablePoolId() - if (!values.poolIcon || (!isTestEnv && !values.executiveSummary)) { - return - } + // Handle pining files for ipfs + if (!values.poolIcon) return - const pinFile = async (file: File): Promise => { - const pinned = await lastValueFrom(centrifuge.metadata.pinFile(await getFileDataURI(file))) - return { uri: pinned.uri, mime: file.type } + const filesToPin = { + poolIcon: values.poolIcon, + issuerLogo: values.issuerLogo, + executiveSummary: values.executiveSummary, + authorAvatar: values.reportAuthorAvatar, } - // Handle pinning files (pool icon, issuer logo, and executive summary) - const promises = [pinFile(values.poolIcon)] + const pinnedFiles = await pinFiles(centrifuge, filesToPin) + if (pinnedFiles.poolIcon) metadataValues.poolIcon = pinnedFiles.poolIcon as FileType + if (pinnedFiles.issuerLogo) metadataValues.issuerLogo = pinnedFiles.issuerLogo as FileType + if (pinnedFiles.executiveSummary) metadataValues.executiveSummary = pinnedFiles.executiveSummary - if (values.issuerLogo) { - promises.push(pinFile(values.issuerLogo)) - } - - if (!isTestEnv && values.executiveSummary) { - promises.push(pinFile(values.executiveSummary)) - } - - const [pinnedPoolIcon, pinnedIssuerLogo, pinnedExecSummary] = await Promise.all(promises) - - metadataValues.issuerLogo = pinnedIssuerLogo?.uri - ? { uri: pinnedIssuerLogo.uri, mime: values?.issuerLogo?.type || '' } - : null - - metadataValues.executiveSummary = - !isTestEnv && values.executiveSummary - ? { uri: pinnedExecSummary.uri, mime: values.executiveSummary.type } - : null - - metadataValues.poolIcon = { uri: pinnedPoolIcon.uri, mime: values.poolIcon.type } - - // Handle pool report if available + // Pool report if (values.reportUrl) { - let avatar = null - if (values.reportAuthorAvatar) { - const pinned = await pinFile(values.reportAuthorAvatar) - avatar = { uri: pinned.uri, mime: values.reportAuthorAvatar.type } - } metadataValues.poolReport = { - authorAvatar: avatar, + authorAvatar: pinnedFiles.authorAvatar, authorName: values.reportAuthorName, authorTitle: values.reportAuthorTitle, url: values.reportUrl, } } - if (values.poolRatings) { - const newRatingReportPromise = await Promise.all( - values.poolRatings.map((rating) => (rating.reportFile ? pinFile(rating.reportFile) : null)) + + // Pool ratings + if (values.poolRatings[0].agency === '') { + metadataValues.poolRatings = [] + } else { + const newRatingReports = await Promise.all( + values.poolRatings.map((rating) => pinFileIfExists(centrifuge, rating.reportFile ?? null)) ) const ratings = values.poolRatings.map((rating, index) => { - let reportFile: FileType | null = rating.reportFile - ? { uri: rating.reportFile.name, mime: rating.reportFile.type } - : null - if (rating.reportFile && newRatingReportPromise[index]?.uri) { - reportFile = newRatingReportPromise[index] ?? null - } + const pinnedReport = newRatingReports[index] return { - agency: rating.agency ?? '', - value: rating.value ?? '', - reportUrl: rating.reportUrl ?? '', - reportFile: reportFile ?? null, + agency: rating.agency, + value: rating.value, + reportUrl: rating.reportUrl, + reportFile: pinnedReport ? { uri: pinnedReport.uri, mime: rating.reportFile?.type ?? '' } : null, } }) metadataValues.poolRatings = ratings } - const nonJuniorTranches = metadataValues.tranches.slice(1) - const tranches = [ - {}, - ...nonJuniorTranches.map((tranche) => ({ - interestRatePerSec: Rate.fromAprPercent(tranche.interestRate), - minRiskBuffer: Perquintill.fromPercent(tranche.minRiskBuffer), - })), - ] + // Tranches + const tranches: TrancheCreatePool[] = metadataValues.tranches.map((tranche, index) => { + const trancheType = + index === 0 + ? 'Residual' + : { + NonResidual: { + interestRatePerSec: Rate.fromAprPercent(tranche.interestRate).toString(), + minRiskBuffer: Perquintill.fromPercent(tranche.minRiskBuffer).toString(), + }, + } + + return { + trancheType, + metadata: { + tokenName: + metadataValues.tranches.length > 1 ? `${metadataValues.poolName} ${tranche.tokenName}` : 'Junior', + tokenSymbol: tranche.symbolName, + }, + } + }) + // Pool fees const feeId = await firstValueFrom(centrifuge.pools.getNextPoolFeeId()) - const poolFees: AddFee['fee'][] = values.poolFees.map((fee, i) => { + const poolFees: AddFee['fee'][] = values.poolFees.map((fee) => { return { name: fee.name, destination: fee.walletAddress, @@ -520,10 +355,43 @@ function CreatePoolForm() { feeType: fee.feeType, })) + const feeInput = poolFees.map((fee) => { + return [ + 'Top', + { + destination: fee.destination, + editor: fee?.account ? { account: fee.account } : 'Root', + feeType: { [fee.feeType]: { limit: { [fee.limit]: fee?.amount } } }, + }, + ] + }) + + // Multisign + metadataValues.adminMultisig = + values.adminMultisigEnabled && values.adminMultisig.threshold > 1 + ? { + ...values.adminMultisig, + signers: sortAddresses(values.adminMultisig.signers), + } + : undefined + if (metadataValues.adminMultisig && metadataValues.adminMultisig.threshold > 1) { addMultisig(metadataValues.adminMultisig) } + // Onboarding + if (metadataValues.onboardingExperience === 'none') { + metadataValues.onboarding = { + taxInfoRequired: metadataValues.onboarding?.taxInfoRequired, + tranches: {}, + } + } + + // Issuer categories + if (values.issuerCategories[0].value === '') { + metadataValues.issuerCategories = [] + } + createProxies([ (aoProxy, adminProxy) => { createPoolTx( @@ -537,7 +405,7 @@ function CreatePoolForm() { currency.key, CurrencyBalance.fromFloat(values.maxReserve, currency.decimals), metadataValues, - poolFees, + feeInput, ], { createType } ) @@ -548,62 +416,40 @@ function CreatePoolForm() { }, }) - React.useEffect(() => { - if (!isStoredIssuerLoading && storedIssuer && waitingForStoredIssuer) { - if (storedIssuer.name) { - form.setFieldValue('issuerName', storedIssuer.name, false) - } - if (storedIssuer.repName) { - form.setFieldValue('issuerRepName', storedIssuer.repName, false) - } - if (storedIssuer.description) { - form.setFieldValue('issuerDescription', storedIssuer.description, false) - } - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isStoredIssuerLoading]) - - React.useEffect(() => { - if (createType === 'notePreimage') { - const $events = centrifuge - .getEvents() - .pipe( - tap(({ api, events }) => { - const event = events.find(({ event }) => api.events.preimage.Noted.is(event)) - const parsedEvent = event?.toJSON() as any - if (!parsedEvent) return false - console.info('Preimage hash: ', parsedEvent.event.data[0]) - setPreimageHash(parsedEvent.event.data[0]) - setIsPreimageDialogOpen(true) - }) - ) - .subscribe() - return () => $events.unsubscribe() - } - }, [centrifuge, createType]) - - const formRef = React.useRef(null) - useFocusInvalidInput(form, formRef) - const { proposeFee, poolDeposit, proxyDeposit, collectionDeposit } = useCreatePoolFee(form?.values) + const createDeposit = (proposeFee?.toDecimal() ?? Dec(0)) .add(poolDeposit.toDecimal()) .add(collectionDeposit.toDecimal()) + const deposit = createDeposit.add(proxyDeposit.toDecimal()) - const subAssetClasses = - config.assetClasses[form.values.assetClass]?.map((label) => ({ - label, - value: label, - })) ?? [] + const { values, errors } = form - // Use useEffect to update tranche name when poolName changes - React.useEffect(() => { - if (form.values.poolName) { - form.setFieldValue('tranches', [createEmptyTranche(form.values.poolName)]) + const checkStepCompletion = (stepNumber: number) => { + const fields = stepFields[stepNumber] + return fields.every( + (field) => + values[field as keyof typeof values] !== null && + values[field as keyof typeof values] !== '' && + !errors[field as keyof typeof errors] + ) + } + + const handleNextStep = () => { + if (step === 3) { + form.handleSubmit() + } else { + setStep((prevStep) => prevStep + 1) } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [form.values.poolName]) + } + + useEffect(() => { + setStepCompleted((prev) => ({ + ...prev, + [step]: checkStepCompletion(step), + })) + }, [values, errors, step, stepFields]) return ( <> @@ -623,199 +469,79 @@ function CreatePoolForm() { )}
- {(form.values.poolName || 'New Pool')[0]}} - title={form.values.poolName || 'New Pool'} - subtitle={ - - by {form.values.issuerName || (address && truncate(address))} - - } - /> - - - - - - - - {({ field, form, meta }: FieldProps) => ( - } - onChange={(event) => { - form.setFieldValue('assetClass', event.target.value) - form.setFieldValue('subAssetClass', '', false) - }} - onBlur={field.onBlur} - errorMessage={meta.touched && meta.error ? meta.error : undefined} - value={field.value} - options={ASSET_CLASSES} - placeholder="Select..." - /> - )} - - - - - {({ field, meta, form }: FieldProps) => ( - } - onChange={(event: any) => form.setFieldValue('investorType', event.target.value)} - onBlur={field.onBlur} - errorMessage={meta.touched && meta.error ? meta.error : undefined} - value={field.value} - as={TextInput} - /> - )} - - - - - {({ field, meta, form }: FieldProps) => ( - } - onChange={(event) => form.setFieldValue('currency', event.target.value)} - onBlur={field.onBlur} - errorMessage={meta.touched && meta.error ? meta.error : undefined} - value={field.value} - options={currencies?.map((c) => ({ value: c.symbol, label: c.name })) ?? []} - placeholder="Select..." - /> - ) - }} - - - - - {({ field, form }: FieldProps) => ( - form.setFieldValue('maxReserve', value)} - /> - )} - - - - - {({ field, meta, form }: FieldProps) => ( - form.setFieldValue('poolStructure', event.target.value)} - onBlur={field.onBlur} - errorMessage={meta.touched && meta.error ? meta.error : undefined} - value={field.value} - as={TextInput} - placeholder="Revolving" - /> - )} - - - - - - - - - - - - - - - - - - - - - - - Deposit required: {formatBalance(deposit, balances?.native.currency.symbol, 1)} - - + + New pool setup + + + + + + + + + {step === 1 && ( + + + A deposit of {deposit.toNumber()} CFG is required to create this pool. Please make sure you have + sufficient funds in your wallet. + + + )} + + {step === 1 && } + {step === 2 && } + {step === 3 && } + + + {step !== 1 && ( - - - + )} + + +
+ {createdModal && ( + setCreatedModal(false)} width={426} hideButton> + + Your pool is almost ready! + + A governance proposal to launch this pool has been submitted on your behalf. Once the proposal is + approved, your pool will go live. + + + + + + + + )} ) } + +export default IssuerCreatePoolPage diff --git a/centrifuge-app/src/pages/IssuerCreatePool/types.ts b/centrifuge-app/src/pages/IssuerCreatePool/types.ts new file mode 100644 index 0000000000..cacc80029e --- /dev/null +++ b/centrifuge-app/src/pages/IssuerCreatePool/types.ts @@ -0,0 +1,133 @@ +import { FeeTypes, PoolMetadataInput } from '@centrifuge/centrifuge-js' + +export interface Tranche { + tokenName: string + symbolName: string + minRiskBuffer: number | '' + minInvestment: number | '' + apy: string + interestRate: number | '' + apyPercentage: number | null +} + +export interface PoolFee { + id: number + name: string + feeType: FeeTypes + percentOfNav: number + walletAddress: string + feePosition: 'Top of waterfall' + category: string +} +export interface WriteOffGroupInput { + days: number | '' + writeOff: number | '' + penaltyInterest: number | '' +} + +export const createEmptyTranche = (trancheName: string): Tranche => ({ + tokenName: trancheName, + symbolName: '', + interestRate: 0, + minRiskBuffer: trancheName === 'Junior' ? '' : 0, + minInvestment: 1000, + apy: '90d', + apyPercentage: null, +}) + +export const createPoolFee = (): PoolFee => { + return { + id: 0, + name: '', + category: '', + feePosition: 'Top of waterfall', + feeType: 'Fixed' as FeeTypes, + percentOfNav: 0.4, + walletAddress: import.meta.env.REACT_APP_TREASURY, + } +} + +export type CreatePoolValues = Omit< + PoolMetadataInput, + | 'poolIcon' + | 'issuerLogo' + | 'executiveSummary' + | 'adminMultisig' + | 'poolFees' + | 'poolReport' + | 'poolRatings' + | 'issuerName' + | 'epochHours' + | 'epochMinutes' + | 'poolFees' +> & { + // pool structure + issuerName: null | '' + assetDenomination: string + + // pool details + issuerCategories: { type: string; value: string }[] + poolIcon: File | null + issuerLogo: File | null + executiveSummary: File | null + + reportAuthorName: string + reportAuthorTitle: string + reportAuthorAvatar: File | null + reportUrl: string + adminMultisigEnabled: boolean + adminMultisig: Exclude + poolFees: PoolFee[] + poolRatings: { + agency?: string + value?: string + reportUrl?: string + reportFile?: File | null + }[] +} + +export const initialValues: CreatePoolValues = { + // pool structure + poolStructure: 'revolving', + assetClass: 'Private credit', + assetDenomination: 'USDC', + subAssetClass: '', + tranches: [createEmptyTranche('Junior')], + + // pool details section + poolName: '', + poolIcon: null, + maxReserve: 1000000, + investorType: '', + issuerName: null, + issuerRepName: '', + issuerLogo: null, + issuerDescription: '', + issuerShortDescription: '', + issuerCategories: [{ type: '', value: '' }], + poolRatings: [{ agency: '', value: '', reportUrl: '', reportFile: null }], + executiveSummary: null, + website: '', + forum: '', + email: '', + details: [], + reportAuthorName: '', + reportAuthorTitle: '', + reportAuthorAvatar: null, + reportUrl: '', + + assetOriginators: [''], + adminMultisig: { + signers: [''], + threshold: 1, + }, + adminMultisigEnabled: false, + poolFees: [createPoolFee()], + poolType: 'open', + + onboarding: { + tranches: {}, + taxInfoRequired: false, + }, + onboardingExperience: 'none', +} diff --git a/centrifuge-app/src/pages/IssuerCreatePool/utils.ts b/centrifuge-app/src/pages/IssuerCreatePool/utils.ts new file mode 100644 index 0000000000..5b17d05ff1 --- /dev/null +++ b/centrifuge-app/src/pages/IssuerCreatePool/utils.ts @@ -0,0 +1,32 @@ +import Centrifuge, { FileType } from '@centrifuge/centrifuge-js' +import { lastValueFrom } from 'rxjs' +import { getFileDataURI } from '../../../src/utils/getFileDataURI' + +const pinFile = async (centrifuge: Centrifuge, file: File): Promise => { + const pinned = await lastValueFrom(centrifuge.metadata.pinFile(await getFileDataURI(file))) + return { uri: pinned.uri, mime: file.type } +} + +export const pinFileIfExists = async (centrifuge: Centrifuge, file: File | null) => + file ? pinFile(centrifuge, file) : null + +export const pinFiles = async (centrifuge: Centrifuge, files: { [key: string]: File | null }) => { + const promises = Object.entries(files).map(async ([key, file]) => { + const pinnedFile = await pinFileIfExists(centrifuge, file) + return { key, pinnedFile } + }) + + const results = await Promise.all(promises) + + return results.reduce((acc, { key, pinnedFile }) => { + if (pinnedFile) { + acc[key] = { + uri: pinnedFile.uri, + mime: files[key]?.type || '', + } + } else { + acc[key] = null + } + return acc + }, {} as { [key: string]: { uri: string; mime: string } | null }) +} diff --git a/centrifuge-app/src/pages/IssuerCreatePool/validate.ts b/centrifuge-app/src/pages/IssuerCreatePool/validate.ts index 9744838687..af2ca35979 100644 --- a/centrifuge-app/src/pages/IssuerCreatePool/validate.ts +++ b/centrifuge-app/src/pages/IssuerCreatePool/validate.ts @@ -1,3 +1,5 @@ +import { FormikErrors, setIn } from 'formik' +import { isSubstrateAddress } from '../../../src/utils/address' import { combine, combineAsync, @@ -18,23 +20,40 @@ import { positiveNumber, required, } from '../../utils/validation' +import { CreatePoolValues } from './types' export const MB = 1024 ** 2 export const validate = { nftImage: combine(imageFile(), maxFileSize(1 * MB)), - poolName: combine(required(), maxLength(100)), - poolIcon: combine(required(), mimeType('image/svg+xml', 'Icon must be an SVG file')), + // pool structure + poolStructure: required(), + trancheStructure: required(), assetClass: required(), subAssetClass: required(), + currency: required(), + + // tranches + tokenName: combine(required(), maxLength(100)), + symbolName: combine(required(), maxLength(12)), + minInvestment: combine(required(), nonNegativeNumber(), max(Number.MAX_SAFE_INTEGER)), + interestRate: combine(required(), positiveNumber(), max(Number.MAX_SAFE_INTEGER)), + minRiskBuffer: combine(required(), positiveNumber(), max(100)), + maxPriceVariation: combine(required(), min(0), max(10000)), + maturityDate: combine(required(), maturityDate()), + apy: required(), + apyPercentage: required(), + + // pool details + poolName: combine(required(), maxLength(100)), + poolIcon: combine(required(), mimeType('image/svg+xml', 'Icon must be an SVG file')), maxReserve: combine(required(), nonNegativeNumber(), max(Number.MAX_SAFE_INTEGER)), - poolType: required(), investorType: required(), + poolType: required(), epochHours: combine(required(), nonNegativeNumber(), integer(), max(24 * 7 /* 1 week */)), epochMinutes: combine(required(), nonNegativeNumber(), integer(), max(59)), - currency: required(), issuerName: combine(required(), maxLength(100)), issuerRepName: combine(required(), maxLength(100)), @@ -49,15 +68,6 @@ export const validate = { issuerDetailTitle: combine(required(), maxLength(50)), issuerDetailBody: combine(required(), maxLength(3000)), - // tranches - tokenName: combine(required(), maxLength(100)), - symbolName: combine(required(), maxLength(12)), - minInvestment: combine(required(), nonNegativeNumber(), max(Number.MAX_SAFE_INTEGER)), - interestRate: combine(required(), positiveNumber(), max(Number.MAX_SAFE_INTEGER)), - minRiskBuffer: combine(required(), positiveNumber(), max(100)), - maxPriceVariation: combine(required(), min(0), max(10000)), - maturityDate: combine(required(), maturityDate()), - // risk groups groupName: maxLength(30), advanceRate: combine(required(), positiveNumber(), max(100)), @@ -73,4 +83,124 @@ export const validate = { days: combine(required(), integer(), nonNegativeNumber(), max(Number.MAX_SAFE_INTEGER)), writeOff: combine(required(), positiveNumber(), max(100)), penaltyInterest: combine(required(), nonNegativeNumber(), max(100)), + + addressValidate: required(), + externalOnboardingUrl: required(), +} + +export const validateValues = (values: CreatePoolValues) => { + let errors: FormikErrors = {} + + const tokenNames = new Set() + const commonTokenSymbolStart = values.tranches[0].symbolName.slice(0, 3) + const tokenSymbols = new Set() + let prevInterest = Infinity + let prevRiskBuffer = 0 + + const juniorInterestRate = + values.tranches[0].apyPercentage !== null ? parseFloat(values.tranches[0].apyPercentage.toString()) : 0 + + if (values.issuerCategories.length > 1) { + values.issuerCategories.forEach((category, i) => { + if (category.type === '') { + errors = setIn(errors, `issuerCategories.${i}.type`, 'Type is required') + } + if (category.value === '') { + errors = setIn(errors, `issuerCategories.${i}.value`, 'Name of provider is required') + } + if (category.type === 'other' && category.description === '') { + errors = setIn(errors, `issuerCategories.${i}.description`, 'Field is required') + } + }) + } + + if (values.poolRatings.length > 1) { + values.poolRatings.forEach((rating, i) => { + if (rating.agency === '') { + errors = setIn(errors, `poolRatings.${i}.agency`, 'Field is required') + } + if (rating.value === '') { + errors = setIn(errors, `poolRatings.${i}.value`, 'Field is required') + } + if (rating.reportUrl === '') { + errors = setIn(errors, `poolRatings.${i}.reportUrl`, 'Field is required') + } + if (rating.reportFile === null) { + errors = setIn(errors, `poolRatings.${i}.reportFile`, 'Field is required') + } + }) + } + + values.poolFees.forEach((fee, i) => { + if (fee.name === '' && i !== 0) { + errors = setIn(errors, `poolFees.${i}.name`, 'Name is required') + } + if (fee.percentOfNav === 0 || fee.percentOfNav < 0.0001 || fee.percentOfNav > 10) { + errors = setIn(errors, `poolFees.${i}.percentOfNav`, 'Percentage between 0.0001 and 10 is required') + } + if (fee.walletAddress === '') { + errors = setIn(errors, `poolFees.${i}.walletAddress`, 'Wallet address is required') + } + if (!isSubstrateAddress(fee?.walletAddress)) { + errors = setIn(errors, `poolFees.${i}.walletAddress`, 'Invalid address') + } + }) + + values.tranches.forEach((t, i) => { + if (tokenNames.has(t.tokenName)) { + errors = setIn(errors, `tranches.${i}.tokenName`, 'Tranche names must be unique') + } + tokenNames.add(t.tokenName) + + // matches any character thats not alphanumeric or - + if (/[^a-z^A-Z^0-9^-]+/.test(t.symbolName)) { + errors = setIn(errors, `tranches.${i}.symbolName`, 'Invalid character detected') + } + + if (tokenSymbols.has(t.symbolName)) { + errors = setIn(errors, `tranches.${i}.symbolName`, 'Token symbols must be unique') + } + tokenSymbols.add(t.symbolName) + + if (t.symbolName.slice(0, 3) !== commonTokenSymbolStart) { + errors = setIn(errors, `tranches.${i}.symbolName`, 'Token symbols must all start with the same 3 characters') + } + + if (i > 0 && t.interestRate !== '') { + if (t.interestRate > juniorInterestRate) { + errors = setIn( + errors, + `tranches.${i}.interestRate`, + "Interest rate can't be higher than the junior tranche's target APY" + ) + } + if (t.interestRate > prevInterest) { + errors = setIn(errors, `tranches.${i}.interestRate`, "Can't be higher than a more junior tranche") + } + prevInterest = t.interestRate + } + + if (t.minRiskBuffer !== '') { + if (t.minRiskBuffer < prevRiskBuffer) { + errors = setIn(errors, `tranches.${i}.minRiskBuffer`, "Can't be lower than a more junior tranche") + } + prevRiskBuffer = t.minRiskBuffer + } + + if (values.assetOriginators.length >= 2) { + values.assetOriginators.forEach((val, idx) => { + const isDuplicated = values.assetOriginators.indexOf(val) !== idx + if (isDuplicated) errors = setIn(errors, `assetOriginators.${idx}`, 'Address already exists') + }) + } + + if (values.adminMultisig.signers.length >= 2) { + values.adminMultisig.signers.forEach((val, idx) => { + const isDuplicated = values.adminMultisig.signers.indexOf(val) !== idx + if (isDuplicated) errors = setIn(errors, `adminMultisig.signers.${idx}`, 'Address already exists') + }) + } + }) + + return errors } diff --git a/centrifuge-app/src/pages/IssuerPool/Configuration/AddressInput.tsx b/centrifuge-app/src/pages/IssuerPool/Configuration/AddressInput.tsx new file mode 100644 index 0000000000..e69de29bb2 diff --git a/centrifuge-app/src/pages/IssuerPool/Configuration/Details.tsx b/centrifuge-app/src/pages/IssuerPool/Configuration/Details.tsx index a88fb8bd1d..8a61118305 100644 --- a/centrifuge-app/src/pages/IssuerPool/Configuration/Details.tsx +++ b/centrifuge-app/src/pages/IssuerPool/Configuration/Details.tsx @@ -17,7 +17,7 @@ import { useFile } from '../../../utils/useFile' import { usePrefetchMetadata } from '../../../utils/useMetadata' import { useSuitableAccounts } from '../../../utils/usePermissions' import { usePool, usePoolMetadata } from '../../../utils/usePools' -import { CreatePoolValues } from '../../IssuerCreatePool' +import { CreatePoolValues } from '../../IssuerCreatePool/types' import { validate } from '../../IssuerCreatePool/validate' type Values = Pick< @@ -174,10 +174,10 @@ export function Details() { form.setFieldTouched('poolIcon', true, false) form.setFieldValue('poolIcon', file) }} - requirements="" label="Pool icon: SVG in square size*" errorMessage={meta.touched ? meta.error : undefined} accept="image/svg+xml" + onClear={() => form.setFieldValue('poolIcon', null)} /> )} @@ -193,7 +193,7 @@ export function Details() { {({ field, meta, form }: FieldProps) => (