From aeebecd0f57ed8464e1a44e173e020012d3a94bc Mon Sep 17 00:00:00 2001
From: Katty Barroso <51223655+kattylucy@users.noreply.github.com>
Date: Wed, 4 Dec 2024 12:04:30 +0100
Subject: [PATCH] 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
---
.../src/components/Menu/IssuerMenu.tsx | 4 +-
centrifuge-app/src/components/Menu/Toggle.tsx | 2 +-
centrifuge-app/src/components/Menu/index.tsx | 4 +
.../pages/IssuerCreatePool/AdminMultisig.tsx | 2 +-
.../IssuerCreatePool/IssuerCategories.tsx | 40 +-
.../IssuerCreatePool/PoolDetailsSection.tsx | 5 +-
.../IssuerCreatePool/PoolSetupSection.tsx | 165 +++++--
.../IssuerCreatePool/PoolStructureSection.tsx | 100 +++--
.../src/pages/IssuerCreatePool/index.tsx | 419 +++++++++++++++++-
.../src/pages/IssuerCreatePool/types.ts | 52 ++-
.../src/pages/IssuerCreatePool/utils.ts | 33 ++
.../src/pages/IssuerCreatePool/validate.ts | 3 +-
centrifuge-js/src/modules/pools.ts | 112 ++---
fabric/src/components/Dialog/index.tsx | 16 +-
fabric/src/components/Toast/index.tsx | 2 +-
fabric/src/icon-svg/icon-chevron-down.svg | 2 +-
16 files changed, 772 insertions(+), 189 deletions(-)
create mode 100644 centrifuge-app/src/pages/IssuerCreatePool/utils.ts
diff --git a/centrifuge-app/src/components/Menu/IssuerMenu.tsx b/centrifuge-app/src/components/Menu/IssuerMenu.tsx
index 24ad7b9a81..1e2385649a 100644
--- a/centrifuge-app/src/components/Menu/IssuerMenu.tsx
+++ b/centrifuge-app/src/components/Menu/IssuerMenu.tsx
@@ -62,9 +62,9 @@ export function IssuerMenu({ defaultOpen = false, children }: IssuerMenuProps) {
Issuer
{isLarge &&
(open ? (
-
+
) : (
-
+
))}
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 1c3e321919..5fe10a79d2 100644
--- a/centrifuge-app/src/components/Menu/index.tsx
+++ b/centrifuge-app/src/components/Menu/index.tsx
@@ -40,6 +40,10 @@ const StyledRouterLinkButton = styled(RouterLinkButton)`
background-color: ${COLOR};
color: white;
}
+
+ &:active {
+ border-color: transparent;
+ }
}
`
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/IssuerCategories.tsx b/centrifuge-app/src/pages/IssuerCreatePool/IssuerCategories.tsx
index 4d70a2f345..311c7e76a7 100644
--- a/centrifuge-app/src/pages/IssuerCreatePool/IssuerCategories.tsx
+++ b/centrifuge-app/src/pages/IssuerCreatePool/IssuerCategories.tsx
@@ -1,5 +1,5 @@
import { PoolMetadataInput } from '@centrifuge/centrifuge-js'
-import { Box, IconButton, IconTrash, Select, Text, TextInput } from '@centrifuge/fabric'
+import { Box, Grid, IconButton, IconTrash, Select, Text, TextInput } from '@centrifuge/fabric'
import { Field, FieldArray, FieldProps, useFormikContext } from 'formik'
import { AddButton } from './PoolDetailsSection'
import { StyledGrid } from './PoolStructureSection'
@@ -38,24 +38,32 @@ export const IssuerCategoriesSection = () => {
{({ push, remove }) => (
<>
- {form.values.issuerCategories.map((_, index) => (
+ {form.values.issuerCategories.map((category, index) => (
<>
-
- {({ field, meta }: FieldProps) => (
-
-
+
- {({ field, meta }: FieldProps) => (
+ {({ field }: FieldProps) => (
{
const form = useFormikContext()
const createLabel = (label: string) => `${label}${isTestEnv ? '' : '*'}`
- console.log(form.values)
-
return (
@@ -193,7 +191,6 @@ export const PoolDetailsSection = () => {
placeholder="Type here..."
maxLength={1000}
errorMessage={meta.touched && meta.error ? meta.error : undefined}
- // disabled={waitingForStoredIssuer}
/>
)}
@@ -299,9 +296,9 @@ export const PoolDetailsSection = () => {
onFileChange={(file) => {
form.setFieldValue('reportAuthorAvatar', file)
}}
- accept="application/pdf"
label="Reviewer avatar"
placeholder="Choose file"
+ accept="image/*"
small
/>
)}
diff --git a/centrifuge-app/src/pages/IssuerCreatePool/PoolSetupSection.tsx b/centrifuge-app/src/pages/IssuerCreatePool/PoolSetupSection.tsx
index b3bfede001..971334ca03 100644
--- a/centrifuge-app/src/pages/IssuerCreatePool/PoolSetupSection.tsx
+++ b/centrifuge-app/src/pages/IssuerCreatePool/PoolSetupSection.tsx
@@ -1,4 +1,5 @@
-import { PoolMetadataInput } from '@centrifuge/centrifuge-js'
+import { addressToHex, evmToSubstrateAddress, PoolMetadataInput } from '@centrifuge/centrifuge-js'
+import { useCentEvmChainId } from '@centrifuge/centrifuge-react'
import {
Box,
Checkbox,
@@ -18,6 +19,7 @@ import { useTheme } from 'styled-components'
import { FieldWithErrorMessage } from '../../../src/components/FieldWithErrorMessage'
import { Tooltips } from '../../../src/components/Tooltips'
import { feeCategories } from '../../../src/config'
+import { isEvmAddress } from '../../../src/utils/address'
import { AddButton } from './PoolDetailsSection'
import { CheckboxOption, Line, StyledGrid } from './PoolStructureSection'
@@ -28,8 +30,30 @@ const FEE_TYPES = [
const FEE_POSISTIONS = [{ 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
@@ -73,7 +97,24 @@ export const PoolSetupSection = () => {
values.adminMultisig?.signers?.map((_, index) => (
- {({ field }: FieldProps) => }
+ {({ field, form }: FieldProps) => (
+ {
+ form.setFieldValue(`adminMultisig.signers.${index}`, val.target.value)
+ }}
+ onBlur={() => {
+ const value = form.values.adminMultisig.signers[index]
+ if (value) {
+ const transformedValue = isEvmAddress(value)
+ ? evmToSubstrateAddress(value, chainId ?? 0)
+ : value
+ form.setFieldValue(`adminMultisig.signers.${index}`, transformedValue)
+ }
+ }}
+ />
+ )}
))
@@ -88,7 +129,7 @@ export const PoolSetupSection = () => {
{
- if (form.values.adminMultisig && form.values.adminMultisig.signers?.length <= 10) {
+ if (values.adminMultisig && values.adminMultisig.signers?.length <= 10) {
push('')
}
}}
@@ -103,19 +144,19 @@ export const PoolSetupSection = () => {
-
+
{({ field, meta, form }: FieldProps) => (
form.setFieldValue('subAssetClass', event.target.value)}
+ onChange={(event) => form.setFieldValue('adminMultisig.threshold', event.target.value)}
onBlur={field.onBlur}
errorMessage={meta.touched && meta.error ? meta.error : undefined}
value={field.value}
- options={form.values.adminMultisig.signers.map((_: string, i: number) => ({
+ options={values.adminMultisig?.signers.map((_: string, i: number) => ({
label: i + 1,
value: i + 1,
}))}
@@ -142,10 +183,27 @@ export const PoolSetupSection = () => {
Add or remove addresses that can:
Originate assets and invest in the pool*
- {form.values.assetOriginators?.map((_: string, index: number) => (
+ {values.assetOriginators?.map((_: string, index: number) => (
- {({ field }: FieldProps) => }
+ {({ field, form }: FieldProps) => (
+ {
+ form.setFieldValue(`assetOriginators.${index}`, val.target.value)
+ }}
+ onBlur={() => {
+ const value = form.values.assetOriginators[index]
+ if (value) {
+ const transformedValue = isEvmAddress(value)
+ ? evmToSubstrateAddress(value, chainId ?? 0)
+ : addressToHex(value)
+ form.setFieldValue(`assetOriginators.${index}`, transformedValue)
+ }
+ }}
+ />
+ )}
))}
@@ -154,7 +212,7 @@ export const PoolSetupSection = () => {
{
- if (form.values.adminMultisig && form.values.adminMultisig.signers?.length <= 10) {
+ if (values.adminMultisig && values.adminMultisig.signers?.length <= 10) {
push('')
}
}}
@@ -207,16 +265,14 @@ export const PoolSetupSection = () => {
{({ push, remove }) => (
<>
- {form.values.poolFees.map((_, index) => (
+ {values.poolFees.map((_, index) => (
Pool fees {index + 1}
- {form.values.poolFees.length > 1 && (
- remove(index)}>
-
-
- )}
+ remove(index)}>
+
+
@@ -325,33 +381,58 @@ export const PoolSetupSection = () => {
icon={}
/>
-
-
- {({ field, meta, form }: FieldProps) => (
-
- {
- form.setFieldTouched('poolIcon', true, false)
- form.setFieldValue('poolIcon', file)
- }}
- label="Click to upload"
- errorMessage={meta.touched && meta.error ? meta.error : undefined}
- accept="application/pdf"
- small
- />
-
- )}
-
-
- Tax document requirement
-
+ {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' && (
+
+ {values.tranches.map((tranche, index) => (
+
+ {({ field, meta }: FieldProps) => (
+
+ Onboarding URL {tranche.tokenName}}
+ isUrl
+ placeholder="www.example.com"
+ onChange={(e: React.ChangeEvent) =>
+ form.setFieldValue(`onboarding.tranches.${tranche.tokenName}`, e.target.value)
+ }
+ />
+
+ )}
+
+ ))}
+
+
+ )}
diff --git a/centrifuge-app/src/pages/IssuerCreatePool/PoolStructureSection.tsx b/centrifuge-app/src/pages/IssuerCreatePool/PoolStructureSection.tsx
index 9a529063d3..2aba393a6e 100644
--- a/centrifuge-app/src/pages/IssuerCreatePool/PoolStructureSection.tsx
+++ b/centrifuge-app/src/pages/IssuerCreatePool/PoolStructureSection.tsx
@@ -16,6 +16,7 @@ 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 = [
@@ -38,6 +39,12 @@ export const StyledGrid = styled(Grid)`
}
`
+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
@@ -58,16 +65,20 @@ export const CheckboxOption = ({
id,
height,
styles,
+ onChange,
+ isChecked,
}: {
name: string
label: string
sublabel?: string
- value: string | number | boolean
+ 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()
@@ -85,20 +96,24 @@ export const CheckboxOption = ({
alignItems={icon ? 'center' : 'flex-start'}
{...styles}
>
-
- {({ field, form, meta }: FieldProps) => (
- form.setFieldValue(name, val.target.checked ? value : null)}
- onBlur={field.onBlur}
- checked={form.values[name] === value}
- />
- )}
-
+ {onChange ? (
+
+ ) : (
+
+ {({ field, form, meta }: FieldProps) => (
+ form.setFieldValue(name, val.target.checked ? value : null)}
+ onBlur={field.onBlur}
+ checked={form.values[name] === value}
+ />
+ )}
+
+ )}
{icon && {icon}} />}
{sublabel && (
@@ -125,7 +140,7 @@ export const PoolStructureSection = () => {
case 0:
return 'Junior'
case 1:
- return values.trancheStructure === 2 ? 'Senior' : 'Mezzanine'
+ return values.tranches.length === 2 ? 'Senior' : 'Mezzanine'
case 2:
return 'Senior'
default:
@@ -140,6 +155,23 @@ export const PoolStructureSection = () => {
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 (
@@ -166,27 +198,19 @@ export const PoolStructureSection = () => {
Define tranche structure *
- }
- />
- }
- />
- }
- />
+
+ {Array.from({ length: 3 }).map((_, index) => {
+ return (
+ }
+ onChange={() => handleTrancheCheckboxChange(index)}
+ isChecked={values.tranches.length === tranches[index].length}
+ />
+ )
+ })}
@@ -259,7 +283,7 @@ export const PoolStructureSection = () => {
Tranches
- {Array.from({ length: values.trancheStructure }).map((_, index) => (
+ {values.tranches.map((_, index) => (
Tranche {index + 1}
diff --git a/centrifuge-app/src/pages/IssuerCreatePool/index.tsx b/centrifuge-app/src/pages/IssuerCreatePool/index.tsx
index 018cde8203..502787c0c3 100644
--- a/centrifuge-app/src/pages/IssuerCreatePool/index.tsx
+++ b/centrifuge-app/src/pages/IssuerCreatePool/index.tsx
@@ -1,12 +1,45 @@
-import { Box, Button, Step, Stepper, Text } from '@centrifuge/fabric'
+import {
+ CurrencyBalance,
+ CurrencyKey,
+ FileType,
+ isSameAddress,
+ Perquintill,
+ PoolFeesCreatePool,
+ PoolMetadataInput,
+ 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 {
+ useAddress,
+ useCentrifuge,
+ useCentrifugeApi,
+ useCentrifugeConsts,
+ useCentrifugeTransaction,
+ useWallet,
+} 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 { initialValues } from './types'
+import { CreatePoolValues, initialValues, PoolFee } from './types'
+import { pinFileIfExists, pinFiles } from './utils'
import { validateValues } from './validate'
const StyledBox = styled(Box)`
@@ -31,20 +64,348 @@ const stepFields: { [key: number]: string[] } = {
3: ['investmentDetails', 'liquidityDetails'],
}
+const txMessage = {
+ immediate: 'Create pool',
+ propose: 'Submit pool proposal',
+ notePreimage: 'Note preimage',
+}
+
const IssuerCreatePoolPage = () => {
const theme = useTheme()
const formRef = useRef(null)
const isSmall = useIsAboveBreakpoint('S')
+ const address = useAddress('substrate')
+ const navigate = useNavigate()
+ const currencies = usePoolCurrencies()
+ const centrifuge = useCentrifuge()
+ const api = useCentrifugeApi()
+ const pools = usePools()
+ const { poolCreationType } = useDebugFlags()
+ const consts = useCentrifugeConsts()
+ const { chainDecimals } = useCentrifugeConsts()
+ const createType = (poolCreationType as TransactionOptions['createType']) || config.poolCreationType || 'immediate'
+ const {
+ substrate: { addMultisig },
+ } = useWallet()
+
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(true)
+ const [createdModal, setCreatedModal] = useState(false)
+ const [preimageHash, setPreimageHash] = useState('')
+ const [isPreimageDialogOpen, setIsPreimageDialogOpen] = useState(false)
+ const [createdPoolId, setCreatedPoolId] = useState('')
+
+ 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
+ setPreimageHash(parsedEvent.event.data[0])
+ setIsPreimageDialogOpen(true)
+ })
+ )
+ .subscribe()
+ return () => $events.unsubscribe()
+ }
+ }, [centrifuge, createType])
+
+ useEffect(() => {
+ if (createdPoolId && pools?.find((p) => p.id === createdPoolId)) {
+ // 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}`)
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [pools, createdPoolId])
+
+ 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 { execute: createPoolTx, isLoading: transactionIsPending } = useCentrifugeTransaction(
+ `${txMessage[createType]} 2/2`,
+ (cent) =>
+ (
+ args: [
+ values: CreatePoolValues,
+ transferToMultisig: BN,
+ aoProxy: string,
+ adminProxy: string,
+ poolId: string,
+ tranches: TrancheCreatePool[],
+ currency: CurrencyKey,
+ maxReserve: BN,
+ metadata: PoolMetadataInput,
+ poolFees: PoolFeesCreatePool[]
+ ],
+ options
+ ) => {
+ 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([
+ cent.getApi(),
+ cent.pools.createPool(poolArgs, { createType: options?.createType, batch: true }),
+ ]).pipe(
+ switchMap(([api, poolSubmittable]) => {
+ const adminProxyDelegates = multisigAddr
+ ? [multisigAddr]
+ : (adminMultisig && values.adminMultisig?.signers?.filter((addr) => addr !== address)) ?? []
+ const otherMultisigSigners =
+ multisigAddr && sortAddresses(adminMultisig.signers.filter((addr) => !isSameAddress(addr, address!)))
+ const proxiedPoolCreate = api.tx.proxy.proxy(adminProxy, undefined, poolSubmittable)
+ const submittable = api.tx.utility.batchAll(
+ [
+ api.tx.balances.transferKeepAlive(adminProxy, consts.proxy.proxyDepositFactor.add(transferToMultisig)),
+ api.tx.balances.transferKeepAlive(
+ aoProxy,
+ consts.proxy.proxyDepositFactor
+ .add(consts.uniques.collectionDeposit)
+ .add(consts.proxy.proxyDepositFactor.mul(new BN(assetOriginators.length * 4)))
+ ),
+ adminProxyDelegates.length > 0 &&
+ api.tx.proxy.proxy(
+ adminProxy,
+ undefined,
+ api.tx.utility.batchAll(
+ [
+ ...adminProxyDelegates.map((addr) => api.tx.proxy.addProxy(addr, 'Any', 0)),
+ multisigAddr ? api.tx.proxy.removeProxy(address, 'Any', 0) : null,
+ ].filter(Boolean)
+ )
+ ),
+ api.tx.proxy.proxy(
+ aoProxy,
+ undefined,
+ 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)
+ : proxiedPoolCreate,
+ ].filter(Boolean)
+ )
+ setMultisigData({ callData: proxiedPoolCreate.method.toHex(), hash: proxiedPoolCreate.method.hash.toHex() })
+ return cent.wrapSignAndSend(api, submittable, { ...options })
+ })
+ )
+ },
+ {
+ onSuccess: (args, result) => {
+ if (form.values.adminMultisigEnabled && form.values.adminMultisig.threshold > 1) {
+ setIsMultisigDialogOpen(true)
+ }
+ const [, , , , poolId] = args
+ console.log(poolId, result)
+ if (createType === 'immediate') {
+ setCreatedPoolId(poolId)
+ } else {
+ setCreatedModal(true)
+ }
+ },
+ }
+ )
const form = useFormik({
initialValues,
validate: (values) => validateValues(values),
validateOnMount: true,
- onSubmit: () => console.log('a'),
+ onSubmit: async (values, { setSubmitting }) => {
+ const poolId = await centrifuge.pools.getAvailablePoolId()
+
+ if (!currencies || !address) return
+
+ const metadataValues: PoolMetadataInput = { ...values } as any
+
+ // Find the currency (asset denomination in UI)
+ const currency = currencies.find((c) => c.symbol.toLowerCase() === values.assetDenomination.toLowerCase())!
+
+ // Handle pining files for ipfs
+ if (!values.poolIcon) return
+
+ const filesToPin = {
+ poolIcon: values.poolIcon,
+ issuerLogo: values.issuerLogo,
+ executiveSummary: values.executiveSummary,
+ authorAvatar: values.reportAuthorAvatar,
+ }
+
+ 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
+
+ // Pool report
+ if (values.reportUrl) {
+ metadataValues.poolReport = {
+ authorAvatar: pinnedFiles.authorAvatar,
+ authorName: values.reportAuthorName,
+ authorTitle: values.reportAuthorTitle,
+ url: values.reportUrl,
+ }
+ }
+
+ // Pool ratings
+ if (values.poolRatings) {
+ const newRatingReports = await Promise.all(
+ values.poolRatings.map((rating) => pinFileIfExists(centrifuge, rating.reportFile ?? null))
+ )
+
+ const ratings = values.poolRatings.map((rating, index) => {
+ const pinnedReport = newRatingReports[index]
+ return {
+ agency: rating.agency ?? '',
+ value: rating.value ?? '',
+ reportUrl: rating.reportUrl ?? '',
+ reportFile: pinnedReport ? { uri: pinnedReport.uri, mime: rating.reportFile?.type ?? '' } : null,
+ }
+ })
+
+ metadataValues.poolRatings = ratings
+ }
+
+ // 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 metadataPoolFees: Pick[] = []
+ const feeInput: PoolFeesCreatePool = []
+
+ values.poolFees.forEach((fee, index) => {
+ metadataPoolFees.push({
+ name: fee.name,
+ id: feeId ? feeId + index : 0,
+ feePosition: fee.feePosition,
+ feeType: fee.feeType,
+ })
+
+ feeInput.push([
+ 'Top',
+ {
+ destination: fee.walletAddress,
+ editor: fee.feeType === 'chargedUpTo' ? { account: fee.walletAddress } : 'Root',
+ feeType: {
+ [fee.feeType]: { limit: { ['ShareOfPortfolioValuation']: Rate.fromPercent(fee.percentOfNav) } },
+ },
+ },
+ ])
+ })
+
+ metadataValues.poolFees = metadataPoolFees
+
+ // 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: {},
+ }
+ }
+
+ createProxies([
+ (aoProxy, adminProxy) => {
+ createPoolTx(
+ [
+ values,
+ CurrencyBalance.fromFloat(createDeposit, chainDecimals),
+ aoProxy,
+ adminProxy,
+ poolId,
+ tranches,
+ currency.key,
+ CurrencyBalance.fromFloat(values.maxReserve, currency.decimals),
+ metadataValues,
+ feeInput,
+ ],
+ { createType }
+ )
+ },
+ ])
+
+ setSubmitting(false)
+ },
})
+ 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 { values, errors } = form
const checkStepCompletion = (stepNumber: number) => {
@@ -58,7 +419,11 @@ const IssuerCreatePoolPage = () => {
}
const handleNextStep = () => {
- setStep((prevStep) => prevStep + 1)
+ if (step === 3) {
+ form.handleSubmit()
+ } else {
+ setStep((prevStep) => prevStep + 1)
+ }
}
useEffect(() => {
@@ -70,6 +435,20 @@ const IssuerCreatePoolPage = () => {
return (
<>
+ setIsPreimageDialogOpen(false)}
+ />
+ {multisigData && (
+ setIsMultisigDialogOpen(false)}
+ />
+ )}
+ {createdModal && (
+
+ )}
>
)
}
diff --git a/centrifuge-app/src/pages/IssuerCreatePool/types.ts b/centrifuge-app/src/pages/IssuerCreatePool/types.ts
index b6f9c8eac5..12d64725da 100644
--- a/centrifuge-app/src/pages/IssuerCreatePool/types.ts
+++ b/centrifuge-app/src/pages/IssuerCreatePool/types.ts
@@ -9,6 +9,16 @@ export interface Tranche {
apy: string
interestRate: number | ''
}
+
+export interface PoolFee {
+ id: number
+ name: string
+ feeType: FeeTypes
+ percentOfNav: string
+ walletAddress: string
+ feePosition: 'Top of waterfall'
+ category: string
+}
export interface WriteOffGroupInput {
days: number | ''
writeOff: number | ''
@@ -24,11 +34,12 @@ export const createEmptyTranche = (trancheName: string): Tranche => ({
apy: '90d',
})
-export const createPoolFee = () => ({
+export const createPoolFee = (): PoolFee => ({
+ id: 0,
name: '',
category: '',
- feePosition: '',
- feeType: '',
+ feePosition: 'Top of waterfall',
+ feeType: '' as FeeTypes,
percentOfNav: '',
walletAddress: '',
})
@@ -39,11 +50,12 @@ export type CreatePoolValues = Omit<
> & {
// pool structure
assetDenomination: string
- trancheStructure: 1 | 2 | 3
// pool details
- poolType: 'open' | 'closed'
issuerCategories: { type: string; value: string }[]
+ poolIcon: File | null
+ issuerLogo: File | null
+ executiveSummary: File | null
reportAuthorName: string
reportAuthorTitle: string
@@ -51,15 +63,7 @@ export type CreatePoolValues = Omit<
reportUrl: string
adminMultisigEnabled: boolean
adminMultisig: Exclude
- poolFees: {
- id?: number
- name: string
- feeType: FeeTypes
- percentOfNav: number | ''
- walletAddress: string
- feePosition: 'Top of waterfall'
- category: string
- }[]
+ poolFees: PoolFee[]
poolRatings: {
agency?: string
value?: string
@@ -71,18 +75,14 @@ export type CreatePoolValues = Omit<
export const initialValues: CreatePoolValues = {
// pool structure
poolStructure: 'revolving',
- trancheStructure: 1,
assetClass: 'Private credit',
- assetDenomination: '',
+ assetDenomination: isTestEnv ? 'USDC' : 'Native USDC',
subAssetClass: '',
-
- // pool structure -> tranches
- tranches: [createEmptyTranche('')],
+ tranches: [createEmptyTranche('Junior')],
// pool details section
poolName: '',
poolIcon: null,
- currency: isTestEnv ? 'USDC' : 'Native USDC',
maxReserve: 1000000,
investorType: '',
issuerName: '',
@@ -104,10 +104,18 @@ export const initialValues: CreatePoolValues = {
assetOriginators: [''],
adminMultisig: {
- signers: ['', ''],
+ signers: [''],
threshold: 1,
},
adminMultisigEnabled: false,
- poolFees: [createPoolFee()],
+ poolFees: [],
poolType: 'open',
+
+ onboarding: {
+ tranches: {},
+ taxInfoRequired: false,
+ },
+ onboardingExperience: 'none',
+ epochHours: 0,
+ epochMinutes: 0,
}
diff --git a/centrifuge-app/src/pages/IssuerCreatePool/utils.ts b/centrifuge-app/src/pages/IssuerCreatePool/utils.ts
new file mode 100644
index 0000000000..dfd5d64d79
--- /dev/null
+++ b/centrifuge-app/src/pages/IssuerCreatePool/utils.ts
@@ -0,0 +1,33 @@
+import { FileType } from '@centrifuge/centrifuge-js'
+import { useCentrifuge } from '@centrifuge/centrifuge-react'
+import { lastValueFrom } from 'rxjs'
+import { getFileDataURI } from '../../../src/utils/getFileDataURI'
+
+const pinFile = async (centrifuge: ReturnType, 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: ReturnType, file: File | null) =>
+ file ? pinFile(centrifuge, file) : Promise.resolve(null)
+
+export const pinFiles = async (centrifuge: ReturnType, 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 18052c55e2..7c618bec82 100644
--- a/centrifuge-app/src/pages/IssuerCreatePool/validate.ts
+++ b/centrifuge-app/src/pages/IssuerCreatePool/validate.ts
@@ -20,6 +20,7 @@ import {
positiveNumber,
required,
} from '../../utils/validation'
+import { CreatePoolValues } from './types'
export const MB = 1024 ** 2
@@ -83,7 +84,7 @@ export const validate = {
penaltyInterest: combine(required(), nonNegativeNumber(), max(100)),
}
-export const validateValues = (values) => {
+export const validateValues = (values: CreatePoolValues) => {
let errors: FormikErrors = {}
const tokenNames = new Set()
diff --git a/centrifuge-js/src/modules/pools.ts b/centrifuge-js/src/modules/pools.ts
index 553d5cb399..575b0c612d 100644
--- a/centrifuge-js/src/modules/pools.ts
+++ b/centrifuge-js/src/modules/pools.ts
@@ -665,60 +665,59 @@ interface TrancheFormValues {
}
export interface PoolMetadataInput {
- // pool structure
- poolStructure: string
+ // structure
+ poolStructure: 'revolving'
assetClass: 'Public credit' | 'Private credit'
subAssetClass: string
-
- // pool structure -> tranches
tranches: TrancheFormValues[]
// details
poolName: string
- assetDenomination: string
investorType: string
- poolIcon: FileType | null
+ poolIcon: FileType
+ poolType: 'open' | 'closed'
maxReserve: number | ''
issuerName: string
- issuerLogo?: FileType | null
issuerRepName: string
+ issuerLogo: FileType
issuerShortDescription: string
issuerDescription: string
- issuerCategories: { type: string; value: string; customType?: string }[]
website: string
forum: string
email: string
executiveSummary: FileType | null
details?: IssuerDetail[]
-
- currency: string
- epochHours: number | ''
- epochMinutes: number | ''
- listed?: boolean
-
- poolReport?: {
- authorName: string
- authorTitle: string
- authorAvatar: FileType | null
- url: string
- }
+ issuerCategories: { type: string; value: string; description?: string }[]
poolRatings: {
agency?: string
value?: string
reportUrl?: string
reportFile?: FileType | null
}[]
+ poolReport?: {
+ authorName: string
+ authorTitle: string
+ authorAvatar: FileType | null
+ url: string
+ }
+ // setup
adminMultisig?: {
signers: string[]
threshold: number
}
-
poolFees: { id: number; name: string; feePosition: 'Top of waterfall'; category?: string; feeType: FeeTypes }[]
-
- poolType: 'open' | 'closed'
-
adminMultisigEnabled: boolean
+ assetOriginators: string[]
+ onboardingExperience: string
+ onboarding?: {
+ tranches: { [trancheId: string]: { agreement: FileType | undefined; openForOnboarding: boolean } }
+ taxInfoRequired?: boolean
+ }
+
+ listed?: boolean
+ epochHours: number | ''
+ epochMinutes: number | ''
}
export type WithdrawAddress = {
@@ -1022,6 +1021,38 @@ export type AddFee = {
poolId: string
}
+export type PoolFeesCreatePool = Array<
+ [
+ string,
+ {
+ destination: string
+ editor: any
+ feeType: {
+ [key: string]: {
+ limit: {
+ ShareOfPortfolioValuation: Rate
+ }
+ }
+ }
+ }
+ ]
+>
+
+export type TrancheCreatePool = {
+ trancheType:
+ | 'Residual'
+ | {
+ NonResidual: {
+ interestRatePerSec: string
+ minRiskBuffer: string
+ }
+ }
+ metadata: {
+ tokenName: string
+ tokenSymbol: string
+ }
+}
+
const formatPoolKey = (keys: StorageKey<[u32]>) => (keys.toHuman() as string[])[0].replace(/\D/g, '')
const formatLoanKey = (keys: StorageKey<[u32, u32]>) => (keys.toHuman() as string[])[1].replace(/\D/g, '')
@@ -1032,40 +1063,15 @@ export function getPoolsModule(inst: Centrifuge) {
args: [
admin: string,
poolId: string,
- tranches: TrancheInput[],
+ tranches: TrancheCreatePool[],
currency: CurrencyKey,
maxReserve: BN,
metadata: PoolMetadataInput,
- fees: AddFee['fee'][]
+ fees: PoolFeesCreatePool[]
],
options?: TransactionOptions
) {
const [admin, poolId, tranches, currency, maxReserve, metadata, fees] = args
- const trancheInput = tranches.map((t, i) => ({
- trancheType: t.interestRatePerSec
- ? {
- NonResidual: {
- interestRatePerSec: t.interestRatePerSec.toString(),
- minRiskBuffer: t.minRiskBuffer?.toString(),
- },
- }
- : 'Residual',
- metadata: {
- tokenName: metadata.tranches[i].tokenName,
- tokenSymbol: metadata.tranches[i].symbolName,
- },
- }))
-
- const feeInput = fees.map((fee) => {
- return [
- 'Top',
- {
- destination: fee.destination,
- editor: fee?.account ? { account: fee.account } : 'Root',
- feeType: { [fee.feeType]: { limit: { [fee.limit]: fee?.amount } } },
- },
- ]
- })
return inst.getApi().pipe(
switchMap((api) =>
@@ -1079,12 +1085,12 @@ export function getPoolsModule(inst: Centrifuge) {
const tx = api.tx.poolRegistry.register(
admin,
poolId,
- trancheInput,
+ tranches,
currency,
maxReserve.toString(),
pinnedMetadata.ipfsHash,
[],
- feeInput
+ fees
)
if (options?.createType === 'propose') {
const proposalTx = api.tx.utility.batchAll([
diff --git a/fabric/src/components/Dialog/index.tsx b/fabric/src/components/Dialog/index.tsx
index 734cf807fd..4ee1fbc066 100644
--- a/fabric/src/components/Dialog/index.tsx
+++ b/fabric/src/components/Dialog/index.tsx
@@ -23,9 +23,19 @@ export type DialogProps = React.PropsWithChildren<{
title?: string | React.ReactElement
subtitle?: string | React.ReactElement
icon?: React.ComponentType | React.ReactElement
+ hideButton?: boolean
}>
-function DialogInner({ children, isOpen, onClose, width = 'dialog', icon: IconComp, title, subtitle }: DialogProps) {
+function DialogInner({
+ children,
+ isOpen,
+ onClose,
+ width = 'dialog',
+ icon: IconComp,
+ title,
+ subtitle,
+ hideButton = false,
+}: DialogProps) {
const ref = React.useRef(null)
const underlayRef = React.useRef(null)
const { overlayProps, underlayProps } = useOverlay(
@@ -78,7 +88,9 @@ function DialogInner({ children, isOpen, onClose, width = 'dialog', icon: IconCo
title
)}
-