From d2f23ffa7f68c0853d1dfd0ecaf0dbe09cf44758 Mon Sep 17 00:00:00 2001 From: katty barroso Date: Thu, 21 Nov 2024 10:51:42 +0100 Subject: [PATCH 01/29] Fabric changes --- .../src/components/FieldWithErrorMessage.tsx | 30 +++- fabric/src/components/Checkbox/index.tsx | 116 +++++++----- fabric/src/components/FileUpload/index.tsx | 158 ++++++++++------ fabric/src/components/Stepper/index.tsx | 170 ++++++++---------- fabric/src/components/TextInput/index.tsx | 63 ++++++- fabric/src/icon-svg/icon-upload.svg | 11 +- fabric/src/theme/tokens/colors.ts | 2 +- fabric/src/theme/tokens/theme.ts | 2 +- 8 files changed, 345 insertions(+), 207 deletions(-) diff --git a/centrifuge-app/src/components/FieldWithErrorMessage.tsx b/centrifuge-app/src/components/FieldWithErrorMessage.tsx index eef59b73ef..6490da9328 100644 --- a/centrifuge-app/src/components/FieldWithErrorMessage.tsx +++ b/centrifuge-app/src/components/FieldWithErrorMessage.tsx @@ -1,11 +1,35 @@ -import { Field, FieldAttributes, useField } from 'formik' +import { Box, URLInput } from '@centrifuge/fabric' +import { Field, FieldAttributes, useField, useFormikContext } from 'formik' import * as React from 'react' type Props = FieldAttributes & { label?: string | React.ReactElement + prefix?: string + isUrl?: boolean } export function FieldWithErrorMessage(props: Props) { - const [, meta] = useField(props) - return + const [field, meta] = useField(props) + const form = useFormikContext() + + const handleChange = (event: React.ChangeEvent) => { + form.setFieldValue(field.name, event.target.value) + } + + return props.isUrl ? ( + + + + ) : ( + + ) } diff --git a/fabric/src/components/Checkbox/index.tsx b/fabric/src/components/Checkbox/index.tsx index 1de1e3c69c..1553241be2 100644 --- a/fabric/src/components/Checkbox/index.tsx +++ b/fabric/src/components/Checkbox/index.tsx @@ -10,15 +10,22 @@ type CheckboxProps = React.InputHTMLAttributes & { label?: string | React.ReactElement errorMessage?: string extendedClickArea?: boolean + variant?: 'square' | 'round' } -export function Checkbox({ label, errorMessage, extendedClickArea, ...checkboxProps }: CheckboxProps) { +export function Checkbox({ + label, + errorMessage, + extendedClickArea, + variant = 'round', + ...checkboxProps +}: CheckboxProps) { return ( - + {label && ( @@ -52,6 +59,8 @@ export function Checkbox({ label, errorMessage, extendedClickArea, ...checkboxPr const StyledLabel = styled.label<{ $extendedClickArea: boolean }>` cursor: pointer; user-select: none; + display: flex; + align-items: center; &:before { --offset: 10px; @@ -74,23 +83,9 @@ const StyledLabel = styled.label<{ $extendedClickArea: boolean }>` } ` -const StyledOutline = styled.span` - display: none; - pointer-events: none; - position: absolute; - top: -4px; - right: -4px; - bottom: -4px; - left: -4px; - width: auto; - height: auto; - margin: auto; - border: 2px solid var(--fabric-focus); - border-radius: 4px; -` - const StyledWrapper = styled(Flex)<{ $hasLabel: boolean }>` position: relative; + align-items: center; &::before { content: '.'; @@ -100,37 +95,72 @@ const StyledWrapper = styled(Flex)<{ $hasLabel: boolean }>` } ` -const StyledCheckbox = styled.input` +const StyledCheckbox = styled.input<{ $variant: 'square' | 'round' }>` width: 18px; height: 18px; - align-self: center; - margin: -20px 0; - cursor: pointer; appearance: none; - border: 2px solid ${({ theme }) => theme.colors.borderPrimary}; - border-radius: 4px; - background-color: ${({ theme }) => theme.colors.backgroundPrimary}; - transition: background-color 0.2s ease, border-color 0.2s ease; + border-radius: ${({ $variant }) => ($variant === 'square' ? '2px' : '50%')}; + border: 1px solid ${({ theme }) => theme.colors.borderPrimary}; + position: relative; + cursor: pointer; + transition: background-color 0.2s, border-color 0.2s; - &:checked { - background-color: ${({ theme }) => theme.colors.textGold}; - border-color: ${({ theme }) => theme.colors.textGold}; - } + ${({ $variant, theme }) => + $variant === 'round' && + ` + &:checked { + border-color: ${theme.colors.textPrimary}; + } - &:checked::after { - content: ''; - display: block; - width: 6px; - height: 10px; - border: solid ${({ theme }) => theme.colors.backgroundPrimary}; - border-width: 0 2px 2px 0; - transform: rotate(45deg); - position: absolute; - top: 3px; - left: 6px; - } + &:checked::after { + content: ''; + position: absolute; + top: 4px; + left: 4px; + width: 8px; + height: 8px; + border-radius: 50%; + background-color: ${theme.colors.textPrimary}; + } + `} + + ${({ $variant, theme }) => + $variant === 'square' && + ` + &:checked { + border-color: ${theme.colors.borderSecondary}; + background-color: ${theme.colors.textGold}; + } - &:focus-visible + span { - display: block; + &:checked::after { + content: ''; + position: absolute; + top: 2px; + left: 5px; + width: 6px; + height: 10px; + border: solid white; + border-width: 0 2px 2px 0; + transform: rotate(45deg); + } + `} + + &:focus-visible { + outline: none; } ` + +const StyledOutline = styled.span` + display: none; + pointer-events: none; + position: absolute; + top: -4px; + right: -4px; + bottom: -4px; + left: -4px; + width: auto; + height: auto; + margin: auto; + border: 2px solid var(--fabric-focus); + border-radius: 100%; +` diff --git a/fabric/src/components/FileUpload/index.tsx b/fabric/src/components/FileUpload/index.tsx index a605c31f9d..7a204d8d97 100644 --- a/fabric/src/components/FileUpload/index.tsx +++ b/fabric/src/components/FileUpload/index.tsx @@ -1,21 +1,17 @@ import * as React from 'react' -import styled, { keyframes } from 'styled-components' -import { InputAction, InputUnit, InputUnitProps, StyledTextInput, TextInputBox } from '../..' -import IconSpinner from '../../icon/IconSpinner' -import IconX from '../../icon/IconX' +import styled from 'styled-components' +import { Box, IconUpload, InputUnit, InputUnitProps, StyledTextInput, Text } from '../..' import { useControlledState } from '../../utils/useControlledState' -import { Button } from '../Button' -import { Flex } from '../Flex' import { Stack } from '../Stack' -const rotate = keyframes` - 0% { - transform: rotate(0); - } - 100% { - transform: rotate(1turn); - } -` +// const rotate = keyframes` +// 0% { +// transform: rotate(0); +// } +// 100% { +// transform: rotate(1turn); +// } +// ` const FormField = styled.input` // Visually hidden @@ -29,12 +25,22 @@ const FormField = styled.input` width: 1px; ` -const Spinner = styled(IconSpinner)` - animation: ${rotate} 600ms linear infinite; -` +// const Spinner = styled(IconSpinner)` +// animation: ${rotate} 600ms linear infinite; +// ` -const FileDragOverContainer = styled(Stack)<{ $disabled?: boolean; $active: boolean }>` +const FileDragOverContainer = styled(Stack)<{ $disabled?: boolean; $active: boolean; small?: boolean }>` position: relative; + height: ${({ small }) => (small ? '44px' : '144px')}; + background-color: #ffffff; + border: 1px solid ${({ theme }) => theme.colors.borderPrimary}; + border-radius: ${({ theme }) => theme.radii.input}px; + display: flex; + justify-content: center; + align-items: center; + text-align: center; + cursor: ${({ $disabled }) => ($disabled ? 'not-allowed' : 'pointer')}; + padding: 16px; &::before { content: ''; width: 100%; @@ -50,6 +56,17 @@ const FileDragOverContainer = styled(Stack)<{ $disabled?: boolean; $active: bool } ` +const FileInputContent = styled(Box)` + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + width: 100%; + padding: 16px 24px; + border-radius: ${({ theme }) => theme.radii.input}px; + background-color: #ffffff; +` + export type FileUploadProps = Omit, 'onChange'> & InputUnitProps & { file?: File | string | null @@ -57,6 +74,8 @@ export type FileUploadProps = Omit, onClear?: () => void validate?: (file: File) => string | undefined loading?: boolean + fileTypeText?: string + small?: boolean } export function FileUpload({ @@ -71,6 +90,8 @@ export function FileUpload({ label, secondaryLabel, id, + fileTypeText, + small, ...inputProps }: FileUploadProps) { const defaultId = React.useId() @@ -140,6 +161,12 @@ export function FileUpload({ setDragOver(false) } + function handleContainerClick() { + if (!disabled) { + handleUploadBtnClick() + } + } + return ( - + + {' '} + {(curFile && typeof curFile !== 'string' && curFile.name) || 'Click to upload'} + + + + )} + {!small && ( + + + + + + + {(curFile && typeof curFile !== 'string' && curFile.name) || 'Click to upload'} + + {curFile && typeof curFile !== 'string' && curFile.name ? ( + '' + ) : ( + + {' '} + or drag and drop + + )} + + {curFile && typeof curFile !== 'string' && curFile.name ? ( + '' + ) : ( + {fileTypeText} + )} + + + )} + - - - - } - symbol={ - loading ? ( - - ) : curFile && !disabled ? ( - - + + New pool setup + + + + + + + + + + {step === 1 && } + {step === 2 && } + {step === 3 && } + + + {step !== 1 && ( - - - + )} + + + ) } + +export default IssuerCreatePoolPage diff --git a/centrifuge-app/src/pages/IssuerCreatePool/oldindex.tsx b/centrifuge-app/src/pages/IssuerCreatePool/oldindex.tsx new file mode 100644 index 0000000000..17fe1b6875 --- /dev/null +++ b/centrifuge-app/src/pages/IssuerCreatePool/oldindex.tsx @@ -0,0 +1,834 @@ +import { CurrencyBalance, isSameAddress, Perquintill, Rate, TransactionOptions } from '@centrifuge/centrifuge-js' +import { + AddFee, + CurrencyKey, + FeeTypes, + FileType, + PoolMetadataInput, + TrancheInput, +} from '@centrifuge/centrifuge-js/dist/modules/pools' +import { + useBalances, + useCentrifuge, + useCentrifugeConsts, + useCentrifugeTransaction, + useWallet, +} from '@centrifuge/centrifuge-react' +import { + Box, + Button, + CurrencyInput, + FileUpload, + Grid, + Select, + Shelf, + Step, + Stepper, + Text, + TextInput, + 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 { useTheme } from 'styled-components' +import { useDebugFlags } from '../../components/DebugFlags' +import { PreimageHashDialog } from '../../components/Dialogs/PreimageHashDialog' +import { ShareMultisigDialog } from '../../components/Dialogs/ShareMultisigDialog' +import { FieldWithErrorMessage } from '../../components/FieldWithErrorMessage' +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 { 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 + trancheStucture: 1 | 2 | 3 +} + +const initialValues: CreatePoolValues = { + poolStructure: '', + trancheStucture: 0, + + 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: '', + 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', +} + +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 ? : +} + +function CreatePoolForm() { + const theme = useTheme() + const [activeStep, setActiveStep] = React.useState(1) + + 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 { poolCreationType } = useDebugFlags() + const consts = useCentrifugeConsts() + const createType = (poolCreationType as TransactionOptions['createType']) || config.poolCreationType || 'immediate' + + 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]) + + React.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 txMessage = { + immediate: 'Create pool', + propose: 'Submit pool proposal', + notePreimage: 'Note preimage', + } + const { execute: createPoolTx, isLoading: transactionIsPending } = useCentrifugeTransaction( + `${txMessage[createType]} 2/2`, + (cent) => + ( + args: [ + values: CreatePoolValues, + transferToMultisig: BN, + aoProxy: string, + adminProxy: string, + poolId: string, + tranches: TrancheInput[], + currency: CurrencyKey, + maxReserve: BN, + metadata: PoolMetadataInput, + poolFees: AddFee['fee'][] + ], + options + ) => { + const [values, transferToMultisig, aoProxy, adminProxy, , , , , { adminMultisig }] = 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) + ), + 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), + api.tx.proxy.removeProxy(address, 'Any', 0), + ]) + ), + 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, multisig: undefined, proxies: undefined }) + }) + ) + }, + { + onSuccess: (args) => { + if (form.values.adminMultisigEnabled && form.values.adminMultisig.threshold > 1) setIsMultisigDialogOpen(true) + const [, , , , poolId] = args + if (createType === 'immediate') { + setCreatedPoolId(poolId) + } + }, + } + ) + + 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 + }, + validateOnMount: true, + onSubmit: async (values, { setSubmitting }) => { + 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)! + + // Pool ID and required assets + const poolId = await centrifuge.pools.getAvailablePoolId() + if (!values.poolIcon || (!isTestEnv && !values.executiveSummary)) { + return + } + + const pinFile = async (file: File): Promise => { + const pinned = await lastValueFrom(centrifuge.metadata.pinFile(await getFileDataURI(file))) + return { uri: pinned.uri, mime: file.type } + } + + // Handle pinning files (pool icon, issuer logo, and executive summary) + const promises = [pinFile(values.poolIcon)] + + 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 + 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, + 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)) + ) + 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 + } + return { + agency: rating.agency ?? '', + value: rating.value ?? '', + reportUrl: rating.reportUrl ?? '', + reportFile: reportFile ?? 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), + })), + ] + + const feeId = await firstValueFrom(centrifuge.pools.getNextPoolFeeId()) + const poolFees: AddFee['fee'][] = values.poolFees.map((fee, i) => { + return { + name: fee.name, + destination: fee.walletAddress, + amount: Rate.fromPercent(fee.percentOfNav), + feeType: fee.feeType, + limit: 'ShareOfPortfolioValuation', + account: fee.feeType === 'chargedUpTo' ? fee.walletAddress : undefined, + feePosition: fee.feePosition, + } + }) + metadataValues.poolFees = poolFees.map((fee, i) => ({ + name: fee.name, + id: feeId + i, + feePosition: fee.feePosition, + feeType: fee.feeType, + })) + + if (metadataValues.adminMultisig && metadataValues.adminMultisig.threshold > 1) { + addMultisig(metadataValues.adminMultisig) + } + + createProxies([ + (aoProxy, adminProxy) => { + createPoolTx( + [ + values, + CurrencyBalance.fromFloat(createDeposit, chainDecimals), + aoProxy, + adminProxy, + poolId, + tranches, + currency.key, + CurrencyBalance.fromFloat(values.maxReserve, currency.decimals), + metadataValues, + poolFees, + ], + { createType } + ) + }, + ]) + + setSubmitting(false) + }, + }) + + 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, + })) ?? [] + + // Use useEffect to update tranche name when poolName changes + React.useEffect(() => { + if (form.values.poolName) { + form.setFieldValue('tranches', [createEmptyTranche(form.values.poolName)]) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [form.values.poolName]) + + return ( + <> + setIsPreimageDialogOpen(false)} + /> + {multisigData && ( + setIsMultisigDialogOpen(false)} + /> + )} + +
+ + New pool setup + + + + + + + + + + + + + + + + {({ 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)} + + + + + + + +
+ + ) +} diff --git a/centrifuge-app/src/pages/IssuerCreatePool/types.ts b/centrifuge-app/src/pages/IssuerCreatePool/types.ts new file mode 100644 index 0000000000..4cbb8e55cd --- /dev/null +++ b/centrifuge-app/src/pages/IssuerCreatePool/types.ts @@ -0,0 +1,112 @@ +import { FeeTypes, PoolMetadataInput } from '@centrifuge/centrifuge-js' +import { isTestEnv } from '../../config' + +export interface Tranche { + tokenName: string + symbolName: string + minRiskBuffer: number | '' + minInvestment: number | '' + apy: string + interestRate: number | '' +} +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', +}) + +export const createPoolFee = () => ({ + name: '', + category: '', + feePosition: '', + feeType: '', + percentOfNav: '', + walletAddress: '', +}) + +export type CreatePoolValues = Omit< + PoolMetadataInput, + 'poolIcon' | 'issuerLogo' | 'executiveSummary' | 'adminMultisig' | 'poolFees' | 'poolReport' | 'poolRatings' +> & { + // pool structure + assetDenomination: string + trancheStructure: 1 | 2 | 3 + + // pool details + poolType: 'open' | 'closed' + issuerCategories: { type: string; value: string }[] + + 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 + }[] + poolRatings: { + agency?: string + value?: string + reportUrl?: string + reportFile?: File | null + }[] +} + +export const initialValues: CreatePoolValues = { + // pool structure + poolStructure: 'revolving', + trancheStructure: 1, + assetClass: 'Private credit', + assetDenomination: '', + subAssetClass: '', + + // pool structure -> tranches + tranches: [createEmptyTranche('')], + + // pool details section + poolName: '', + poolIcon: null, + currency: isTestEnv ? 'USDC' : 'Native USDC', + maxReserve: 1000000, + investorType: '', + issuerName: '', + 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: '', + + adminMultisig: { + signers: [], + threshold: 1, + }, + adminMultisigEnabled: false, + poolFees: [createPoolFee()], + poolType: 'open', +} diff --git a/centrifuge-app/src/pages/IssuerCreatePool/validate.ts b/centrifuge-app/src/pages/IssuerCreatePool/validate.ts index 9744838687..18052c55e2 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, @@ -24,17 +26,32 @@ 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(), + + // 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 +66,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)), @@ -74,3 +82,74 @@ export const validate = { writeOff: combine(required(), positiveNumber(), max(100)), penaltyInterest: combine(required(), nonNegativeNumber(), max(100)), } + +export const validateValues = (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 +} From 21142b1498b5f7e3f7cfa87750a3598850f802dd Mon Sep 17 00:00:00 2001 From: katty barroso Date: Thu, 21 Nov 2024 10:54:49 +0100 Subject: [PATCH 04/29] Add pool structure section --- centrifuge-app/src/components/LoanList.tsx | 1 + centrifuge-app/src/components/Tooltips.tsx | 12 + .../IssuerCreatePool/PoolStructureSection.tsx | 406 ++++++++++++++++++ .../pages/IssuerCreatePool/TrancheInput.tsx | 2 +- .../IssuerPool/Configuration/Details.tsx | 2 +- .../pages/IssuerPool/Configuration/Issuer.tsx | 2 +- 6 files changed, 422 insertions(+), 3 deletions(-) create mode 100644 centrifuge-app/src/pages/IssuerCreatePool/PoolStructureSection.tsx diff --git a/centrifuge-app/src/components/LoanList.tsx b/centrifuge-app/src/components/LoanList.tsx index cb204e25b6..1c91e660f1 100644 --- a/centrifuge-app/src/components/LoanList.tsx +++ b/centrifuge-app/src/components/LoanList.tsx @@ -282,6 +282,7 @@ export function LoanList({ loans, snapshots, isLoading }: Props) { } onChange={(e) => setShowRepaid(!showRepaid)} + variant="square" /> + + + + Pool managers* + + Pool managers can individually add/block investors and manage the liquidity reserve of the pool. + + + + Security requirement + } + /> + } + /> + + + Wallet addresses + + {({ field, form }: FieldProps) => } + + + {({ field, form }: FieldProps) => } + + + + + + + + {({ 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) => { + return ( + { + 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} + /> + ) + }} + + + + + + + ))} + + + + + + + Investor onboarding + + + Onboarding experience + } + /> + } + /> + } + /> + + + + {({ 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 + + + + + + + ) +} From c4de95b05cd3d02e99f110b6904d61cd1d74b9bf Mon Sep 17 00:00:00 2001 From: katty barroso Date: Thu, 21 Nov 2024 11:03:47 +0100 Subject: [PATCH 08/29] Minor UI fix --- fabric/src/components/FileUpload/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fabric/src/components/FileUpload/index.tsx b/fabric/src/components/FileUpload/index.tsx index 7a204d8d97..b2718e9977 100644 --- a/fabric/src/components/FileUpload/index.tsx +++ b/fabric/src/components/FileUpload/index.tsx @@ -31,7 +31,7 @@ const FormField = styled.input` const FileDragOverContainer = styled(Stack)<{ $disabled?: boolean; $active: boolean; small?: boolean }>` position: relative; - height: ${({ small }) => (small ? '44px' : '144px')}; + height: ${({ small }) => (small ? '40px' : '144px')}; background-color: #ffffff; border: 1px solid ${({ theme }) => theme.colors.borderPrimary}; border-radius: ${({ theme }) => theme.radii.input}px; From 15644450fc6236a911e3c93eac84f342d66dfbd7 Mon Sep 17 00:00:00 2001 From: Katty Barroso <51223655+kattylucy@users.noreply.github.com> Date: Wed, 27 Nov 2024 16:29:31 +0100 Subject: [PATCH 09/29] 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 --- .../src/components/PoolCard/index.tsx | 2 +- .../PoolOverview/TrancheTokenCards.tsx | 7 +- centrifuge-app/src/components/Tooltips.tsx | 20 + .../IssuerCreatePool/IssuerCategories.tsx | 17 +- .../IssuerCreatePool/PoolDetailsSection.tsx | 2 - .../IssuerCreatePool/PoolSetupSection.tsx | 345 ++++++++++-------- .../IssuerCreatePool/PoolStructureSection.tsx | 7 +- .../src/pages/IssuerCreatePool/index.tsx | 2 +- .../src/pages/IssuerCreatePool/oldindex.tsx | 5 + .../src/pages/IssuerCreatePool/types.ts | 3 +- .../IssuerPool/Access/AssetOriginators.tsx | 1 + .../src/pages/Pool/Assets/index.tsx | 4 +- centrifuge-js/src/modules/pools.ts | 8 +- 13 files changed, 253 insertions(+), 170 deletions(-) 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/Tooltips.tsx b/centrifuge-app/src/components/Tooltips.tsx index c7bc13c28b..0bca55ddcf 100644 --- a/centrifuge-app/src/components/Tooltips.tsx +++ b/centrifuge-app/src/components/Tooltips.tsx @@ -358,6 +358,26 @@ export const tooltipText = { 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/IssuerCategories.tsx b/centrifuge-app/src/pages/IssuerCreatePool/IssuerCategories.tsx index 09b981e850..4d70a2f345 100644 --- a/centrifuge-app/src/pages/IssuerCreatePool/IssuerCategories.tsx +++ b/centrifuge-app/src/pages/IssuerCreatePool/IssuerCategories.tsx @@ -16,13 +16,15 @@ const PROVIDERS = [ { label: 'Other', value: 'other' }, ] -const LabelWithDeleteButton = ({ onDelete }: { onDelete: () => void }) => { +const LabelWithDeleteButton = ({ onDelete, hideButton }: { onDelete: () => void; hideButton: boolean }) => { return ( Name of provider - - - + {!hideButton && ( + + + + )} ) } @@ -56,7 +58,12 @@ export const IssuerCategoriesSection = () => { {({ field, meta }: FieldProps) => ( remove(index)} />} + label={ + remove(index)} + hideButton={form.values.issuerCategories.length === 1} + /> + } placeholder="Type here..." maxLength={100} /> diff --git a/centrifuge-app/src/pages/IssuerCreatePool/PoolDetailsSection.tsx b/centrifuge-app/src/pages/IssuerCreatePool/PoolDetailsSection.tsx index 7975d4fa94..97178d4d75 100644 --- a/centrifuge-app/src/pages/IssuerCreatePool/PoolDetailsSection.tsx +++ b/centrifuge-app/src/pages/IssuerCreatePool/PoolDetailsSection.tsx @@ -11,7 +11,6 @@ import { TextInput, } from '@centrifuge/fabric' import { Field, FieldProps, useFormikContext } from 'formik' -import { useTheme } from 'styled-components' import { FieldWithErrorMessage } from '../../../src/components/FieldWithErrorMessage' import { Tooltips } from '../../../src/components/Tooltips' import { isTestEnv } from '../../../src/config' @@ -27,7 +26,6 @@ export const AddButton = ({ onClick }: { onClick: () => void }) => ( ) export const PoolDetailsSection = () => { - const theme = useTheme() const form = useFormikContext() const createLabel = (label: string) => `${label}${isTestEnv ? '' : '*'}` diff --git a/centrifuge-app/src/pages/IssuerCreatePool/PoolSetupSection.tsx b/centrifuge-app/src/pages/IssuerCreatePool/PoolSetupSection.tsx index 92dd2f95fe..b3bfede001 100644 --- a/centrifuge-app/src/pages/IssuerCreatePool/PoolSetupSection.tsx +++ b/centrifuge-app/src/pages/IssuerCreatePool/PoolSetupSection.tsx @@ -1,7 +1,6 @@ import { PoolMetadataInput } from '@centrifuge/centrifuge-js' import { Box, - Button, Checkbox, FileUpload, Grid, @@ -14,16 +13,14 @@ import { Text, TextInput, } from '@centrifuge/fabric' -import { Field, FieldProps, useFormikContext } from 'formik' +import { Field, FieldArray, FieldProps, useFormikContext } from 'formik' import { useTheme } from 'styled-components' import { FieldWithErrorMessage } from '../../../src/components/FieldWithErrorMessage' import { Tooltips } from '../../../src/components/Tooltips' -import { feeCategories, isTestEnv } from '../../../src/config' +import { feeCategories } from '../../../src/config' import { AddButton } from './PoolDetailsSection' import { CheckboxOption, Line, StyledGrid } from './PoolStructureSection' -const MAX_FEES = 5 - const FEE_TYPES = [ { label: 'Direct charge', value: 'chargedUpTo' }, { label: 'Fixed %', value: 'fixed' }, @@ -35,18 +32,12 @@ export const PoolSetupSection = () => { const theme = useTheme() const form = useFormikContext() const { values } = form - const createLabel = (label: string) => `${label}${isTestEnv ? '' : '*'}` return ( - - - Management setup - - - + + Management setup + Pool managers* @@ -57,29 +48,57 @@ export const PoolSetupSection = () => { Security requirement } /> } /> - + Wallet addresses - - {({ field, form }: FieldProps) => } - - - {({ field, form }: FieldProps) => } - - + + {({ push }) => ( + <> + {values.adminMultisigEnabled ? ( + values.adminMultisig?.signers?.map((_, index) => ( + + + {({ field }: FieldProps) => } + + + )) + ) : ( + + + {({ field }: FieldProps) => } + + + )} + {values.adminMultisigEnabled && ( + + { + if (form.values.adminMultisig && form.values.adminMultisig.signers?.length <= 10) { + push('') + } + }} + /> + + )} + + )} + + @@ -88,12 +107,18 @@ export const PoolSetupSection = () => { {({ 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) => { - return ( - { - 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} + + {({ field, meta }: FieldProps) => ( + form.setFieldValue(`poolFees.${index}.feePosition`, event.target.value)} + onBlur={field.onBlur} + errorMessage={meta.touched && meta.error ? meta.error : undefined} + value={field.value} + options={FEE_POSISTIONS} + /> + )} + + + {({ field, meta }: FieldProps) => ( + form.setFieldValue(field.name, event.target.value)} - onBlur={field.onBlur} - value={field.value} - options={PROVIDERS} - placeholder="Please select..." - /> + + + {({ field, meta }: 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)} + /> + )}
@@ -87,6 +466,14 @@ const IssuerCreatePoolPage = () => { + {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 && } @@ -103,13 +490,35 @@ const IssuerCreatePoolPage = () => { Previous )} -
+ {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. + + + + + + + + )} ) } 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 )} - @@ -514,7 +517,9 @@ const IssuerCreatePoolPage = () => { - +
diff --git a/centrifuge-app/src/pages/IssuerCreatePool/oldindex.tsx b/centrifuge-app/src/pages/IssuerCreatePool/oldindex.tsx deleted file mode 100644 index 79609964cd..0000000000 --- a/centrifuge-app/src/pages/IssuerCreatePool/oldindex.tsx +++ /dev/null @@ -1,839 +0,0 @@ -import { CurrencyBalance, isSameAddress, Perquintill, Rate, TransactionOptions } from '@centrifuge/centrifuge-js' -import { - AddFee, - CurrencyKey, - FeeTypes, - FileType, - PoolMetadataInput, - TrancheInput, -} from '@centrifuge/centrifuge-js/dist/modules/pools' -import { - useBalances, - useCentrifuge, - useCentrifugeConsts, - useCentrifugeTransaction, - useWallet, -} from '@centrifuge/centrifuge-react' -import { - Box, - Button, - CurrencyInput, - FileUpload, - Grid, - Select, - Shelf, - Step, - Stepper, - Text, - TextInput, - 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 { useTheme } from 'styled-components' -import { useDebugFlags } from '../../components/DebugFlags' -import { PreimageHashDialog } from '../../components/Dialogs/PreimageHashDialog' -import { ShareMultisigDialog } from '../../components/Dialogs/ShareMultisigDialog' -import { FieldWithErrorMessage } from '../../components/FieldWithErrorMessage' -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 { 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 - trancheStucture: 1 | 2 | 3 -} - -const initialValues: CreatePoolValues = { - poolStructure: '', - trancheStucture: 0, - - 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: '', - 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', -} - -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 ? : -} - -function CreatePoolForm() { - const theme = useTheme() - const [activeStep, setActiveStep] = React.useState(1) - - 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 { poolCreationType } = useDebugFlags() - const consts = useCentrifugeConsts() - const createType = (poolCreationType as TransactionOptions['createType']) || config.poolCreationType || 'immediate' - - 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]) - - React.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 txMessage = { - immediate: 'Create pool', - propose: 'Submit pool proposal', - notePreimage: 'Note preimage', - } - const { execute: createPoolTx, isLoading: transactionIsPending } = useCentrifugeTransaction( - `${txMessage[createType]} 2/2`, - (cent) => - ( - args: [ - values: CreatePoolValues, - transferToMultisig: BN, - aoProxy: string, - adminProxy: string, - poolId: string, - tranches: TrancheInput[], - currency: CurrencyKey, - maxReserve: BN, - metadata: PoolMetadataInput, - poolFees: AddFee['fee'][] - ], - options - ) => { - const [values, transferToMultisig, aoProxy, adminProxy, , , , , { adminMultisig }] = 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]) => { - // BATCH https://polkadot.js.org/docs/kusama/extrinsics/#batchcalls-veccall - api.tx.utlity - .batch - // create pool current functionality + pure proxy functionality goes here - () - 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) - ), - 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), - api.tx.proxy.removeProxy(address, 'Any', 0), - ]) - ), - 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, multisig: undefined, proxies: undefined }) - }) - ) - }, - { - onSuccess: (args) => { - if (form.values.adminMultisigEnabled && form.values.adminMultisig.threshold > 1) setIsMultisigDialogOpen(true) - const [, , , , poolId] = args - if (createType === 'immediate') { - setCreatedPoolId(poolId) - } - }, - } - ) - - 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 - }, - validateOnMount: true, - onSubmit: async (values, { setSubmitting }) => { - 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)! - - // Pool ID and required assets - const poolId = await centrifuge.pools.getAvailablePoolId() - if (!values.poolIcon || (!isTestEnv && !values.executiveSummary)) { - return - } - - const pinFile = async (file: File): Promise => { - const pinned = await lastValueFrom(centrifuge.metadata.pinFile(await getFileDataURI(file))) - return { uri: pinned.uri, mime: file.type } - } - - // Handle pinning files (pool icon, issuer logo, and executive summary) - const promises = [pinFile(values.poolIcon)] - - 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 - 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, - 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)) - ) - 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 - } - return { - agency: rating.agency ?? '', - value: rating.value ?? '', - reportUrl: rating.reportUrl ?? '', - reportFile: reportFile ?? 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), - })), - ] - - const feeId = await firstValueFrom(centrifuge.pools.getNextPoolFeeId()) - const poolFees: AddFee['fee'][] = values.poolFees.map((fee, i) => { - return { - name: fee.name, - destination: fee.walletAddress, - amount: Rate.fromPercent(fee.percentOfNav), - feeType: fee.feeType, - limit: 'ShareOfPortfolioValuation', - account: fee.feeType === 'chargedUpTo' ? fee.walletAddress : undefined, - feePosition: fee.feePosition, - } - }) - metadataValues.poolFees = poolFees.map((fee, i) => ({ - name: fee.name, - id: feeId + i, - feePosition: fee.feePosition, - feeType: fee.feeType, - })) - - if (metadataValues.adminMultisig && metadataValues.adminMultisig.threshold > 1) { - addMultisig(metadataValues.adminMultisig) - } - - createProxies([ - (aoProxy, adminProxy) => { - createPoolTx( - [ - values, - CurrencyBalance.fromFloat(createDeposit, chainDecimals), - aoProxy, - adminProxy, - poolId, - tranches, - currency.key, - CurrencyBalance.fromFloat(values.maxReserve, currency.decimals), - metadataValues, - poolFees, - ], - { createType } - ) - }, - ]) - - setSubmitting(false) - }, - }) - - 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, - })) ?? [] - - // Use useEffect to update tranche name when poolName changes - React.useEffect(() => { - if (form.values.poolName) { - form.setFieldValue('tranches', [createEmptyTranche(form.values.poolName)]) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [form.values.poolName]) - - return ( - <> - setIsPreimageDialogOpen(false)} - /> - {multisigData && ( - setIsMultisigDialogOpen(false)} - /> - )} - -
- - New pool setup - - - - - - - - - - - - - - - - {({ 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)} - - - - - - - -
- - ) -} diff --git a/centrifuge-app/src/pages/IssuerCreatePool/types.ts b/centrifuge-app/src/pages/IssuerCreatePool/types.ts index 12d64725da..c266c0970d 100644 --- a/centrifuge-app/src/pages/IssuerCreatePool/types.ts +++ b/centrifuge-app/src/pages/IssuerCreatePool/types.ts @@ -8,13 +8,14 @@ export interface Tranche { minInvestment: number | '' apy: string interestRate: number | '' + apyPercentage: number | null } export interface PoolFee { id: number name: string feeType: FeeTypes - percentOfNav: string + percentOfNav: number walletAddress: string feePosition: 'Top of waterfall' category: string @@ -32,6 +33,7 @@ export const createEmptyTranche = (trancheName: string): Tranche => ({ minRiskBuffer: trancheName === 'Junior' ? '' : 0, minInvestment: 1000, apy: '90d', + apyPercentage: null, }) export const createPoolFee = (): PoolFee => ({ @@ -40,7 +42,7 @@ export const createPoolFee = (): PoolFee => ({ category: '', feePosition: 'Top of waterfall', feeType: '' as FeeTypes, - percentOfNav: '', + percentOfNav: 0, walletAddress: '', }) diff --git a/centrifuge-app/src/pages/IssuerCreatePool/validate.ts b/centrifuge-app/src/pages/IssuerCreatePool/validate.ts index 7c618bec82..52c78d436e 100644 --- a/centrifuge-app/src/pages/IssuerCreatePool/validate.ts +++ b/centrifuge-app/src/pages/IssuerCreatePool/validate.ts @@ -43,6 +43,7 @@ export const validate = { maxPriceVariation: combine(required(), min(0), max(10000)), maturityDate: combine(required(), maturityDate()), apy: required(), + apyPercentage: required(), // pool details poolName: combine(required(), maxLength(100)), @@ -93,13 +94,14 @@ export const validateValues = (values: CreatePoolValues) => { let prevInterest = Infinity let prevRiskBuffer = 0 - const juniorInterestRate = parseFloat(values.tranches[0].interestRate as string) + const juniorInterestRate = + values.tranches[0].apyPercentage !== null ? parseFloat(values.tranches[0].apyPercentage.toString()) : 0 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) { + 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 === '') { diff --git a/centrifuge-js/src/modules/pools.ts b/centrifuge-js/src/modules/pools.ts index 575b0c612d..db37307072 100644 --- a/centrifuge-js/src/modules/pools.ts +++ b/centrifuge-js/src/modules/pools.ts @@ -662,10 +662,12 @@ interface TrancheFormValues { minRiskBuffer: number | '' minInvestment: number | '' apy: string | '' + apyPercentage: number | null } export interface PoolMetadataInput { // structure + assetDenomination: string poolStructure: 'revolving' assetClass: 'Public credit' | 'Private credit' subAssetClass: string @@ -713,6 +715,7 @@ export interface PoolMetadataInput { onboarding?: { tranches: { [trancheId: string]: { agreement: FileType | undefined; openForOnboarding: boolean } } taxInfoRequired?: boolean + externalOnboardingUrl?: string } listed?: boolean @@ -780,6 +783,7 @@ export type PoolMetadata = { icon?: FileType | null minInitialInvestment?: string apy: string + apyPercentage: number | null } > loanTemplates?: { @@ -1129,6 +1133,7 @@ export function getPoolsModule(inst: Centrifuge) { tranchesById[computeTrancheId(index, poolId)] = { minInitialInvestment: CurrencyBalance.fromFloat(tranche.minInvestment, currencyDecimals).toString(), apy: tranche.apy, + apyPercentage: tranche.apyPercentage, } }) @@ -1178,6 +1183,11 @@ export function getPoolsModule(inst: Centrifuge) { pod: {}, tranches: tranchesById, adminMultisig: metadata.adminMultisig, + onboarding: { + tranches: metadata.onboarding?.tranches || {}, + taxInfoRequired: metadata.onboarding?.taxInfoRequired, + externalOnboardingUrl: metadata.onboarding?.externalOnboardingUrl, + }, } return inst.metadata.pinJson(formattedMetadata) From 35f034e07704359b93087cbd103fbcb744c227ed Mon Sep 17 00:00:00 2001 From: Katty Barroso <51223655+kattylucy@users.noreply.github.com> Date: Thu, 5 Dec 2024 11:53:25 +0100 Subject: [PATCH 12/29] Update fabric/src/theme/tokens/colors.ts Co-authored-by: Sophia --- fabric/src/theme/tokens/colors.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fabric/src/theme/tokens/colors.ts b/fabric/src/theme/tokens/colors.ts index cce6516ab7..daff6ddfce 100644 --- a/fabric/src/theme/tokens/colors.ts +++ b/fabric/src/theme/tokens/colors.ts @@ -2,7 +2,7 @@ export const black = '#252B34' export const gold = '#FFC012' export const grayScale = { - 10: '##F2F4F7', + 10: '#F2F4F7', 50: '#F6F6F6', 100: '#E7E7E7', 300: '#CFCFCF', From a658715cd64cb00f3e50a6cc86833b7a8253c2ad Mon Sep 17 00:00:00 2001 From: Katty Barroso <51223655+kattylucy@users.noreply.github.com> Date: Thu, 5 Dec 2024 11:53:53 +0100 Subject: [PATCH 13/29] Update centrifuge-app/src/pages/IssuerCreatePool/IssuerCategories.tsx Co-authored-by: Sophia --- centrifuge-app/src/pages/IssuerCreatePool/IssuerCategories.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/centrifuge-app/src/pages/IssuerCreatePool/IssuerCategories.tsx b/centrifuge-app/src/pages/IssuerCreatePool/IssuerCategories.tsx index 311c7e76a7..7626739651 100644 --- a/centrifuge-app/src/pages/IssuerCreatePool/IssuerCategories.tsx +++ b/centrifuge-app/src/pages/IssuerCreatePool/IssuerCategories.tsx @@ -10,7 +10,7 @@ const PROVIDERS = [ { label: 'Pricing oracle provider', value: 'pricingOracleProvider' }, { label: 'Auditor', value: 'auditor' }, { label: 'Custodian', value: 'custodian' }, - { label: 'Investment manager', value: 'Investment manager' }, + { label: 'Investment manager', value: 'investmentManager' }, { label: 'Sub-advisor', value: 'subadvisor' }, { label: 'Historical default rate', value: 'historicalDefaultRate' }, { label: 'Other', value: 'other' }, From a2c5b417dc6dfd1e9c044d1580de8a0bf55cb4d5 Mon Sep 17 00:00:00 2001 From: Katty Barroso <51223655+kattylucy@users.noreply.github.com> Date: Thu, 5 Dec 2024 11:54:07 +0100 Subject: [PATCH 14/29] Update centrifuge-app/src/pages/IssuerCreatePool/PoolSetupSection.tsx Co-authored-by: Sophia --- centrifuge-app/src/pages/IssuerCreatePool/PoolSetupSection.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/centrifuge-app/src/pages/IssuerCreatePool/PoolSetupSection.tsx b/centrifuge-app/src/pages/IssuerCreatePool/PoolSetupSection.tsx index c20df3abab..042c0d2ec3 100644 --- a/centrifuge-app/src/pages/IssuerCreatePool/PoolSetupSection.tsx +++ b/centrifuge-app/src/pages/IssuerCreatePool/PoolSetupSection.tsx @@ -256,7 +256,7 @@ export const PoolSetupSection = () => { Wallet address
} /> From c95e18734949ebdab1a2a67371d61d3e704fd2ed Mon Sep 17 00:00:00 2001 From: katty barroso Date: Thu, 5 Dec 2024 12:36:33 +0100 Subject: [PATCH 15/29] Add feedback --- .../IssuerCreatePool/PoolSetupSection.tsx | 68 +++++++++---------- .../src/pages/IssuerCreatePool/index.tsx | 8 +-- fabric/src/theme/tokens/colors.ts | 2 +- fabric/src/theme/tokens/theme.ts | 2 +- 4 files changed, 40 insertions(+), 40 deletions(-) diff --git a/centrifuge-app/src/pages/IssuerCreatePool/PoolSetupSection.tsx b/centrifuge-app/src/pages/IssuerCreatePool/PoolSetupSection.tsx index 042c0d2ec3..cd1b1b500f 100644 --- a/centrifuge-app/src/pages/IssuerCreatePool/PoolSetupSection.tsx +++ b/centrifuge-app/src/pages/IssuerCreatePool/PoolSetupSection.tsx @@ -57,8 +57,6 @@ export const PoolSetupSection = () => { const form = useFormikContext() const { values } = form - console.log(values) - return ( @@ -123,7 +121,7 @@ export const PoolSetupSection = () => { ) : ( - {({ field }: FieldProps) => } + {({ field }: FieldProps) => } )} @@ -144,37 +142,39 @@ export const PoolSetupSection = () => { - - - - {({ field, meta, form }: FieldProps) => ( - form.setFieldValue('adminMultisig.threshold', event.target.value)} + onBlur={field.onBlur} + errorMessage={meta.touched && meta.error ? meta.error : undefined} + value={field.value} + options={values.adminMultisig?.signers.map((_: string, i: number) => ({ + label: i + 1, + value: i + 1, + }))} + placeholder="Select..." + /> + )} + + + + + For added security, changes to the pool configuration (e.g., tranche structure or write-off policy) may + require multiple signers and confirmation from the above. + + + + + )} diff --git a/centrifuge-app/src/pages/IssuerCreatePool/index.tsx b/centrifuge-app/src/pages/IssuerCreatePool/index.tsx index 4e59b93693..4b9a343e22 100644 --- a/centrifuge-app/src/pages/IssuerCreatePool/index.tsx +++ b/centrifuge-app/src/pages/IssuerCreatePool/index.tsx @@ -288,16 +288,16 @@ const IssuerCreatePoolPage = () => { 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 ?? '', + agency: rating.agency, + value: rating.value, + reportUrl: rating.reportUrl, reportFile: pinnedReport ? { uri: pinnedReport.uri, mime: rating.reportFile?.type ?? '' } : null, } }) + metadataValues.poolRatings = ratings } // Tranches diff --git a/fabric/src/theme/tokens/colors.ts b/fabric/src/theme/tokens/colors.ts index daff6ddfce..7e8bab6797 100644 --- a/fabric/src/theme/tokens/colors.ts +++ b/fabric/src/theme/tokens/colors.ts @@ -9,7 +9,7 @@ export const grayScale = { 500: '#91969B', 600: '#667085', 800: '#252B34', - 900: '#0F1115', + 900: '#6A7280', } export const yellowScale = { diff --git a/fabric/src/theme/tokens/theme.ts b/fabric/src/theme/tokens/theme.ts index 651ea32ba1..1ef8a5c3e5 100644 --- a/fabric/src/theme/tokens/theme.ts +++ b/fabric/src/theme/tokens/theme.ts @@ -17,7 +17,7 @@ const statusPromoteBg = '#f8107114' const colors = { textPrimary: grayScale[800], textSecondary: grayScale[500], - textDisabled: '#6A7280', + textDisabled: grayScale[900], textInverted: 'white', textGold: gold, From 8d231690bc0f2bfe0a343207aef433f847d9f71a Mon Sep 17 00:00:00 2001 From: katty barroso Date: Thu, 5 Dec 2024 15:44:14 +0100 Subject: [PATCH 16/29] feedback --- .../src/components/FieldWithErrorMessage.tsx | 25 ++--- .../IssuerCreatePool/PoolDetailsSection.tsx | 2 + .../pages/IssuerCreatePool/PoolRatings.tsx | 2 +- .../IssuerCreatePool/PoolSetupSection.tsx | 104 ++++++++++-------- .../IssuerCreatePool/PoolStructureSection.tsx | 58 ++++++---- .../src/pages/IssuerCreatePool/types.ts | 16 ++- .../src/pages/IssuerCreatePool/validate.ts | 15 +++ fabric/src/components/InputUnit/index.tsx | 4 +- fabric/src/components/TextInput/index.tsx | 1 - 9 files changed, 138 insertions(+), 89 deletions(-) diff --git a/centrifuge-app/src/components/FieldWithErrorMessage.tsx b/centrifuge-app/src/components/FieldWithErrorMessage.tsx index 6490da9328..69fc3ef95b 100644 --- a/centrifuge-app/src/components/FieldWithErrorMessage.tsx +++ b/centrifuge-app/src/components/FieldWithErrorMessage.tsx @@ -1,4 +1,4 @@ -import { Box, URLInput } from '@centrifuge/fabric' +import { URLInput } from '@centrifuge/fabric' import { Field, FieldAttributes, useField, useFormikContext } from 'formik' import * as React from 'react' @@ -15,20 +15,17 @@ export function FieldWithErrorMessage(props: Props) { const handleChange = (event: React.ChangeEvent) => { form.setFieldValue(field.name, event.target.value) } - return props.isUrl ? ( - - - + ) : ( ) diff --git a/centrifuge-app/src/pages/IssuerCreatePool/PoolDetailsSection.tsx b/centrifuge-app/src/pages/IssuerCreatePool/PoolDetailsSection.tsx index 8b9f678cbc..595fe4ef32 100644 --- a/centrifuge-app/src/pages/IssuerCreatePool/PoolDetailsSection.tsx +++ b/centrifuge-app/src/pages/IssuerCreatePool/PoolDetailsSection.tsx @@ -29,6 +29,8 @@ export const PoolDetailsSection = () => { const form = useFormikContext() const createLabel = (label: string) => `${label}${isTestEnv ? '' : '*'}` + console.log(form.values) + return ( diff --git a/centrifuge-app/src/pages/IssuerCreatePool/PoolRatings.tsx b/centrifuge-app/src/pages/IssuerCreatePool/PoolRatings.tsx index f43b7a7a3b..df6ebf4be7 100644 --- a/centrifuge-app/src/pages/IssuerCreatePool/PoolRatings.tsx +++ b/centrifuge-app/src/pages/IssuerCreatePool/PoolRatings.tsx @@ -39,7 +39,7 @@ export const PoolRatingsSection = () => { - {({ field, meta }: FieldProps) => ( + {({ field }: FieldProps) => ( { {values.adminMultisigEnabled ? ( values.adminMultisig?.signers?.map((_, index) => ( - - {({ field, form }: FieldProps) => ( - + {({ field, form, meta }: FieldProps) => ( + { + onChange={(val: React.ChangeEvent) => { form.setFieldValue(`adminMultisig.signers.${index}`, val.target.value) }} onBlur={() => { + form.setFieldTouched(`adminMultisig.signers.${index}`, true) const value = form.values.adminMultisig.signers[index] if (value) { const transformedValue = isEvmAddress(value) @@ -113,6 +115,8 @@ export const PoolSetupSection = () => { form.setFieldValue(`adminMultisig.signers.${index}`, transformedValue) } }} + errorMessage={meta.touched && meta.error ? meta.error : undefined} + as={TextInput} /> )} @@ -120,8 +124,15 @@ export const PoolSetupSection = () => { )) ) : ( - - {({ field }: FieldProps) => } + + {({ field }: FieldProps) => ( + + )} )} @@ -187,23 +198,17 @@ export const PoolSetupSection = () => { Originate assets and invest in the pool* {values.assetOriginators?.map((_: string, index: number) => ( - - {({ field, form }: FieldProps) => ( - + {({ field, form, meta }: FieldProps) => ( + { + onChange={(val: React.ChangeEvent) => { 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) - } - }} + errorMessage={meta.touched && meta.error ? meta.error : undefined} + as={TextInput} + onBlur={field.onBlur} /> )} @@ -385,34 +390,47 @@ export const PoolSetupSection = () => { {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.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 + onBlur={field.onBlur} + /> + + )} + + ))} )} {values.onboardingExperience === 'external' && ( - + + {({ field, meta }: FieldProps) => ( + + )} + )} diff --git a/centrifuge-app/src/pages/IssuerCreatePool/PoolStructureSection.tsx b/centrifuge-app/src/pages/IssuerCreatePool/PoolStructureSection.tsx index b54be77109..da1d5c243a 100644 --- a/centrifuge-app/src/pages/IssuerCreatePool/PoolStructureSection.tsx +++ b/centrifuge-app/src/pages/IssuerCreatePool/PoolStructureSection.tsx @@ -5,6 +5,7 @@ import { CurrencyInput, Grid, IconHelpCircle, + InputErrorMessage, NumberInput, Select, Text, @@ -290,7 +291,7 @@ export const PoolStructureSection = () => { - {({ field, form }: FieldProps) => ( + {({ field, form, meta }: FieldProps) => ( { - {({ field, form, meta }: FieldProps) => ( - Min. investment*} /> - } - placeholder="0.00" - currency={values.assetDenomination} - errorMessage={meta.touched ? meta.error : undefined} - onChange={(value) => form.setFieldValue(field.name, value)} - onBlur={() => form.setFieldTouched(field.name, true)} - /> - )} + {({ field, form, meta }: FieldProps) => { + return ( + Min. investment*} + /> + } + placeholder="0.00" + currency={values.assetDenomination} + errorMessage={meta.touched ? meta.error : undefined} + onChange={(value) => form.setFieldValue(field.name, value)} + onBlur={() => form.setFieldTouched(field.name, true)} + /> + ) + }} @@ -325,15 +331,19 @@ export const PoolStructureSection = () => { {({ field, form, meta }: FieldProps) => ( - form.setFieldValue(field.name, e.target.value)} - errorMessage={meta.touched ? meta.error : undefined} - label={Token symbol*} />} - placeholder="4-12 characters" - minLength={4} - maxLength={12} - /> + + form.setFieldValue(field.name, e.target.value)} + label={Token symbol*} />} + placeholder="4-12 characters" + minLength={4} + maxLength={12} + /> + {meta.touched ? ( + {meta.error} + ) : null} + )} @@ -378,7 +388,7 @@ export const PoolStructureSection = () => { - {(index === 1 || index === 2) && ( + {index !== 0 && ( <> diff --git a/centrifuge-app/src/pages/IssuerCreatePool/types.ts b/centrifuge-app/src/pages/IssuerCreatePool/types.ts index c266c0970d..3e16414dbf 100644 --- a/centrifuge-app/src/pages/IssuerCreatePool/types.ts +++ b/centrifuge-app/src/pages/IssuerCreatePool/types.ts @@ -48,9 +48,19 @@ export const createPoolFee = (): PoolFee => ({ export type CreatePoolValues = Omit< PoolMetadataInput, - 'poolIcon' | 'issuerLogo' | 'executiveSummary' | 'adminMultisig' | 'poolFees' | 'poolReport' | 'poolRatings' + | 'poolIcon' + | 'issuerLogo' + | 'executiveSummary' + | 'adminMultisig' + | 'poolFees' + | 'poolReport' + | 'poolRatings' + | 'issuerName' + | 'epochHours' + | 'epochMinutes' > & { // pool structure + issuerName: null | '' assetDenomination: string // pool details @@ -87,7 +97,7 @@ export const initialValues: CreatePoolValues = { poolIcon: null, maxReserve: 1000000, investorType: '', - issuerName: '', + issuerName: null, issuerRepName: '', issuerLogo: null, issuerDescription: '', @@ -118,6 +128,4 @@ export const initialValues: CreatePoolValues = { taxInfoRequired: false, }, onboardingExperience: 'none', - epochHours: 0, - epochMinutes: 0, } diff --git a/centrifuge-app/src/pages/IssuerCreatePool/validate.ts b/centrifuge-app/src/pages/IssuerCreatePool/validate.ts index 52c78d436e..3d44a44993 100644 --- a/centrifuge-app/src/pages/IssuerCreatePool/validate.ts +++ b/centrifuge-app/src/pages/IssuerCreatePool/validate.ts @@ -83,6 +83,9 @@ 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) => { @@ -112,6 +115,18 @@ export const validateValues = (values: CreatePoolValues) => { } }) + values.assetOriginators.forEach((asset, i) => { + if (!isSubstrateAddress(asset) && asset !== '') { + errors = setIn(errors, `assetOriginators.${i}`, 'Invalid address') + } + }) + + values.adminMultisig.signers.forEach((signer, i) => { + if (!isSubstrateAddress(signer) && signer !== '') { + errors = setIn(errors, `adminMultisig.signers.${i}`, 'Invalid address') + } + }) + values.tranches.forEach((t, i) => { if (tokenNames.has(t.tokenName)) { errors = setIn(errors, `tranches.${i}.tokenName`, 'Tranche names must be unique') diff --git a/fabric/src/components/InputUnit/index.tsx b/fabric/src/components/InputUnit/index.tsx index 0b6d13bd4f..5a11ecd975 100644 --- a/fabric/src/components/InputUnit/index.tsx +++ b/fabric/src/components/InputUnit/index.tsx @@ -73,9 +73,9 @@ export function InputLabel({ ) } -export function InputErrorMessage({ children }: { children: React.ReactNode }) { +export function InputErrorMessage({ children, style }: { children: React.ReactNode; style: React.CSSProperties }) { return ( - + {children} ) diff --git a/fabric/src/components/TextInput/index.tsx b/fabric/src/components/TextInput/index.tsx index 06ba0bf03e..698b80b75c 100644 --- a/fabric/src/components/TextInput/index.tsx +++ b/fabric/src/components/TextInput/index.tsx @@ -131,7 +131,6 @@ export function URLInput({ }: URLInputProps) { const defaultId = React.useId() id ??= defaultId - return ( Date: Mon, 9 Dec 2024 12:15:01 +0100 Subject: [PATCH 17/29] Feedback changes --- .../IssuerCreatePool/PoolDetailsSection.tsx | 2 - .../IssuerCreatePool/PoolSetupSection.tsx | 184 ++++++++++-------- .../src/pages/IssuerCreatePool/index.tsx | 51 +++-- .../src/pages/IssuerCreatePool/types.ts | 23 ++- .../src/pages/IssuerCreatePool/validate.ts | 2 +- fabric/src/components/FileUpload/index.tsx | 2 +- fabric/src/components/InputUnit/index.tsx | 2 +- 7 files changed, 139 insertions(+), 127 deletions(-) diff --git a/centrifuge-app/src/pages/IssuerCreatePool/PoolDetailsSection.tsx b/centrifuge-app/src/pages/IssuerCreatePool/PoolDetailsSection.tsx index 595fe4ef32..8b9f678cbc 100644 --- a/centrifuge-app/src/pages/IssuerCreatePool/PoolDetailsSection.tsx +++ b/centrifuge-app/src/pages/IssuerCreatePool/PoolDetailsSection.tsx @@ -29,8 +29,6 @@ export const PoolDetailsSection = () => { const form = useFormikContext() const createLabel = (label: string) => `${label}${isTestEnv ? '' : '*'}` - console.log(form.values) - return ( diff --git a/centrifuge-app/src/pages/IssuerCreatePool/PoolSetupSection.tsx b/centrifuge-app/src/pages/IssuerCreatePool/PoolSetupSection.tsx index fa53e35d8e..21d8478dea 100644 --- a/centrifuge-app/src/pages/IssuerCreatePool/PoolSetupSection.tsx +++ b/centrifuge-app/src/pages/IssuerCreatePool/PoolSetupSection.tsx @@ -1,5 +1,5 @@ import { evmToSubstrateAddress, PoolMetadataInput } from '@centrifuge/centrifuge-js' -import { useCentEvmChainId } from '@centrifuge/centrifuge-react' +import { useCentEvmChainId, useWallet } from '@centrifuge/centrifuge-react' import { Box, Checkbox, @@ -15,6 +15,7 @@ import { 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' @@ -22,6 +23,7 @@ import { feeCategories } from '../../../src/config' import { isEvmAddress } from '../../../src/utils/address' import { AddButton } from './PoolDetailsSection' import { CheckboxOption, Line, StyledGrid } from './PoolStructureSection' +import { CreatePoolValues } from './types' import { validate } from './validate' const FEE_TYPES = [ @@ -55,8 +57,13 @@ const TaxDocument = () => { export const PoolSetupSection = () => { const theme = useTheme() const chainId = useCentEvmChainId() - const form = useFormikContext() + const form = useFormikContext() const { values } = form + const { selectedAccount } = useWallet().substrate + + useEffect(() => { + form.setFieldValue('adminMultisig.signers[0]', selectedAccount?.address) + }, []) return ( @@ -127,9 +134,9 @@ export const PoolSetupSection = () => { {({ field }: FieldProps) => ( )} @@ -168,7 +175,7 @@ export const PoolSetupSection = () => { onBlur={field.onBlur} errorMessage={meta.touched && meta.error ? meta.error : undefined} value={field.value} - options={values.adminMultisig?.signers.map((_: string, i: number) => ({ + options={values.adminMultisig?.signers.map((_: string | number, i: any) => ({ label: i + 1, value: i + 1, }))} @@ -238,15 +245,15 @@ export const PoolSetupSection = () => { Fee position} />} /> Fee type} />} /> @@ -255,13 +262,13 @@ export const PoolSetupSection = () => { label={Fees in % of NAV} symbol="%" name={`poolFees.${1}.percentOfNav`} - value="0.4" + value={values.poolFees[0].percentOfNav} disabled /> Wallet address} /> @@ -269,83 +276,89 @@ export const PoolSetupSection = () => { + {/* POOL FEES */} {({ push, remove }) => ( <> - {values.poolFees.map((_, index) => ( - - - - Pool fees {index + 1} - remove(index)}> - - - - - - - - {({ field, meta }: FieldProps) => ( - form.setFieldValue(`poolFees.${index}.feePosition`, event.target.value)} - onBlur={field.onBlur} - errorMessage={meta.touched && meta.error ? meta.error : undefined} - value={field.value} - options={FEE_POSISTIONS} - /> - )} - - - {({ field, meta }: 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} + /> + )} + + + + + + + ) + })} @@ -399,7 +412,7 @@ export const PoolSetupSection = () => { {({ field, meta }: FieldProps) => ( { form.setFieldTouched(`onboarding.tranches.${tranche.tokenName}`, true, false) @@ -409,7 +422,6 @@ export const PoolSetupSection = () => { errorMessage={meta.touched && meta.error ? meta.error : undefined} accept="application/pdf" small - onBlur={field.onBlur} /> )} diff --git a/centrifuge-app/src/pages/IssuerCreatePool/index.tsx b/centrifuge-app/src/pages/IssuerCreatePool/index.tsx index 4b9a343e22..cd1093a72d 100644 --- a/centrifuge-app/src/pages/IssuerCreatePool/index.tsx +++ b/centrifuge-app/src/pages/IssuerCreatePool/index.tsx @@ -1,4 +1,5 @@ import { + AddFee, CurrencyBalance, CurrencyKey, FileType, @@ -37,7 +38,7 @@ import { config } from '../../config' import { PoolDetailsSection } from './PoolDetailsSection' import { PoolSetupSection } from './PoolSetupSection' import { Line, PoolStructureSection } from './PoolStructureSection' -import { CreatePoolValues, initialValues, PoolFee } from './types' +import { CreatePoolValues, initialValues } from './types' import { pinFileIfExists, pinFiles } from './utils' import { validateValues } from './validate' @@ -324,31 +325,35 @@ const IssuerCreatePoolPage = () => { // Pool fees const feeId = await firstValueFrom(centrifuge.pools.getNextPoolFeeId()) - const metadataPoolFees: Pick[] = [] - const feeInput: PoolFeesCreatePool = [] - - values.poolFees.forEach((fee, index) => { - metadataPoolFees.push({ + const poolFees: AddFee['fee'][] = values.poolFees.map((fee) => { + return { name: fee.name, - id: feeId ? feeId + index : 0, - feePosition: fee.feePosition, + destination: fee.walletAddress, + amount: Rate.fromPercent(fee.percentOfNav), feeType: fee.feeType, - }) - - feeInput.push([ + limit: 'ShareOfPortfolioValuation', + account: fee.feeType === 'chargedUpTo' ? fee.walletAddress : undefined, + feePosition: fee.feePosition, + } + }) + metadataValues.poolFees = poolFees.map((fee, i) => ({ + name: fee.name, + id: feeId + i, + feePosition: fee.feePosition, + feeType: fee.feeType, + })) + + const feeInput = poolFees.map((fee) => { + return [ 'Top', { - destination: fee.walletAddress, - editor: fee.feeType === 'chargedUpTo' ? { account: fee.walletAddress } : 'Root', - feeType: { - [fee.feeType]: { limit: { ['ShareOfPortfolioValuation']: Rate.fromPercent(fee.percentOfNav) } }, - }, + destination: fee.destination, + editor: fee?.account ? { account: fee.account } : 'Root', + feeType: { [fee.feeType]: { limit: { [fee.limit]: fee?.amount } } }, }, - ]) + ] }) - metadataValues.poolFees = metadataPoolFees - // Multisign metadataValues.adminMultisig = values.adminMultisigEnabled && values.adminMultisig.threshold > 1 @@ -429,12 +434,6 @@ const IssuerCreatePoolPage = () => { })) }, [values, step]) - const isCreatePoolEnabled = - values.assetOriginators.length > 0 && - values.assetOriginators[0] !== '' && - values.adminMultisig.signers.length > 0 && - values.adminMultisig.signers[0] !== '' - return ( <> { small onClick={handleNextStep} loading={createProxiesIsPending || transactionIsPending || form.isSubmitting} - disabled={step === 3 && !isCreatePoolEnabled} // Disable the button if on step 3 and conditions aren't met + disabled={step === 3} > {step === 3 ? 'Create pool' : 'Next'} diff --git a/centrifuge-app/src/pages/IssuerCreatePool/types.ts b/centrifuge-app/src/pages/IssuerCreatePool/types.ts index 3e16414dbf..075ccd3c3b 100644 --- a/centrifuge-app/src/pages/IssuerCreatePool/types.ts +++ b/centrifuge-app/src/pages/IssuerCreatePool/types.ts @@ -36,15 +36,17 @@ export const createEmptyTranche = (trancheName: string): Tranche => ({ apyPercentage: null, }) -export const createPoolFee = (): PoolFee => ({ - id: 0, - name: '', - category: '', - feePosition: 'Top of waterfall', - feeType: '' as FeeTypes, - percentOfNav: 0, - walletAddress: '', -}) +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, @@ -58,6 +60,7 @@ export type CreatePoolValues = Omit< | 'issuerName' | 'epochHours' | 'epochMinutes' + | 'poolFees' > & { // pool structure issuerName: null | '' @@ -120,7 +123,7 @@ export const initialValues: CreatePoolValues = { threshold: 1, }, adminMultisigEnabled: false, - poolFees: [], + poolFees: [createPoolFee()], poolType: 'open', onboarding: { diff --git a/centrifuge-app/src/pages/IssuerCreatePool/validate.ts b/centrifuge-app/src/pages/IssuerCreatePool/validate.ts index 3d44a44993..b4a525caea 100644 --- a/centrifuge-app/src/pages/IssuerCreatePool/validate.ts +++ b/centrifuge-app/src/pages/IssuerCreatePool/validate.ts @@ -101,7 +101,7 @@ export const validateValues = (values: CreatePoolValues) => { values.tranches[0].apyPercentage !== null ? parseFloat(values.tranches[0].apyPercentage.toString()) : 0 values.poolFees.forEach((fee, i) => { - if (fee.name === '') { + 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) { diff --git a/fabric/src/components/FileUpload/index.tsx b/fabric/src/components/FileUpload/index.tsx index b2718e9977..c93e3af865 100644 --- a/fabric/src/components/FileUpload/index.tsx +++ b/fabric/src/components/FileUpload/index.tsx @@ -188,7 +188,7 @@ export function FileUpload({ > {small && ( - + {' '} {(curFile && typeof curFile !== 'string' && curFile.name) || 'Click to upload'} diff --git a/fabric/src/components/InputUnit/index.tsx b/fabric/src/components/InputUnit/index.tsx index 5a11ecd975..d4151ce928 100644 --- a/fabric/src/components/InputUnit/index.tsx +++ b/fabric/src/components/InputUnit/index.tsx @@ -73,7 +73,7 @@ export function InputLabel({ ) } -export function InputErrorMessage({ children, style }: { children: React.ReactNode; style: React.CSSProperties }) { +export function InputErrorMessage({ children, style }: { children: React.ReactNode; style?: React.CSSProperties }) { return ( {children} From 14658027720c28b0f67b9d08d869d00390986ef3 Mon Sep 17 00:00:00 2001 From: katty barroso Date: Tue, 10 Dec 2024 11:20:22 +0100 Subject: [PATCH 18/29] Add feeback --- .../src/components/FieldWithErrorMessage.tsx | 27 ++----------- .../IssuerCreatePool/PoolDetailsSection.tsx | 10 ++--- .../pages/IssuerCreatePool/PoolRatings.tsx | 5 +-- .../IssuerCreatePool/PoolStructureSection.tsx | 6 +-- .../src/pages/IssuerCreatePool/index.tsx | 14 ++++++- .../IssuerPool/Access/AssetOriginators.tsx | 1 - fabric/src/components/Checkbox/index.tsx | 38 +++---------------- fabric/src/components/RadioButton/index.tsx | 21 ++++++++++ 8 files changed, 51 insertions(+), 71 deletions(-) diff --git a/centrifuge-app/src/components/FieldWithErrorMessage.tsx b/centrifuge-app/src/components/FieldWithErrorMessage.tsx index 69fc3ef95b..eef59b73ef 100644 --- a/centrifuge-app/src/components/FieldWithErrorMessage.tsx +++ b/centrifuge-app/src/components/FieldWithErrorMessage.tsx @@ -1,32 +1,11 @@ -import { URLInput } from '@centrifuge/fabric' -import { Field, FieldAttributes, useField, useFormikContext } from 'formik' +import { Field, FieldAttributes, useField } from 'formik' import * as React from 'react' type Props = FieldAttributes & { label?: string | React.ReactElement - prefix?: string - isUrl?: boolean } export function FieldWithErrorMessage(props: Props) { - const [field, meta] = useField(props) - const form = useFormikContext() - - const handleChange = (event: React.ChangeEvent) => { - form.setFieldValue(field.name, event.target.value) - } - return props.isUrl ? ( - - ) : ( - - ) + const [, meta] = useField(props) + return } diff --git a/centrifuge-app/src/pages/IssuerCreatePool/PoolDetailsSection.tsx b/centrifuge-app/src/pages/IssuerCreatePool/PoolDetailsSection.tsx index 8b9f678cbc..c63d96fb5a 100644 --- a/centrifuge-app/src/pages/IssuerCreatePool/PoolDetailsSection.tsx +++ b/centrifuge-app/src/pages/IssuerCreatePool/PoolDetailsSection.tsx @@ -9,6 +9,7 @@ import { Text, TextAreaInput, TextInput, + URLInput, } from '@centrifuge/fabric' import { Field, FieldProps, useFormikContext } from 'formik' import { FieldWithErrorMessage } from '../../../src/components/FieldWithErrorMessage' @@ -198,18 +199,16 @@ export const PoolDetailsSection = () => { { { {({ field }: FieldProps) => ( )} diff --git a/centrifuge-app/src/pages/IssuerCreatePool/PoolStructureSection.tsx b/centrifuge-app/src/pages/IssuerCreatePool/PoolStructureSection.tsx index da1d5c243a..6bdea6211e 100644 --- a/centrifuge-app/src/pages/IssuerCreatePool/PoolStructureSection.tsx +++ b/centrifuge-app/src/pages/IssuerCreatePool/PoolStructureSection.tsx @@ -1,12 +1,12 @@ import { PoolMetadataInput } from '@centrifuge/centrifuge-js' import { Box, - Checkbox, CurrencyInput, Grid, IconHelpCircle, InputErrorMessage, NumberInput, + RadioButton, Select, Text, TextInput, @@ -98,11 +98,11 @@ export const CheckboxOption = ({ {...styles} > {onChange ? ( - + ) : ( {({ field, form, meta }: FieldProps) => ( - { const { poolCreationType } = useDebugFlags() const consts = useCentrifugeConsts() const { chainDecimals } = useCentrifugeConsts() + const pools = usePools() const createType = (poolCreationType as TransactionOptions['createType']) || config.poolCreationType || 'immediate' const { substrate: { addMultisig }, @@ -97,6 +99,7 @@ const IssuerCreatePoolPage = () => { const [preimageHash, setPreimageHash] = useState('') const [isPreimageDialogOpen, setIsPreimageDialogOpen] = useState(false) const [proposalId, setProposalId] = useState(null) + const [poolId, setPoolId] = useState(null) useEffect(() => { if (createType === 'notePreimage') { @@ -117,6 +120,15 @@ const IssuerCreatePoolPage = () => { } }, [centrifuge, createType]) + 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/${poolId}`) + } + }, [poolId, pools]) + const { execute: createProxies, isLoading: createProxiesIsPending } = useCentrifugeTransaction( `${txMessage[createType]} 1/2`, (cent) => { @@ -229,7 +241,7 @@ const IssuerCreatePoolPage = () => { } const [, , , , poolId] = args if (createType === 'immediate') { - navigate(`/pools/${poolId}`) + setPoolId(poolId) } else { const event = result.events.find(({ event }) => api.events.democracy.Proposed.is(event)) if (event) { diff --git a/centrifuge-app/src/pages/IssuerPool/Access/AssetOriginators.tsx b/centrifuge-app/src/pages/IssuerPool/Access/AssetOriginators.tsx index 4f8e173d6b..6c61ee51fc 100644 --- a/centrifuge-app/src/pages/IssuerPool/Access/AssetOriginators.tsx +++ b/centrifuge-app/src/pages/IssuerPool/Access/AssetOriginators.tsx @@ -208,7 +208,6 @@ function AOForm({ }), newMetadata ? cent.pools.setMetadata([poolId, newMetadata], { batch: true }) : of(null), ]).pipe( - // THIS IS WHERE PROXIES ARE BEING CREATED switchMap(([permissionTx, setMetadataTx]) => { const numProxyTypesPerHotWallet = 4 const deposit = proxyDepositFactor diff --git a/fabric/src/components/Checkbox/index.tsx b/fabric/src/components/Checkbox/index.tsx index 1553241be2..7155e9c0aa 100644 --- a/fabric/src/components/Checkbox/index.tsx +++ b/fabric/src/components/Checkbox/index.tsx @@ -10,22 +10,15 @@ type CheckboxProps = React.InputHTMLAttributes & { label?: string | React.ReactElement errorMessage?: string extendedClickArea?: boolean - variant?: 'square' | 'round' } -export function Checkbox({ - label, - errorMessage, - extendedClickArea, - variant = 'round', - ...checkboxProps -}: CheckboxProps) { +export function Checkbox({ label, errorMessage, extendedClickArea, ...checkboxProps }: CheckboxProps) { return ( - + {label && ( @@ -95,38 +88,17 @@ const StyledWrapper = styled(Flex)<{ $hasLabel: boolean }>` } ` -const StyledCheckbox = styled.input<{ $variant: 'square' | 'round' }>` +const StyledCheckbox = styled.input` width: 18px; height: 18px; appearance: none; - border-radius: ${({ $variant }) => ($variant === 'square' ? '2px' : '50%')}; + border-radius: 2px; border: 1px solid ${({ theme }) => theme.colors.borderPrimary}; position: relative; cursor: pointer; transition: background-color 0.2s, border-color 0.2s; - ${({ $variant, theme }) => - $variant === 'round' && - ` - &:checked { - border-color: ${theme.colors.textPrimary}; - } - - &:checked::after { - content: ''; - position: absolute; - top: 4px; - left: 4px; - width: 8px; - height: 8px; - border-radius: 50%; - background-color: ${theme.colors.textPrimary}; - } - `} - - ${({ $variant, theme }) => - $variant === 'square' && - ` + ${({ theme }) => ` &:checked { border-color: ${theme.colors.borderSecondary}; background-color: ${theme.colors.textGold}; diff --git a/fabric/src/components/RadioButton/index.tsx b/fabric/src/components/RadioButton/index.tsx index d9f016b3b3..a0c1c63a2e 100644 --- a/fabric/src/components/RadioButton/index.tsx +++ b/fabric/src/components/RadioButton/index.tsx @@ -62,6 +62,27 @@ const StyledRadioButton = styled.input` height: 18px; align-self: center; margin: -20px 0; + appearance: none; + border: 1px solid ${({ theme }) => theme.colors.textPrimary}; + border-radius: 50%; + position: relative; + cursor: pointer; + + &:checked { + border-color: ${({ theme }) => theme.colors.textPrimary}; + } + + &:checked::after { + content: ''; + width: 8px; + height: 8px; + background: ${({ theme }) => theme.colors.textPrimary}; + border-radius: 50%; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + } &:focus-visible + span { display: block; From 4c58801779e30ea5a4415ebb3ac5367b0f6f7152 Mon Sep 17 00:00:00 2001 From: katty barroso Date: Wed, 11 Dec 2024 10:32:30 +0100 Subject: [PATCH 19/29] Fix tranches apy APY should be based on junior token --- .../src/pages/IssuerCreatePool/PoolStructureSection.tsx | 4 ++-- centrifuge-js/src/modules/pools.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/centrifuge-app/src/pages/IssuerCreatePool/PoolStructureSection.tsx b/centrifuge-app/src/pages/IssuerCreatePool/PoolStructureSection.tsx index 6bdea6211e..01355bd193 100644 --- a/centrifuge-app/src/pages/IssuerCreatePool/PoolStructureSection.tsx +++ b/centrifuge-app/src/pages/IssuerCreatePool/PoolStructureSection.tsx @@ -291,7 +291,7 @@ export const PoolStructureSection = () => { - {({ field, form, meta }: FieldProps) => ( + {({ field, form }: FieldProps) => ( { } name={`tranches.${index}.apy`} disabled - value="90 Day" + value={apyOptions.find((option) => option.value === values.tranches[0].apy)?.label} /> )} diff --git a/centrifuge-js/src/modules/pools.ts b/centrifuge-js/src/modules/pools.ts index db37307072..f84ec5d6fb 100644 --- a/centrifuge-js/src/modules/pools.ts +++ b/centrifuge-js/src/modules/pools.ts @@ -1132,7 +1132,7 @@ export function getPoolsModule(inst: Centrifuge) { metadata.tranches.forEach((tranche, index) => { tranchesById[computeTrancheId(index, poolId)] = { minInitialInvestment: CurrencyBalance.fromFloat(tranche.minInvestment, currencyDecimals).toString(), - apy: tranche.apy, + apy: metadata.tranches[0].apy, apyPercentage: tranche.apyPercentage, } }) From b000a41e820fbfe6c7f3782c2a56a81ee0a55224 Mon Sep 17 00:00:00 2001 From: katty barroso Date: Thu, 12 Dec 2024 10:42:19 +0100 Subject: [PATCH 20/29] Add feeback --- .../IssuerCreatePool/IssuerCategories.tsx | 33 +++++-- .../IssuerCreatePool/PoolDetailsSection.tsx | 41 +++++---- .../pages/IssuerCreatePool/PoolRatings.tsx | 16 +++- .../IssuerCreatePool/PoolSetupSection.tsx | 88 ++++++++++++------- .../IssuerCreatePool/PoolStructureSection.tsx | 6 +- .../src/pages/IssuerCreatePool/index.tsx | 8 +- .../src/pages/IssuerCreatePool/validate.ts | 47 ++++++++-- fabric/src/components/Checkbox/index.tsx | 2 +- fabric/src/components/Select/index.tsx | 4 +- 9 files changed, 172 insertions(+), 73 deletions(-) diff --git a/centrifuge-app/src/pages/IssuerCreatePool/IssuerCategories.tsx b/centrifuge-app/src/pages/IssuerCreatePool/IssuerCategories.tsx index 7626739651..5ae6cbd2dc 100644 --- a/centrifuge-app/src/pages/IssuerCreatePool/IssuerCategories.tsx +++ b/centrifuge-app/src/pages/IssuerCreatePool/IssuerCategories.tsx @@ -1,6 +1,7 @@ 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' @@ -16,10 +17,18 @@ const PROVIDERS = [ { label: 'Other', value: 'other' }, ] -const LabelWithDeleteButton = ({ onDelete, hideButton }: { onDelete: () => void; hideButton: boolean }) => { +export const LabelWithDeleteButton = ({ + onDelete, + hideButton, + label, +}: { + onDelete: () => void + hideButton: boolean + label: string +}) => { return ( - Name of provider + {label} {!hideButton && ( @@ -51,29 +60,43 @@ export const IssuerCategoriesSection = () => { value={field.value} options={PROVIDERS} placeholder="Please select..." + errorMessage={meta.touched && meta.error ? meta.error : undefined} + activePlaceholder /> )} {category.type === 'other' && ( {({ field, meta }: FieldProps) => ( - + )} )}
- {({ field }: FieldProps) => ( - ( + remove(index)} hideButton={form.values.issuerCategories.length === 1} + label="Name of provider" /> } placeholder="Type here..." maxLength={100} + errorMessage={meta.touched && meta.error ? meta.error : undefined} + onBlur={field.onBlur} + as={TextInput} /> )} diff --git a/centrifuge-app/src/pages/IssuerCreatePool/PoolDetailsSection.tsx b/centrifuge-app/src/pages/IssuerCreatePool/PoolDetailsSection.tsx index c63d96fb5a..3b8509d170 100644 --- a/centrifuge-app/src/pages/IssuerCreatePool/PoolDetailsSection.tsx +++ b/centrifuge-app/src/pages/IssuerCreatePool/PoolDetailsSection.tsx @@ -5,6 +5,7 @@ import { CurrencyInput, FileUpload, Grid, + InputErrorMessage, Select, Text, TextAreaInput, @@ -59,6 +60,7 @@ export const PoolDetailsSection = () => { errorMessage={meta.touched && meta.error ? meta.error : undefined} accept="image/svg+xml" fileTypeText="SVG (in square size)" + onClear={() => form.setFieldValue('poolIcon', null)} /> )}
@@ -119,19 +121,23 @@ export const PoolDetailsSection = () => { {({ field, meta, form }: FieldProps) => ( - Legal name of the issuer*} /> - } - onChange={(event: any) => form.setFieldValue('issuerName', event.target.value)} - onBlur={field.onBlur} - errorMessage={meta.touched && meta.error ? meta.error : undefined} - value={field.value} - as={TextInput} - placeholder="Type here..." - maxLength={100} - /> + + 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} + )} @@ -142,6 +148,7 @@ export const PoolDetailsSection = () => { accept="image/png, image/jpeg, image/jpg" fileTypeText="SVG, PNG, or JPG (max. 1MB; 480x480px)" label="Issuer logo" + onClear={() => form.setFieldValue('issuerLogo', null)} /> )} @@ -169,7 +176,7 @@ export const PoolDetailsSection = () => { {({ field, meta, form }: FieldProps) => ( form.setFieldValue('issuerShortDescription', event.target.value)} onBlur={field.onBlur} errorMessage={meta.touched && meta.error ? meta.error : undefined} @@ -188,7 +195,7 @@ export const PoolDetailsSection = () => { validate={validate.issuerDescription} name="issuerDescription" as={TextAreaInput} - label="Overview page description (max. 3000 characters)*" + label="Overview page description (100-3000 characters)*" placeholder="Type here..." maxLength={1000} errorMessage={meta.touched && meta.error ? meta.error : undefined} @@ -228,6 +235,7 @@ export const PoolDetailsSection = () => { accept="application/pdf" label={createLabel('Executive summary PDF')} placeholder="Choose file" + onClear={() => form.setFieldValue(`executiveSummary`, null)} small /> )} @@ -274,7 +282,7 @@ export const PoolDetailsSection = () => { - Service analysis + Pool analysis { 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 index 7c7ffab232..fd8ea61eb9 100644 --- a/centrifuge-app/src/pages/IssuerCreatePool/PoolRatings.tsx +++ b/centrifuge-app/src/pages/IssuerCreatePool/PoolRatings.tsx @@ -2,17 +2,19 @@ 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 }) => ( + {({ push, remove }) => ( <> {form.values.poolRatings.map((_, index) => ( <> @@ -32,8 +34,14 @@ export const PoolRatingsSection = () => { remove(index)} + hideButton={form.values.poolRatings.length === 1} + label="Rating value" + /> + } /> )}
@@ -50,7 +58,7 @@ export const PoolRatingsSection = () => {
- {({ field, form }: FieldProps) => ( + {({ field, form, meta }: FieldProps) => ( { @@ -61,6 +69,8 @@ export const PoolRatingsSection = () => { label="Executive summary PDF" placeholder="Choose file" small + errorMessage={meta.touched && meta.error ? meta.error : undefined} + onClear={() => form.setFieldValue(`poolRatings.${index}.reportFile`, null)} /> )} diff --git a/centrifuge-app/src/pages/IssuerCreatePool/PoolSetupSection.tsx b/centrifuge-app/src/pages/IssuerCreatePool/PoolSetupSection.tsx index 21d8478dea..7b85f14191 100644 --- a/centrifuge-app/src/pages/IssuerCreatePool/PoolSetupSection.tsx +++ b/centrifuge-app/src/pages/IssuerCreatePool/PoolSetupSection.tsx @@ -35,17 +35,16 @@ const FEE_POSISTIONS = [{ label: 'Top of waterfall', value: 'Top of waterfall' } const TaxDocument = () => { const form = useFormikContext() - return ( - - Tax document requirement - + + + Tax document requirement + {({ field }: FieldProps) => ( form.setFieldValue('onboarding.taxInfoRequired', val.target.checked ? true : false)} /> )} @@ -65,6 +64,8 @@ export const PoolSetupSection = () => { form.setFieldValue('adminMultisig.signers[0]', selectedAccount?.address) }, []) + console.log(values) + return ( @@ -83,48 +84,60 @@ export const PoolSetupSection = () => { height={40} name="adminMultisigEnabled" label="Single" - value={false} - id="singleMultisign" icon={} + onChange={() => { + form.setFieldValue('adminMultisigEnabled', false) + }} + isChecked={!values.adminMultisigEnabled} /> } + onChange={() => { + form.setFieldValue('adminMultisigEnabled', true) + form.setFieldValue('adminMultisig.signers', [form.values.adminMultisig.signers[0], '']) + }} + isChecked={values.adminMultisigEnabled} /> Wallet addresses - {({ push }) => ( + {({ push, remove }) => ( <> {values.adminMultisigEnabled ? ( values.adminMultisig?.signers?.map((_, index) => ( {({ field, form, meta }: FieldProps) => ( - ) => { - form.setFieldValue(`adminMultisig.signers.${index}`, val.target.value) - }} - onBlur={() => { - form.setFieldTouched(`adminMultisig.signers.${index}`, true) - const value = form.values.adminMultisig.signers[index] - if (value) { - const transformedValue = isEvmAddress(value) - ? evmToSubstrateAddress(value, chainId ?? 0) - : value - form.setFieldValue(`adminMultisig.signers.${index}`, transformedValue) - } - }} - errorMessage={meta.touched && meta.error ? meta.error : undefined} - as={TextInput} - /> + + ) => { + form.setFieldValue(`adminMultisig.signers.${index}`, val.target.value) + }} + onBlur={() => { + form.setFieldTouched(`adminMultisig.signers.${index}`, true) + const value = form.values.adminMultisig.signers[index] + if (value) { + const transformedValue = isEvmAddress(value) + ? evmToSubstrateAddress(value, chainId ?? 0) + : value + form.setFieldValue(`adminMultisig.signers.${index}`, transformedValue) + } + }} + errorMessage={meta.touched && meta.error ? meta.error : undefined} + as={TextInput} + /> + {values.adminMultisig.signers.length >= 3 && index >= 2 && ( + remove(index)}> + + + )} + )} @@ -167,7 +180,7 @@ export const PoolSetupSection = () => { {({ field, meta, form }: FieldProps) => ( } + label={} onChange={(event) => { form.setFieldValue('assetClass', event.target.value) form.setFieldValue('subAssetClass', '', false) diff --git a/fabric/src/components/FileUpload/index.tsx b/fabric/src/components/FileUpload/index.tsx index c93e3af865..eece23aa94 100644 --- a/fabric/src/components/FileUpload/index.tsx +++ b/fabric/src/components/FileUpload/index.tsx @@ -1,6 +1,6 @@ import * as React from 'react' import styled from 'styled-components' -import { Box, IconUpload, InputUnit, InputUnitProps, StyledTextInput, Text } from '../..' +import { Box, IconButton, IconUpload, IconX, InputUnit, InputUnitProps, StyledTextInput, Text } from '../..' import { useControlledState } from '../../utils/useControlledState' import { Stack } from '../Stack' @@ -40,7 +40,7 @@ const FileDragOverContainer = styled(Stack)<{ $disabled?: boolean; $active: bool align-items: center; text-align: center; cursor: ${({ $disabled }) => ($disabled ? 'not-allowed' : 'pointer')}; - padding: 16px; + padding: 8px; &::before { content: ''; width: 100%; @@ -187,12 +187,23 @@ export function FileUpload({ small={small} > {small && ( - + {' '} {(curFile && typeof curFile !== 'string' && curFile.name) || 'Click to upload'} - + {curFile && typeof curFile !== 'string' && curFile.name ? ( + { + e.stopPropagation() + handleClear() + }} + > + + + ) : ( + + )} )} {!small && ( diff --git a/fabric/src/components/ImageUpload/index.tsx b/fabric/src/components/ImageUpload/index.tsx index 0aa1edb918..1799ca3af4 100644 --- a/fabric/src/components/ImageUpload/index.tsx +++ b/fabric/src/components/ImageUpload/index.tsx @@ -46,6 +46,8 @@ const FormField = styled.input` ` const Container = styled(Grid)<{ $disabled?: boolean; $active: boolean }>` + background-color: white; + border-radius: 8px; position: relative; &::before { content: ''; @@ -66,15 +68,13 @@ const Container = styled(Grid)<{ $disabled?: boolean; $active: boolean }>` } &:hover::before, &:has(:focus-visible)::before { - border: ${({ theme, $disabled }) => !$disabled && `1px solid ${theme.colors.accentPrimary}`}; + border: ${({ theme, $disabled }) => !$disabled && `1px solid ${theme.colors.textPrimary}`}; } ` export type ImageUploadProps = Omit & { file?: File | null - requirements?: string height?: ResponsiveValue - buttonLabel?: string } export function ImageUpload({ @@ -87,10 +87,8 @@ export function ImageUpload({ disabled, label, secondaryLabel, - requirements, height, - placeholder = 'Drag a file here', - buttonLabel = 'Choose file', + placeholder = '', ...inputProps }: ImageUploadProps) { const defaultId = React.useId() @@ -187,6 +185,7 @@ export function ImageUpload({ disabled={disabled} tabIndex={-1} ref={inputRef} + accept={accept} {...inputProps} /> - - {placeholder} - {requirements && {requirements}} + + + + Click to upload + + or drag and drop + + + + {placeholder} + + - From 77cda8b9c51ffd92762a0fdfcb49e4ad7dd5fdb7 Mon Sep 17 00:00:00 2001 From: katty barroso Date: Thu, 12 Dec 2024 13:03:16 +0100 Subject: [PATCH 23/29] Bug fixes --- .../PoolOverview/TransactionHistory.tsx | 4 ++-- .../pages/IssuerCreatePool/PoolSetupSection.tsx | 17 +++++++++++++---- .../src/pages/IssuerCreatePool/index.tsx | 9 +++++++-- 3 files changed, 22 insertions(+), 8 deletions(-) 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/pages/IssuerCreatePool/PoolSetupSection.tsx b/centrifuge-app/src/pages/IssuerCreatePool/PoolSetupSection.tsx index 7b85f14191..675c149b46 100644 --- a/centrifuge-app/src/pages/IssuerCreatePool/PoolSetupSection.tsx +++ b/centrifuge-app/src/pages/IssuerCreatePool/PoolSetupSection.tsx @@ -64,8 +64,6 @@ export const PoolSetupSection = () => { form.setFieldValue('adminMultisig.signers[0]', selectedAccount?.address) }, []) - console.log(values) - return ( @@ -89,6 +87,7 @@ export const PoolSetupSection = () => { form.setFieldValue('adminMultisigEnabled', false) }} isChecked={!values.adminMultisigEnabled} + id="singleMultisign" /> { form.setFieldValue('adminMultisig.signers', [form.values.adminMultisig.signers[0], '']) }} isChecked={values.adminMultisigEnabled} + id="multiMultisign" /> @@ -223,7 +223,7 @@ export const PoolSetupSection = () => { {values.assetOriginators?.map((_: string, index: number) => ( - {({ field, form, meta }: FieldProps) => ( + {({ field, form }: FieldProps) => ( { form.setFieldValue(`assetOriginators.${index}`, val.target.value) }} as={TextInput} - onBlur={field.onBlur} + onBlur={() => { + form.setFieldTouched(`assetOriginators.${index}`, true) + const value = form.values.assetOriginators[index] + if (value) { + const transformedValue = isEvmAddress(value) + ? evmToSubstrateAddress(value, chainId ?? 0) + : value + form.setFieldValue(`assetOriginators.${index}`, transformedValue) + } + }} /> )} diff --git a/centrifuge-app/src/pages/IssuerCreatePool/index.tsx b/centrifuge-app/src/pages/IssuerCreatePool/index.tsx index 3fd68d13c6..baec4a060f 100644 --- a/centrifuge-app/src/pages/IssuerCreatePool/index.tsx +++ b/centrifuge-app/src/pages/IssuerCreatePool/index.tsx @@ -94,7 +94,7 @@ const IssuerCreatePoolPage = () => { 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 [isMultisigDialogOpen, setIsMultisigDialogOpen] = useState(false) const [createdModal, setCreatedModal] = useState(false) const [preimageHash, setPreimageHash] = useState('') const [isPreimageDialogOpen, setIsPreimageDialogOpen] = useState(false) @@ -387,6 +387,11 @@ const IssuerCreatePoolPage = () => { } } + // Issuer categories + if (values.issuerCategories[0].value === '') { + metadataValues.issuerCategories = [] + } + createProxies([ (aoProxy, adminProxy) => { createPoolTx( @@ -508,7 +513,7 @@ const IssuerCreatePoolPage = () => { small onClick={handleNextStep} loading={createProxiesIsPending || transactionIsPending || form.isSubmitting} - disabled={step === 3} + disabled={step === 3 ? !(Object.keys(errors).length === 0) : false} > {step === 3 ? 'Create pool' : 'Next'} From d11affd23a2197002c316e900c6ec958280e1db7 Mon Sep 17 00:00:00 2001 From: katty barroso Date: Fri, 13 Dec 2024 12:18:37 +0100 Subject: [PATCH 24/29] Code review feedback --- centrifuge-app/src/components/LoanList.tsx | 1 - .../src/components/Menu/IssuerMenu.tsx | 2 +- .../IssuerCreatePool/FormAddressInput.tsx | 48 +++++++++++++++ .../IssuerCreatePool/IssuerCategories.tsx | 3 +- .../IssuerCreatePool/PoolSetupSection.tsx | 60 +++++-------------- .../src/pages/IssuerCreatePool/index.tsx | 2 +- .../src/pages/IssuerCreatePool/utils.ts | 11 ++-- .../src/pages/IssuerCreatePool/validate.ts | 14 +++++ .../IssuerPool/Configuration/AddressInput.tsx | 0 .../src/pages/Pool/Assets/index.tsx | 2 +- fabric/src/components/FileUpload/index.tsx | 13 ---- fabric/src/components/Select/index.tsx | 4 +- 12 files changed, 88 insertions(+), 72 deletions(-) create mode 100644 centrifuge-app/src/pages/IssuerCreatePool/FormAddressInput.tsx create mode 100644 centrifuge-app/src/pages/IssuerPool/Configuration/AddressInput.tsx diff --git a/centrifuge-app/src/components/LoanList.tsx b/centrifuge-app/src/components/LoanList.tsx index 1c91e660f1..cb204e25b6 100644 --- a/centrifuge-app/src/components/LoanList.tsx +++ b/centrifuge-app/src/components/LoanList.tsx @@ -282,7 +282,6 @@ export function LoanList({ loans, snapshots, isLoading }: Props) { } onChange={(e) => setShowRepaid(!showRepaid)} - variant="square" />