Skip to content

Commit

Permalink
Asset finance repay redesign (#2282)
Browse files Browse the repository at this point in the history
* Move transfer debt form so it's showed after the user chooses a source

* Add SourceSelect to repay forms

* Fix decimals

* Add fee charging to finance forms (WIP)

* Add remark txs

* Update Repay forms and add fees to repay

* Fix usabilty of fee select

* Fix tx execution

* Update charge fee UI

* Limit fee charging to borrower (fee must be added with borrower as destination)

* Add comment about fees

* Split up finance and repay drawers & UI improvements

* Move available financing to loan page

* Add total amount and rename amount to principal in forms

* Deploy PR preview to demo

* Purchase/Finance vs Sell/Repay and add totals

* Pass fee tx to remark tx to be executed there

* Implement transfer debt directly in finance form

* Allow multiple fees and check validity of inputs

* Disable buttons while form is incomplete

* Better form validation for external assets

* Finish adding transfer debt to all finance/repay forms

* Fix mac price variants from failing on non oracle assets

* Fix inconsistencies

* Transfer debt source/desitnation only show cash assets

* UI changes proposed by Jeroen

* Unify finance/repay and transfer debt forms

* Fix default behavior of add fee

* Add text blurbs

* Add charge fee summary

* Remove padding on add fees button

* Handle input/erros in repay form

* Update info texts in all forms

* Add error handling and better form control

* Fix bug in available external finance form

* Updates to inline feedback component

* Fix fee tx submission

* Validate upper limits in repay forms and fix required vs non required fields

* Rename

* Fix bugs in internal finance form

* Check charged fees against max charageable

* Remove indiv error messages in favor of general ones on repay form

* Improve error handling in repay form

* Fix wording

* Fix warning

* Remove repay all and allow repayments of just interest

* Fix type error

* Rename external repay form to sell and use source loan max interest

* Rename external finance form to purchase

* Prep for cash finance/repayments

* Add support for cash assets

* Add cash asset suuport to repay

* Clean up and fix form submissions

* Fix template rendering

* Fix decimal point error and TransferDebtAmountMismatched error

* Allow fees to be charged by non AO proxy destinations

* Cash: rename principal to amount and include withdrawal addresses

* Fix fee submission

* Revert charging without AO and remove uncharge fees

* Attempt to fix proxy call on repay

* Fix tx pushed mistake

* Wrap proxy calls and move remarks to module

* Remove fee percentage from dropdown

* Fix showing finance form

* Always show repay forms so that money can be transfer even without outstanding debt

* Fix bug where initial input is missing in fee category

* Charge fee difference instead of uncharging/recharging

* Fix available in repay forms

* Remove close all transaction

* Use USD for all virtual accounting processes

* Fix null in extension period

* Consitently use two commas in error messages

* Price and quantity updates (max, secondary labels), show buttons appropriatly

* Add custom padding to drawer

* Remove maturity date in loan list

* Remove decimals from quantity

* Reorder category options in repay

* Remove financing date for cash assets

* Remove low wallet balance warning

* Add interest rate in pricing values

* Add tooltip for additional amount input

* Better error messages

* Improve error messages and remove additional amount from max calcs

* Fix repay forms and add errors for balance checking

* Asset redesign fixes (#2353)

* Fix max quantity and principal

* Update repay boxes

* Update external repay form

* Update finance forms

* Error handling

* Transaction summary

* Add principal amount

* Update principal

* Use ids from dropdown

* Fix asset list report values

* Onchain reserve name

* Update type

* Fix another type

* Change gap

* Add changes for Jay and add disabled input for principal on external repay

* Add tooltips, improve spacing in summary and attempt to charge margin on interest

* Correct interest margin calc

* Use textencoder instead of Buffer, fix spacing in finance form summary, fix lint warnings

* Fix margin buffer and when to use it

* convert 5 minute buffer to seconds

* Add reserve info box to finance forms

* Fix layout of external repay

* Add component for ErrorMessage

* Add principal calc to external finance form

* Add missing gap

---------

Co-authored-by: Jeroen Offerijns <[email protected]>
Co-authored-by: Jeroen <[email protected]>
  • Loading branch information
3 people committed Aug 12, 2024
1 parent ae9e26d commit 8c38ff8
Show file tree
Hide file tree
Showing 25 changed files with 1,568 additions and 836 deletions.
26 changes: 13 additions & 13 deletions centrifuge-app/.env-config/.env.development
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
REACT_APP_COLLATOR_WSS_URL=wss://fullnode.development.cntrfg.com
REACT_APP_DEFAULT_UNLIST_POOLS=false
REACT_APP_FAUCET_URL=https://europe-central2-peak-vista-185616.cloudfunctions.net/faucet-api-dev
REACT_APP_COLLATOR_WSS_URL=wss://fullnode-apps.demo.k-f.dev
REACT_APP_DEFAULT_UNLIST_POOLS=true
REACT_APP_FAUCET_URL=https://europe-central2-peak-vista-185616.cloudfunctions.net/faucet-api-demo
REACT_APP_IPFS_GATEWAY=https://centrifuge.mypinata.cloud/
REACT_APP_IS_DEMO=false
REACT_APP_NETWORK=centrifuge
REACT_APP_ONBOARDING_API_URL=https://europe-central2-peak-vista-185616.cloudfunctions.net/onboarding-api-dev
REACT_APP_PINNING_API_URL=https://europe-central2-peak-vista-185616.cloudfunctions.net/pinning-api-dev
REACT_APP_IS_DEMO=true
REACT_APP_ONBOARDING_API_URL=https://europe-central2-peak-vista-185616.cloudfunctions.net/onboarding-api-demo
REACT_APP_PINNING_API_URL=https://europe-central2-peak-vista-185616.cloudfunctions.net/pinning-api-demo
REACT_APP_POOL_CREATION_TYPE=immediate
REACT_APP_RELAY_WSS_URL=wss://fullnode-relay.development.cntrfg.com
REACT_APP_SUBQUERY_URL=https://api.subquery.network/sq/centrifuge/pools-development
REACT_APP_SUBSCAN_URL=https://centrifuge.subscan.io
REACT_APP_RELAY_WSS_URL=wss://frag-moonbase-relay-rpc-ws.g.moonbase.moonbeam.network
REACT_APP_SUBQUERY_URL=https://api.subquery.network/sq/centrifuge/pools-demo-multichain
REACT_APP_SUBSCAN_URL=
REACT_APP_TINLAKE_NETWORK=goerli
REACT_APP_INFURA_KEY=8cd8e043ee8d4001b97a1c37e08fd9dd
REACT_APP_ONFINALITY_KEY=0e1c049f-d876-4e77-a45f-b5afdf5739b2
REACT_APP_WHITELISTED_ACCOUNTS=
REACT_APP_TINLAKE_SUBGRAPH_URL=https://api.goldsky.com/api/public/project_clhi43ef5g4rw49zwftsvd2ks/subgraphs/main/prod/gn
REACT_APP_NETWORK=centrifuge
REACT_APP_REWARDS_TREE_URL=https://storage.googleapis.com/rad-rewards-trees-kovan-staging/latest.json
REACT_APP_MEMBERLIST_ADMIN_PURE_PROXY=kALwmJutBq95s41U9fWnoApCUgvPqPGTh1GSmFnQh5f9fWo93
REACT_APP_WALLETCONNECT_ID=c32fa79350803519804a67fcab0b742a
REACT_APP_MEMBERLIST_ADMIN_PURE_PROXY=kAJ27w29x7gHM75xajP2yXVLjVBaKmmUTxHwgRuCoAcWaoEiz
REACT_APP_TREASURY=kAJkmGxAd6iqX9JjWTdhXgCf2PL1TAphTRYrmEqzBrYhwbXAn
REACT_APP_TINLAKE_SUBGRAPH_URL=https://api.goldsky.com/api/public/project_clhi43ef5g4rw49zwftsvd2ks/subgraphs/main/prod/gn
REACT_APP_TREASURY=kAJkmGxAd6iqX9JjWTdhXgCf2PL1TAphTRYrmEqzBrYhwbXAn
10 changes: 8 additions & 2 deletions centrifuge-app/src/components/LoanList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,10 @@ export function LoanList({ loans }: Props) {
header: <SortableTableHeader label="Financing date" />,
cell: (l: Row) => {
// @ts-expect-error value only exists on Tinlake loans and on active Centrifuge loans
return l.originationDate && (l.poolId.startsWith('0x') || l.status === 'Active')
return l.originationDate &&
(l.poolId.startsWith('0x') || l.status === 'Active') &&
'valuationMethod' in l.pricing &&
l.pricing.valuationMethod !== 'cash'
? // @ts-expect-error
formatDate(l.originationDate)
: '-'
Expand All @@ -132,7 +135,10 @@ export function LoanList({ loans }: Props) {
{
align: 'left',
header: <SortableTableHeader label="Maturity date" />,
cell: (l: Row) => (l?.maturityDate ? formatDate(l.maturityDate) : '-'),
cell: (l: Row) =>
l?.maturityDate && 'valuationMethod' in l.pricing && l.pricing.valuationMethod !== 'cash'
? formatDate(l.maturityDate)
: '-',
sortKey: 'maturityDate',
},
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export const ChargeFeesDrawer = ({ onClose, isOpen }: ChargeFeesProps) => {
const feeIndex = params.get('charge')
const feeMetadata = feeIndex ? poolMetadata?.pool?.poolFees?.find((f) => f.id.toString() === feeIndex) : undefined
const feeChainData = feeIndex ? poolFees?.find((f) => f.id.toString() === feeIndex) : undefined
const maxCharge = feeChainData?.amounts.percentOfNav.toDecimal().mul(pool.nav.aum.toDecimal()).div(100)
const maxCharge = feeChainData?.amounts.percentOfNav.toDecimal().mul(pool.nav.aum.toDecimal())
const [updateCharge, setUpdateCharge] = React.useState(false)
const address = useAddress()
const isAllowedToCharge = feeChainData?.destination && addressToHex(feeChainData.destination) === address
Expand Down
1 change: 1 addition & 0 deletions centrifuge-app/src/components/PoolFees/EditFeesDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,7 @@ export const EditFeesDrawer = ({ onClose, isOpen }: ChargeFeesProps) => {
receivingAddress: '',
feeId: undefined,
type: 'chargedUpTo',
category: feeCategories[0],
})
}
>
Expand Down
11 changes: 8 additions & 3 deletions centrifuge-app/src/components/Report/AssetList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Text } from '@centrifuge/fabric'
import { useContext, useEffect, useMemo } from 'react'
import { useBasePath } from '../../../src/utils/useBasePath'
import { formatDate } from '../../utils/date'
import { formatBalance } from '../../utils/formatting'
import { formatBalance, formatPercentage } from '../../utils/formatting'
import { getCSVDownloadUrl } from '../../utils/getCSVDownloadUrl'
import { useAllPoolAssetSnapshots, usePoolMetadata } from '../../utils/usePools'
import { DataTable, SortableTableHeader } from '../DataTable'
Expand Down Expand Up @@ -82,12 +82,17 @@ function getColumnConfig(isPrivate: boolean, symbol: string) {
formatter: (v: any) => (v ? formatDate(v) : 'Open-end'),
sortKey: 'maturity-date',
},
{ header: 'Valuation method', align: 'left', csvOnly: false, formatter: noop },
{
header: 'Valuation method',
align: 'left',
csvOnly: false,
formatter: (v: any) => (v === 'OutstandingDebt' ? 'At par' : v),
},
{
header: 'Advance rate',
align: 'left',
csvOnly: false,
formatter: (v: any) => (v ? formatBalance(v, symbol, 2) : '-'),
formatter: (v: any) => (v ? formatPercentage(v, true, {}, 2) : '-'),
},
{
header: 'Collateral value',
Expand Down
12 changes: 12 additions & 0 deletions centrifuge-app/src/components/Tooltips.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,18 @@ export const tooltipText = {
label: 'Token price',
body: 'The token price is equal to the NAV divided by the outstanding supply of tokens.',
},
additionalAmountInput: {
label: 'Additional amount',
body: 'This can be used to repay an additional amount beyond the outstanding principal and interest of the asset. This will lead to an increase in the NAV of the pool.',
},
repayFormAvailableBalance: {
label: 'Available balance',
body: 'Balance of the asset originator account on Centrifuge.',
},
repayFormAvailableBalanceUnlimited: {
label: 'Available balance',
body: 'Unlimited because this is a virtual accounting process.',
},
}

export type TooltipsProps = {
Expand Down
1 change: 1 addition & 0 deletions centrifuge-app/src/pages/IssuerCreatePool/PoolFeeInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export function PoolFeeSection() {
percentOfNav: '',
walletAddress: '',
feePosition: 'Top of waterfall',
category: feeCategories[0],
})
}}
small
Expand Down
183 changes: 183 additions & 0 deletions centrifuge-app/src/pages/Loan/ChargeFeesFields.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import { CurrencyBalance, Pool, addressToHex } from '@centrifuge/centrifuge-js'
import {
CombinedSubstrateAccount,
formatBalance,
useCentrifuge,
useCentrifugeApi,
wrapProxyCallsForAccount,
} from '@centrifuge/centrifuge-react'
import { Box, CurrencyInput, IconMinusCircle, IconPlusCircle, Select, Shelf, Stack, Text } from '@centrifuge/fabric'
import { Field, FieldArray, FieldProps, useFormikContext } from 'formik'
import React from 'react'
import { combineLatest, map, of } from 'rxjs'
import { Dec } from '../../utils/Decimal'
import { useBorrower } from '../../utils/usePermissions'
import { usePool, usePoolFees, usePoolMetadata } from '../../utils/usePools'
import { FinanceValues } from './ExternalFinanceForm'
import { RepayValues } from './RepayForm'

export const ChargeFeesFields = ({
pool,
borrower,
}: {
pool: Pool
borrower: CombinedSubstrateAccount | undefined
}) => {
const form = useFormikContext<FinanceValues>()
const { data: poolMetadata } = usePoolMetadata(pool)
const poolFees = usePoolFees(pool.id)
// fees can only be charged by the destination address
// fees destination must be set to the AO Proxy address
const chargableFees = React.useMemo(
() =>
poolFees?.filter(
(fee) => fee.type !== 'fixed' && borrower && addressToHex(fee.destination) === borrower.actingAddress
),
[poolFees, borrower]
)

const getOptions = React.useCallback(() => {
const chargableOptions = (chargableFees || []).map((f) => {
const feeName = poolMetadata?.pool?.poolFees?.find((feeMeta) => feeMeta.id === f.id)?.name || 'Unknown Fee'
return {
label: `${feeName}`,
value: f.id.toString(),
}
})
return chargableFees && chargableFees.length > 1
? [{ label: 'Select fee', value: '' }, ...chargableOptions]
: chargableOptions
}, [chargableFees, poolMetadata])

return (
<Stack gap={2}>
<FieldArray name="fees">
{({ remove, push }) => {
return (
<>
<Stack gap={2}>
<Stack gap={2}>
{form.values.fees.map((fee, index) => {
return (
<Shelf key={`${fee.id}-${index}`} gap={1} alignItems="flex-start">
<Box flex={1}>
<Select
options={getOptions()}
label="Fee"
onChange={(e) => {
form.setFieldValue(`fees.${index}.id`, e.target.value)
}}
value={form.values.fees[index].id}
/>
</Box>
<Box flex={1}>
<Field
key={`fees.${index}.amount`}
name={`fees.${index}.amount`}
validate={(value: number) => {
let error
if (!value) {
error = 'Enter an amount or remove the fee'
}
return error
}}
>
{({ field, meta }: FieldProps) => {
return (
<CurrencyInput
{...field}
label="Amount"
errorMessage={meta.touched ? meta.error : undefined}
currency={pool.currency.symbol}
placeholder="0"
onChange={(value) => form.setFieldValue(`fees.${index}.amount`, value)}
/>
)
}}
</Field>
</Box>
<Box
alignSelf="flex-start"
background="none"
border="none"
as="button"
mt={4}
style={{ cursor: 'pointer' }}
onClick={() => remove(index)}
>
<IconMinusCircle size="20px" />
</Box>
</Shelf>
)
})}
</Stack>
{chargableFees?.length ? (
<Shelf
gap={1}
alignItems="center"
as="button"
style={{ cursor: 'pointer', background: 'none', border: 'none' }}
onClick={(e) => {
e.preventDefault()
if (chargableFees.length === 1) {
return push({ id: chargableFees[0].id.toString(), amount: '' })
}
return push({ id: '', amount: '' })
}}
>
<IconPlusCircle size="20px" color="textButtonTertiary" />
<Text variant="label1" color="textButtonTertiary">
Add fee
</Text>
</Shelf>
) : null}
</Stack>
</>
)
}}
</FieldArray>
</Stack>
)
}

function ChargePoolFeeSummary({ poolId }: { poolId: string }) {
const form = useFormikContext<FinanceValues | RepayValues>()
const pool = usePool(poolId)
const totalFees = form.values.fees.reduce((acc, fee) => acc.add(Dec(fee.amount || 0)), Dec(0))

return form.values.fees.length > 0 ? (
<Stack gap={1}>
<Shelf justifyContent="space-between">
<Text variant="label2">Fees</Text>
<Text variant="label2">{formatBalance(Dec(totalFees), pool.currency.symbol, 2)}</Text>
</Shelf>
</Stack>
) : null
}

export function useChargePoolFees(poolId: string, loanId: string) {
const pool = usePool(poolId)
const borrower = useBorrower(poolId, loanId)
const api = useCentrifugeApi()
const cent = useCentrifuge()
return {
render: () => <ChargeFeesFields pool={pool as Pool} borrower={borrower} />,
renderSummary: () => <ChargePoolFeeSummary poolId={poolId} />,
isValid: ({ values }: { values: Pick<FinanceValues | RepayValues, 'fees'> }) => {
return values.fees.every((fee) => !!fee.id && !!fee.amount)
},
getBatch: ({ values }: { values: Pick<FinanceValues | RepayValues, 'fees'> }) => {
if (!values.fees.length) return of([])
const fees = values.fees.flatMap((fee) => {
if (!fee.amount) throw new Error('Charge amount not provided')
if (!borrower) throw new Error('No borrower')
const feeAmount = CurrencyBalance.fromFloat(fee.amount, pool.currency.decimals)
let feeTx = api.tx.poolFees.chargeFee(fee.id, feeAmount.toString())
return cent.remark
.remark([[{ Loan: [poolId, loanId] }], feeTx], { batch: true })
.pipe(map((tx) => wrapProxyCallsForAccount(api, tx, borrower, 'Borrow')))
})
return combineLatest(fees)
},
}
}
28 changes: 28 additions & 0 deletions centrifuge-app/src/pages/Loan/ErrorMessage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { Box, InlineFeedback, Text } from '@centrifuge/fabric'

type Props = {
children: React.ReactNode
type: 'default' | 'critical'
condition: boolean
}

const styles: Record<Props['type'], { bg: string; color: string }> = {
default: {
bg: 'statusDefaultBg',
color: 'statusDefault',
},
critical: {
bg: 'statusCriticalBg',
color: 'statusCritical',
},
}

export function ErrorMessage({ children, condition, type }: Props) {
return condition ? (
<Box bg={styles[type].bg} p={1}>
<InlineFeedback status={type}>
<Text color={styles[type].color}>{children}</Text>
</InlineFeedback>
</Box>
) : null
}
Loading

0 comments on commit 8c38ff8

Please sign in to comment.