diff --git a/centrifuge-app/.env-config/.env.development b/centrifuge-app/.env-config/.env.development
index 5114861124..52fcfca65a 100644
--- a/centrifuge-app/.env-config/.env.development
+++ b/centrifuge-app/.env-config/.env.development
@@ -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
\ No newline at end of file
+REACT_APP_TINLAKE_SUBGRAPH_URL=https://api.goldsky.com/api/public/project_clhi43ef5g4rw49zwftsvd2ks/subgraphs/main/prod/gn
+REACT_APP_TREASURY=kAJkmGxAd6iqX9JjWTdhXgCf2PL1TAphTRYrmEqzBrYhwbXAn
diff --git a/centrifuge-app/src/components/LoanList.tsx b/centrifuge-app/src/components/LoanList.tsx
index 7371a4ce07..17d74d553a 100644
--- a/centrifuge-app/src/components/LoanList.tsx
+++ b/centrifuge-app/src/components/LoanList.tsx
@@ -121,7 +121,10 @@ export function LoanList({ loans }: Props) {
header: ,
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)
: '-'
@@ -132,7 +135,10 @@ export function LoanList({ loans }: Props) {
{
align: 'left',
header: ,
- 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',
},
{
diff --git a/centrifuge-app/src/components/PoolFees/ChargeFeesDrawer.tsx b/centrifuge-app/src/components/PoolFees/ChargeFeesDrawer.tsx
index 9a61d8221c..da3c21d245 100644
--- a/centrifuge-app/src/components/PoolFees/ChargeFeesDrawer.tsx
+++ b/centrifuge-app/src/components/PoolFees/ChargeFeesDrawer.tsx
@@ -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
diff --git a/centrifuge-app/src/components/PoolFees/EditFeesDrawer.tsx b/centrifuge-app/src/components/PoolFees/EditFeesDrawer.tsx
index 3fa55b2524..c9f33a9cd2 100644
--- a/centrifuge-app/src/components/PoolFees/EditFeesDrawer.tsx
+++ b/centrifuge-app/src/components/PoolFees/EditFeesDrawer.tsx
@@ -322,6 +322,7 @@ export const EditFeesDrawer = ({ onClose, isOpen }: ChargeFeesProps) => {
receivingAddress: '',
feeId: undefined,
type: 'chargedUpTo',
+ category: feeCategories[0],
})
}
>
diff --git a/centrifuge-app/src/components/Report/AssetList.tsx b/centrifuge-app/src/components/Report/AssetList.tsx
index 6f26cd97b2..96747498cb 100644
--- a/centrifuge-app/src/components/Report/AssetList.tsx
+++ b/centrifuge-app/src/components/Report/AssetList.tsx
@@ -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'
@@ -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',
diff --git a/centrifuge-app/src/components/Tooltips.tsx b/centrifuge-app/src/components/Tooltips.tsx
index 1fc5d6e19b..2b51b6bf31 100644
--- a/centrifuge-app/src/components/Tooltips.tsx
+++ b/centrifuge-app/src/components/Tooltips.tsx
@@ -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 = {
diff --git a/centrifuge-app/src/pages/IssuerCreatePool/PoolFeeInput.tsx b/centrifuge-app/src/pages/IssuerCreatePool/PoolFeeInput.tsx
index 6ba2a0c109..fc7afbf80e 100644
--- a/centrifuge-app/src/pages/IssuerCreatePool/PoolFeeInput.tsx
+++ b/centrifuge-app/src/pages/IssuerCreatePool/PoolFeeInput.tsx
@@ -76,6 +76,7 @@ export function PoolFeeSection() {
percentOfNav: '',
walletAddress: '',
feePosition: 'Top of waterfall',
+ category: feeCategories[0],
})
}}
small
diff --git a/centrifuge-app/src/pages/Loan/ChargeFeesFields.tsx b/centrifuge-app/src/pages/Loan/ChargeFeesFields.tsx
new file mode 100644
index 0000000000..2781b6733b
--- /dev/null
+++ b/centrifuge-app/src/pages/Loan/ChargeFeesFields.tsx
@@ -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()
+ 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 (
+
+
+ {({ remove, push }) => {
+ return (
+ <>
+
+
+ {form.values.fees.map((fee, index) => {
+ return (
+
+
+
+
+ {
+ let error
+ if (!value) {
+ error = 'Enter an amount or remove the fee'
+ }
+ return error
+ }}
+ >
+ {({ field, meta }: FieldProps) => {
+ return (
+ form.setFieldValue(`fees.${index}.amount`, value)}
+ />
+ )
+ }}
+
+
+ remove(index)}
+ >
+
+
+
+ )
+ })}
+
+ {chargableFees?.length ? (
+ {
+ e.preventDefault()
+ if (chargableFees.length === 1) {
+ return push({ id: chargableFees[0].id.toString(), amount: '' })
+ }
+ return push({ id: '', amount: '' })
+ }}
+ >
+
+
+ Add fee
+
+
+ ) : null}
+
+ >
+ )
+ }}
+
+
+ )
+}
+
+function ChargePoolFeeSummary({ poolId }: { poolId: string }) {
+ const form = useFormikContext()
+ 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 ? (
+
+
+ Fees
+ {formatBalance(Dec(totalFees), pool.currency.symbol, 2)}
+
+
+ ) : 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: () => ,
+ renderSummary: () => ,
+ isValid: ({ values }: { values: Pick }) => {
+ return values.fees.every((fee) => !!fee.id && !!fee.amount)
+ },
+ getBatch: ({ values }: { values: Pick }) => {
+ 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)
+ },
+ }
+}
diff --git a/centrifuge-app/src/pages/Loan/ErrorMessage.tsx b/centrifuge-app/src/pages/Loan/ErrorMessage.tsx
new file mode 100644
index 0000000000..d27bde3308
--- /dev/null
+++ b/centrifuge-app/src/pages/Loan/ErrorMessage.tsx
@@ -0,0 +1,28 @@
+import { Box, InlineFeedback, Text } from '@centrifuge/fabric'
+
+type Props = {
+ children: React.ReactNode
+ type: 'default' | 'critical'
+ condition: boolean
+}
+
+const styles: Record = {
+ default: {
+ bg: 'statusDefaultBg',
+ color: 'statusDefault',
+ },
+ critical: {
+ bg: 'statusCriticalBg',
+ color: 'statusCritical',
+ },
+}
+
+export function ErrorMessage({ children, condition, type }: Props) {
+ return condition ? (
+
+
+ {children}
+
+
+ ) : null
+}
diff --git a/centrifuge-app/src/pages/Loan/ExternalFinanceForm.tsx b/centrifuge-app/src/pages/Loan/ExternalFinanceForm.tsx
index 96ea0f8440..db070c8f54 100644
--- a/centrifuge-app/src/pages/Loan/ExternalFinanceForm.tsx
+++ b/centrifuge-app/src/pages/Loan/ExternalFinanceForm.tsx
@@ -1,40 +1,74 @@
-import { CurrencyBalance, ExternalLoan, Pool, Price, WithdrawAddress } from '@centrifuge/centrifuge-js'
+import {
+ ActiveLoan,
+ CreatedLoan,
+ CurrencyBalance,
+ ExternalLoan,
+ Pool,
+ Price,
+ WithdrawAddress,
+} from '@centrifuge/centrifuge-js'
import { useCentrifugeApi, useCentrifugeTransaction, wrapProxyCallsForAccount } from '@centrifuge/centrifuge-react'
-import { Box, Button, Card, CurrencyInput, Shelf, Stack, Text } from '@centrifuge/fabric'
+import { Button, CurrencyInput, InlineFeedback, Shelf, Stack, Text, Tooltip } from '@centrifuge/fabric'
+import { BN } from 'bn.js'
import Decimal from 'decimal.js-light'
-import { Field, FieldProps, Form, FormikProvider, useFormik, useFormikContext } from 'formik'
+import { Field, FieldProps, Form, FormikProvider, useFormik } from 'formik'
import * as React from 'react'
import { combineLatest, switchMap } from 'rxjs'
-import { Dec, min } from '../../utils/Decimal'
+import { AnchorTextLink } from '../../components/TextLink'
+import { Dec } from '../../utils/Decimal'
import { formatBalance } from '../../utils/formatting'
import { useFocusInvalidInput } from '../../utils/useFocusInvalidInput'
-import { useAvailableFinancing } from '../../utils/useLoans'
+import { useLoans } from '../../utils/useLoans'
import { useBorrower } from '../../utils/usePermissions'
import { usePool } from '../../utils/usePools'
-import { combine, maxPriceVariance, nonNegativeNumber, positiveNumber, required } from '../../utils/validation'
+import { combine, maxPriceVariance, positiveNumber, required } from '../../utils/validation'
+import { useChargePoolFees } from './ChargeFeesFields'
+import { ErrorMessage } from './ErrorMessage'
import { useWithdraw } from './FinanceForm'
-type FinanceValues = {
+export type FinanceValues = {
price: number | '' | Decimal
quantity: number | ''
withdraw: undefined | WithdrawAddress
+ fees: { id: string; amount: '' | number | Decimal }[]
}
-export function ExternalFinanceForm({ loan }: { loan: ExternalLoan }) {
+/**
+ * Finance form for loans with `valuationMethod === oracle`
+ */
+export function ExternalFinanceForm({ loan, source }: { loan: ExternalLoan; source: string }) {
const pool = usePool(loan.poolId) as Pool
const account = useBorrower(loan.poolId, loan.id)
+ const poolFees = useChargePoolFees(loan.poolId, loan.id)
const api = useCentrifugeApi()
- if (!account) throw new Error('No borrower')
- const { current: availableFinancing } = useAvailableFinancing(loan.poolId, loan.id)
+ const loans = useLoans(loan.poolId)
+ const sourceLoan = loans?.find((l) => l.id === source) as CreatedLoan | ActiveLoan
+ const displayCurrency = source === 'reserve' ? pool.currency.symbol : 'USD'
const { execute: doFinanceTransaction, isLoading: isFinanceLoading } = useCentrifugeTransaction(
- 'Finance asset',
+ 'Purchase asset',
(cent) => (args: [poolId: string, loanId: string, quantity: Price, price: CurrencyBalance], options) => {
+ if (!account) throw new Error('No borrower')
const [poolId, loanId, quantity, price] = args
- return combineLatest([
- cent.pools.financeExternalLoan([poolId, loanId, quantity, price], { batch: true }),
- withdraw.getBatch(financeForm),
- ]).pipe(
- switchMap(([loanTx, batch]) => {
+ let financeTx
+ if (source === 'reserve') {
+ financeTx = cent.pools.financeExternalLoan([poolId, loanId, quantity, price], { batch: true })
+ } else {
+ const principal = new CurrencyBalance(
+ price.mul(new BN(quantity.toDecimal().toString())),
+ pool.currency.decimals
+ )
+ const repay = { principal, interest: new BN(0), unscheduled: new BN(0) }
+ const borrow = {
+ quantity,
+ price,
+ interest: new BN(0),
+ unscheduled: new BN(0),
+ }
+ financeTx = cent.pools.transferLoanDebt([poolId, sourceLoan.id, loan.id, repay, borrow], { batch: true })
+ }
+ return combineLatest([financeTx, withdraw.getBatch(financeForm), poolFees.getBatch(financeForm)]).pipe(
+ switchMap(([loanTx, withdrawBatch, poolFeesBatch]) => {
+ let batch = [...withdrawBatch, ...poolFeesBatch]
let tx = wrapProxyCallsForAccount(api, loanTx, account, 'Borrow')
if (batch.length) {
tx = api.tx.utility.batchAll([tx, ...batch])
@@ -55,11 +89,11 @@ export function ExternalFinanceForm({ loan }: { loan: ExternalLoan }) {
price: '',
quantity: '',
withdraw: undefined,
+ fees: [],
},
onSubmit: (values, actions) => {
- const price = CurrencyBalance.fromFloat(values.price, pool.currency.decimals)
+ const price = CurrencyBalance.fromFloat(values.price.toString(), pool.currency.decimals)
const quantity = Price.fromFloat(values.quantity)
-
doFinanceTransaction([loan.poolId, loan.id, quantity, price], {
account,
})
@@ -71,105 +105,154 @@ export function ExternalFinanceForm({ loan }: { loan: ExternalLoan }) {
const financeFormRef = React.useRef(null)
useFocusInvalidInput(financeForm, financeFormRef)
- const amountDec = Dec(financeForm.values.price || 0).mul(Dec(financeForm.values.quantity || 0))
+ const totalFinance = Dec(financeForm.values.price || 0).mul(Dec(financeForm.values.quantity || 0))
+ const maxAvailable =
+ source === 'reserve' ? pool.reserve.available.toDecimal() : sourceLoan.outstandingDebt.toDecimal()
- const withdraw = useWithdraw(loan.poolId, account, amountDec)
+ const withdraw = useWithdraw(loan.poolId, account!, totalFinance)
if (loan.status === 'Closed' || ('valuationMethod' in loan.pricing && loan.pricing.valuationMethod !== 'oracle')) {
return null
}
- const maturityDatePassed = loan?.pricing.maturityDate && new Date() > new Date(loan.pricing.maturityDate)
-
return (
-
-
- To finance the asset, enter quantity and settlement price of the transaction.
-
- {availableFinancing.greaterThan(0) && !maturityDatePassed && (
+ <>
+ {
-
- {withdraw.render()}
+
+
+ {({ field, form }: FieldProps) => {
+ return (
+ form.setFieldValue('quantity', value)}
+ />
+ )
+ }}
+
+ {
+ const financeAmount = Dec(val).mul(financeForm.values.quantity || 1)
+ return financeAmount.gt(maxAvailable)
+ ? `Amount exceeds available (${formatBalance(maxAvailable, displayCurrency, 2)})`
+ : ''
+ },
+ maxPriceVariance(loan.pricing)
+ )}
+ >
+ {({ field, form }: FieldProps) => {
+ return (
+ form.setFieldValue('price', value)}
+ decimals={8}
+ />
+ )
+ }}
+
+
- Total amount
-
- {financeForm.values.price && !Number.isNaN(financeForm.values.price as number)
- ? formatBalance(amountDec, pool?.currency.symbol, 2)
- : `0.00 ${pool.currency.symbol}`}
+
+ ={' '}
+ {formatBalance(
+ Dec(financeForm.values.price || 0).mul(financeForm.values.quantity || 0),
+ displayCurrency,
+ 2
+ )}{' '}
+ principal
-
-
- )}
-
- )
-}
-
-export function ExternalFinanceFields({
- loan,
- pool,
- validate,
-}: {
- loan: ExternalLoan
- pool: Pool
- validate?: (val: any) => string
-}) {
- const form = useFormikContext()
- const { current: availableFinancing } = useAvailableFinancing(loan.poolId, loan.id)
- const poolReserve = pool?.reserve.available.toDecimal() ?? Dec(0)
- const maxBorrow = min(poolReserve, availableFinancing)
- return (
- <>
-
- {({ field, meta, form }: FieldProps) => {
- return (
- form.setFieldValue('quantity', value)}
- />
- )
- }}
-
- {
- const financeAmount = Dec(val).mul(form.values.quantity || 1)
-
- return financeAmount.gt(maxBorrow)
- ? `Amount exceeds max borrow (${formatBalance(maxBorrow, pool.currency.symbol, 2)})`
- : ''
- }),
- maxPriceVariance(loan.pricing)
- )}
- >
- {({ field, meta, form }: FieldProps) => {
- return (
- form.setFieldValue('price', value)}
- decimals={8}
- />
- )
- }}
-
+ }
>
)
}
diff --git a/centrifuge-app/src/pages/Loan/ExternalRepayForm.tsx b/centrifuge-app/src/pages/Loan/ExternalRepayForm.tsx
index bf09c84ae8..975e2bd86a 100644
--- a/centrifuge-app/src/pages/Loan/ExternalRepayForm.tsx
+++ b/centrifuge-app/src/pages/Loan/ExternalRepayForm.tsx
@@ -1,32 +1,93 @@
-import { CurrencyBalance, ExternalLoan, findBalance, Price } from '@centrifuge/centrifuge-js'
-import { roundDown, useBalances, useCentrifugeTransaction } from '@centrifuge/centrifuge-react'
-import { Box, Button, Card, CurrencyInput, Shelf, Stack, Text } from '@centrifuge/fabric'
-import BN from 'bn.js'
+import { ActiveLoan, CurrencyBalance, ExternalLoan, findBalance, Price, Rate } from '@centrifuge/centrifuge-js'
+import {
+ useBalances,
+ useCentrifugeTransaction,
+ useCentrifugeUtils,
+ wrapProxyCallsForAccount,
+} from '@centrifuge/centrifuge-react'
+import { Button, CurrencyInput, InlineFeedback, Shelf, Stack, Text } from '@centrifuge/fabric'
+import { BN } from 'bn.js'
import Decimal from 'decimal.js-light'
import { Field, FieldProps, Form, FormikProvider, useFormik } from 'formik'
import * as React from 'react'
+import { combineLatest, switchMap } from 'rxjs'
+import { copyable } from '../../components/Report/utils'
+import { Tooltips } from '../../components/Tooltips'
import { Dec } from '../../utils/Decimal'
import { formatBalance } from '../../utils/formatting'
import { useFocusInvalidInput } from '../../utils/useFocusInvalidInput'
+import { useLoans } from '../../utils/useLoans'
import { useBorrower } from '../../utils/usePermissions'
import { usePool } from '../../utils/usePools'
-import { combine, maxPriceVariance, positiveNumber, required } from '../../utils/validation'
+import { combine, maxNotRequired, nonNegativeNumberNotRequired } from '../../utils/validation'
+import { useChargePoolFees } from './ChargeFeesFields'
+import { ErrorMessage } from './ErrorMessage'
type RepayValues = {
price: number | '' | Decimal
+ interest: number | '' | Decimal
+ amountAdditional: number | '' | Decimal
quantity: number | ''
+ fees: { id: string; amount: number | '' | Decimal }[]
}
-export function ExternalRepayForm({ loan }: { loan: ExternalLoan }) {
+const UNLIMITED = Dec(1000000000000000)
+
+/**
+ * Repay form for loans with `valuationMethod === oracle
+ */
+export function ExternalRepayForm({ loan, destination }: { loan: ExternalLoan; destination: string }) {
const pool = usePool(loan.poolId)
const account = useBorrower(loan.poolId, loan.id)
- if (!account) throw new Error('No borrower')
- const balances = useBalances(account.actingAddress)
+ const balances = useBalances(account?.actingAddress)
const balance = (balances && findBalance(balances.currencies, pool.currency.key)?.balance.toDecimal()) || Dec(0)
+ const poolFees = useChargePoolFees(loan.poolId, loan.id)
+ const loans = useLoans(loan.poolId)
+ const destinationLoan = loans?.find((l) => l.id === destination) as ActiveLoan
+ const displayCurrency = destination === 'reserve' ? pool.currency.symbol : 'USD'
+ const utils = useCentrifugeUtils()
const { execute: doRepayTransaction, isLoading: isRepayLoading } = useCentrifugeTransaction(
- 'Repay asset',
- (cent) => cent.pools.repayExternalLoanPartially,
+ 'Sell asset',
+ (cent) =>
+ (
+ args: [quantity: Price, interest: CurrencyBalance, amountAdditional: CurrencyBalance, price: CurrencyBalance],
+ options
+ ) => {
+ const [quantity, interest, amountAdditional, price] = args
+ if (!account) throw new Error('No borrower')
+ let repayTx
+ if (destination === 'reserve') {
+ repayTx = cent.pools.repayExternalLoanPartially(
+ [pool.id, loan.id, quantity, interest, amountAdditional, price],
+ {
+ batch: true,
+ }
+ )
+ } else {
+ const repay = { quantity, price, interest, unscheduled: amountAdditional }
+ const principal = new CurrencyBalance(
+ price.mul(new BN(quantity.toDecimal().toString())),
+ pool.currency.decimals
+ )
+ let borrow = {
+ amount: new CurrencyBalance(
+ principal.add(interest).add(amountAdditional).toString(),
+ pool.currency.decimals
+ ),
+ }
+ repayTx = cent.pools.transferLoanDebt([pool.id, loan.id, destinationLoan.id, repay, borrow], { batch: true })
+ }
+ return combineLatest([cent.getApi(), repayTx, poolFees.getBatch(repayForm)]).pipe(
+ switchMap(([api, repayTx, batch]) => {
+ let tx = wrapProxyCallsForAccount(api, repayTx, account, 'Borrow')
+ if (batch.length) {
+ tx = api.tx.utility.batchAll([tx, ...batch])
+ }
+ return cent.wrapSignAndSend(api, tx, { ...options, proxies: undefined })
+ })
+ )
+ },
{
onSuccess: () => {
repayForm.resetForm()
@@ -34,26 +95,48 @@ export function ExternalRepayForm({ loan }: { loan: ExternalLoan }) {
}
)
- const { execute: doCloseTransaction, isLoading: isCloseLoading } = useCentrifugeTransaction(
- 'Close asset',
- (cent) => cent.pools.closeLoan
- )
-
- const currentFace =
- loan?.pricing && 'outstandingQuantity' in loan.pricing
- ? loan.pricing.outstandingQuantity.toDecimal().mul(loan.pricing.notional.toDecimal())
- : null
-
const repayForm = useFormik({
initialValues: {
price: '',
quantity: '',
+ fees: [],
+ interest: '',
+ amountAdditional: '',
},
onSubmit: (values, actions) => {
- const price = CurrencyBalance.fromFloat(values.price, pool.currency.decimals)
+ const price = CurrencyBalance.fromFloat(values.price || 0, pool.currency.decimals)
+ let interest = CurrencyBalance.fromFloat(values?.interest || 0, pool.currency.decimals)
+ const amountAdditional = CurrencyBalance.fromFloat(values.amountAdditional || 0, pool.currency.decimals)
const quantity = Price.fromFloat(values.quantity || 0)
- doRepayTransaction([loan.poolId, loan.id, quantity, new BN(0), new BN(0), price], {
+ if (interest.toDecimal().eq(maxInterest) && quantity.toDecimal().eq(maxQuantity.toDecimal())) {
+ const outstandingInterest =
+ 'outstandingInterest' in loan
+ ? loan.outstandingInterest
+ : CurrencyBalance.fromFloat(0, pool.currency.decimals)
+ const outstandingPrincipal =
+ 'outstandingPrincipal' in loan
+ ? loan.outstandingPrincipal
+ : CurrencyBalance.fromFloat(0, pool.currency.decimals)
+
+ const fiveMinuteBuffer = 5 * 60
+ const time = Date.now() + fiveMinuteBuffer - loan.fetchedAt.getTime()
+ const mostUpToDateInterest = CurrencyBalance.fromFloat(
+ outstandingPrincipal
+ .toDecimal()
+ .mul(Rate.fractionFromAprPercent(loan.pricing.interestRate.toDecimal()).toDecimal())
+ .mul(time)
+ .add(outstandingInterest.toDecimal()),
+ pool.currency.decimals
+ )
+ interest = mostUpToDateInterest
+ console.log(
+ `Repaying with interest including buffer ${mostUpToDateInterest.toDecimal()} instead of ${outstandingInterest.toDecimal()}`,
+ loan.pricing.interestRate.toDecimal().toString()
+ )
+ }
+
+ doRepayTransaction([quantity, interest, amountAdditional, price], {
account,
})
actions.setSubmitting(false)
@@ -64,111 +147,200 @@ export function ExternalRepayForm({ loan }: { loan: ExternalLoan }) {
const repayFormRef = React.useRef(null)
useFocusInvalidInput(repayForm, repayFormRef)
+ const { maxAvailable, maxInterest, totalRepay, maxQuantity, principal } = React.useMemo(() => {
+ const outstandingInterest = 'outstandingInterest' in loan ? loan.outstandingInterest.toDecimal() : Dec(0)
+ const { quantity, interest, price, amountAdditional } = repayForm.values
+ const totalRepay = Dec(price || 0)
+ .mul(quantity || 0)
+ .add(interest || 0)
+ .add(amountAdditional || 0)
+
+ const principal = Dec(price || 0).mul(quantity || 0)
+
+ const maxInterest = outstandingInterest
+ let maxQuantity = loan.pricing.outstandingQuantity
+ let maxAvailable
+ if (destination === 'reserve') {
+ maxAvailable = balance
+ } else {
+ maxAvailable = UNLIMITED
+ }
+
+ return {
+ maxAvailable,
+ maxInterest,
+ maxQuantity,
+ totalRepay,
+ principal,
+ }
+ }, [loan, balance, repayForm.values, destination])
+
if (loan.status === 'Closed' || ('valuationMethod' in loan.pricing && loan.pricing.valuationMethod !== 'oracle')) {
return null
}
- const debt = loan.outstandingDebt?.toDecimal() || Dec(0)
-
return (
-
-
- To repay the asset, enter quantity and settlement price of the transaction.
-
-
- {currentFace ? (
-
+
+
+
+
+ {
+ if (Dec(val || 0).gt(maxQuantity.toDecimal())) {
+ return `Quantity exeeds max (${maxQuantity.toString()})`
+ }
+ return ''
+ })}
+ name="quantity"
+ >
+ {({ field, form }: FieldProps) => {
+ return (
+ form.setFieldValue('quantity', value)}
+ placeholder="0"
+ onSetMax={() =>
+ form.setFieldValue('quantity', loan.pricing.outstandingQuantity.toDecimal().toNumber())
+ }
+ />
+ )
+ }}
+
+
+ {({ field, form }: FieldProps) => {
+ return (
+ form.setFieldValue('price', value)}
+ decimals={8}
+ currency={displayCurrency}
+ />
+ )
+ }}
+
+
- Current face
- {formatBalance(currentFace, pool.currency.symbol, 2, 2)}
+
+ = {formatBalance(principal, displayCurrency, 2)} principal
+
- ) : null}
-
- {loan.status !== 'Created' &&
- (debt.gt(0) ? (
-
-
-
- {({ field, meta, form }: FieldProps) => {
- return (
- form.setFieldValue('quantity', value)}
- currency={pool.currency.symbol}
- />
- )
- }}
-
- {
- const num = val instanceof Decimal ? val.toNumber() : val
- const repayAmount = Dec(num).mul(repayForm.values.quantity)
-
- return repayAmount.gt(balance)
- ? `Your wallet balance (${formatBalance(
- roundDown(balance),
- pool?.currency.symbol,
- 2
- )}) is smaller than
- the outstanding balance.`
- : ''
- },
- maxPriceVariance(loan.pricing)
- )}
- name="price"
- >
- {({ field, meta, form }: FieldProps) => {
- return (
- form.setFieldValue('price', value)}
- decimals={8}
- />
- )
- }}
-
-
-
- Total amount
-
- {repayForm.values.price && !Number.isNaN(repayForm.values.price as number)
- ? formatBalance(
- Dec(repayForm.values.price || 0).mul(Dec(repayForm.values.quantity || 0)),
- pool?.currency.symbol,
- 2
- )
- : `0.00 ${pool.currency.symbol}`}
-
-
-
-
-
- Repay asset
-
-
+
+ {'outstandingInterest' in loan && loan.outstandingInterest.toDecimal().gt(0) && (
+
+ {({ field, form }: FieldProps) => {
+ return (
+ form.setFieldValue('interest', value)}
+ onSetMax={() => form.setFieldValue('interest', maxInterest.toNumber())}
+ />
+ )
+ }}
+
+ )}
+
+ {({ field, form }: FieldProps) => {
+ return (
+ }
+ disabled={isRepayLoading}
+ currency={displayCurrency}
+ onChange={(value) => form.setFieldValue('amountAdditional', value)}
+ />
+ )
+ }}
+
+
+ {poolFees.render()}
+
+
+ The balance of the asset originator account ({formatBalance(balance, displayCurrency, 2)}) is insufficient.
+ Transfer {formatBalance(totalRepay.sub(balance), displayCurrency, 2)} to{' '}
+ {copyable(utils.formatAddress(account?.actingAddress || ''))} on Centrifuge.
+
+
+
+ Quantity ({repayForm.values.quantity}) is greater than the outstanding quantity (
+ {maxQuantity.toDecimal().toString()}).
+
+
+
+ Interest ({formatBalance(Dec(repayForm.values.interest || 0), displayCurrency, 2)}) is greater than the
+ outstanding interest ({formatBalance(maxInterest, displayCurrency, 2)}).
+
+
+
+
+ Transaction summary
+
+
+
+ {maxAvailable === UNLIMITED ? 'No limit' : formatBalance(maxAvailable, displayCurrency, 2)}
+
+
+
+
+
+
+ Sale amount
+
+ {formatBalance(totalRepay, displayCurrency, 2)}
+
-
- ) : (
+
+ {poolFees.renderSummary()}
+
+
+ {destination === 'reserve' ? (
+
+ Stablecoins will be transferred to the onchain reserve.
+
+ ) : (
+
+
+ Virtual accounting process. No onchain stablecoin transfers are expected.
+
+
+ )}
+
+
+
doCloseTransaction([loan.poolId, loan.id], { account, forceProxyType: 'Borrow' })}
+ type="submit"
+ disabled={
+ isRepayLoading ||
+ !poolFees.isValid(repayForm) ||
+ !repayForm.isValid ||
+ totalRepay.greaterThan(maxAvailable) ||
+ maxAvailable.eq(0) ||
+ (destination === 'reserve' && balance.lt(totalRepay))
+ }
+ loading={isRepayLoading}
>
- Close
+ Sell
- ))}
-
+
+
+
)
}
diff --git a/centrifuge-app/src/pages/Loan/FinanceForm.tsx b/centrifuge-app/src/pages/Loan/FinanceForm.tsx
index 70b820f174..ee8aa98546 100644
--- a/centrifuge-app/src/pages/Loan/FinanceForm.tsx
+++ b/centrifuge-app/src/pages/Loan/FinanceForm.tsx
@@ -1,5 +1,7 @@
import Centrifuge, {
AccountCurrencyBalance,
+ ActiveLoan,
+ CreatedLoan,
CurrencyBalance,
CurrencyMetadata,
ExternalLoan,
@@ -23,7 +25,6 @@ import {
import {
Box,
Button,
- Card,
CurrencyInput,
Flex,
Grid,
@@ -34,56 +35,99 @@ import {
Shelf,
Stack,
Text,
+ Tooltip,
} from '@centrifuge/fabric'
import BN from 'bn.js'
import Decimal from 'decimal.js-light'
import { Field, FieldProps, Form, FormikProvider, useField, useFormik, useFormikContext } from 'formik'
import * as React from 'react'
import { combineLatest, map, of, switchMap } from 'rxjs'
+import { AnchorTextLink } from '../../components/TextLink'
import { parachainIcons, parachainNames } from '../../config'
-import { Dec } from '../../utils/Decimal'
-import { formatBalance, roundDown } from '../../utils/formatting'
+import { Dec, min } from '../../utils/Decimal'
+import { formatBalance } from '../../utils/formatting'
import { useFocusInvalidInput } from '../../utils/useFocusInvalidInput'
-import { useAvailableFinancing } from '../../utils/useLoans'
+import { useAvailableFinancing, useLoans } from '../../utils/useLoans'
import { useBorrower, usePoolAccess } from '../../utils/usePermissions'
import { usePool } from '../../utils/usePools'
-import { combine, max, positiveNumber } from '../../utils/validation'
+import { combine, positiveNumber } from '../../utils/validation'
+import { useChargePoolFees } from './ChargeFeesFields'
+import { ErrorMessage } from './ErrorMessage'
import { ExternalFinanceForm } from './ExternalFinanceForm'
-import { isExternalLoan } from './utils'
+import { SourceSelect } from './SourceSelect'
+import { isCashLoan, isExternalLoan } from './utils'
const TOKENMUX_PALLET_ACCOUNTID = '0x6d6f646c6366672f746d75780000000000000000000000000000000000000000'
type Key = `${'parachain' | 'evm'}:${number}` | 'centrifuge'
type FinanceValues = {
- amount: number | '' | Decimal
+ principal: number | '' | Decimal
withdraw: undefined | WithdrawAddress
+ fees: { id: string; amount: '' | number | Decimal }[]
+ category: 'interest' | 'miscellaneous' | undefined
}
+const UNLIMITED = Dec(1000000000000000)
+
export function FinanceForm({ loan }: { loan: LoanType }) {
- return isExternalLoan(loan) ? (
-
- ) : (
-
+ const [source, setSource] = React.useState('reserve')
+
+ if (isExternalLoan(loan)) {
+ return (
+
+ Purchase
+
+
+
+ )
+ }
+
+ return (
+
+ {isCashLoan(loan) ? 'Deposit' : 'Finance'}
+
+
+
)
}
-function InternalFinanceForm({ loan }: { loan: LoanType }) {
+/**
+ * Finance form for loans with `valuationMethod: outstandingDebt, discountedCashflow, cash`
+ */
+function InternalFinanceForm({ loan, source }: { loan: LoanType; source: string }) {
const pool = usePool(loan.poolId) as Pool
const account = useBorrower(loan.poolId, loan.id)
const api = useCentrifugeApi()
- if (!account) throw new Error('No borrower')
+ const poolFees = useChargePoolFees(loan.poolId, loan.id)
+ const loans = useLoans(loan.poolId)
+ const displayCurrency = source === 'reserve' ? pool.currency.symbol : 'USD'
const { current: availableFinancing } = useAvailableFinancing(loan.poolId, loan.id)
-
+ const sourceLoan = loans?.find((l) => l.id === source) as CreatedLoan | ActiveLoan
const { execute: doFinanceTransaction, isLoading: isFinanceLoading } = useCentrifugeTransaction(
- 'Finance asset',
- (cent) => (args: [poolId: string, loanId: string, amount: BN], options) => {
- const [poolId, loanId, amount] = args
- return combineLatest([
- cent.pools.financeLoan([poolId, loanId, amount], { batch: true }),
- withdraw.getBatch(financeForm),
- ]).pipe(
- switchMap(([loanTx, batch]) => {
+ isCashLoan(loan) ? 'Deposit funds' : 'Finance asset',
+ (cent) => (args: [poolId: string, loanId: string, principal: BN], options) => {
+ if (!account) throw new Error('No borrower')
+ const [poolId, loanId, principal] = args
+ let financeTx
+ if (source === 'reserve') {
+ financeTx = cent.pools.financeLoan([poolId, loanId, principal], { batch: true })
+ } else if (source === 'other') {
+ if (!financeForm.values.category) throw new Error('No category selected')
+ const increaseDebtTx = api.tx.loans.increaseDebt(poolId, loan.id, { internal: principal })
+ const encoded = new TextEncoder().encode(financeForm.values.category)
+ const categoryHex = Array.from(encoded)
+ .map((byte) => byte.toString(16).padStart(2, '0'))
+ .join('')
+ financeTx = cent.remark.remark([[{ Named: categoryHex }], increaseDebtTx], { batch: true })
+ } else {
+ const repay = { principal, interest: new BN(0), unscheduled: new BN(0) }
+ let borrow = { amount: principal }
+ financeTx = cent.pools.transferLoanDebt([poolId, sourceLoan.id, loan.id, repay, borrow], { batch: true })
+ }
+ return combineLatest([financeTx, withdraw.getBatch(financeForm), poolFees.getBatch(financeForm)]).pipe(
+ switchMap(([loanTx, withdrawBatch, poolFeesBatch]) => {
+ const batch = [...withdrawBatch, ...poolFeesBatch]
let tx = wrapProxyCallsForAccount(api, loanTx, account, 'Borrow')
if (batch.length) {
tx = api.tx.utility.batchAll([tx, ...batch])
@@ -101,12 +145,17 @@ function InternalFinanceForm({ loan }: { loan: LoanType }) {
const financeForm = useFormik({
initialValues: {
- amount: '',
+ principal: '',
withdraw: undefined,
+ fees: [],
+ category: 'interest',
},
onSubmit: (values, actions) => {
- const amount = CurrencyBalance.fromFloat(values.amount, pool.currency.decimals)
- doFinanceTransaction([loan.poolId, loan.id, amount], { account, forceProxyType: 'Borrow' })
+ const principal = CurrencyBalance.fromFloat(values.principal, pool.currency.decimals)
+ doFinanceTransaction([loan.poolId, loan.id, principal], {
+ account,
+ forceProxyType: 'Borrow',
+ })
actions.setSubmitting(false)
},
validateOnMount: true,
@@ -115,7 +164,7 @@ function InternalFinanceForm({ loan }: { loan: LoanType }) {
const financeFormRef = React.useRef(null)
useFocusInvalidInput(financeForm, financeFormRef)
- const withdraw = useWithdraw(loan.poolId, account, Dec(financeForm.values.amount || 0))
+ const withdraw = useWithdraw(loan.poolId, account!, Dec(financeForm.values.principal || 0))
if (loan.status === 'Closed') {
return null
@@ -123,68 +172,165 @@ function InternalFinanceForm({ loan }: { loan: LoanType }) {
const poolReserve = pool?.reserve.available.toDecimal() ?? Dec(0)
const maturityDatePassed = loan?.pricing.maturityDate && new Date() > new Date(loan.pricing.maturityDate)
- const maxBorrow = poolReserve.lessThan(availableFinancing) ? poolReserve : availableFinancing
+ const totalFinance = Dec(financeForm.values.principal || 0)
+
+ const maxAvailable =
+ source === 'reserve'
+ ? min(poolReserve, availableFinancing)
+ : source === 'other'
+ ? UNLIMITED
+ : sourceLoan.outstandingDebt.toDecimal()
return (
-
-
- {'valuationMethod' in loan.pricing && loan.pricing.valuationMethod !== 'cash' && (
-
- Available financing
- {/* availableFinancing needs to be rounded down, b/c onSetMax displays the rounded down value as well */}
- {formatBalance(roundDown(availableFinancing), pool?.currency.symbol, 2)}
-
- )}
-
- Total financed
- {formatBalance(loan.totalBorrowed?.toDecimal() ?? 0, pool?.currency.symbol, 2)}
-
-
- {availableFinancing.greaterThan(0) && !maturityDatePassed && (
+ <>
+ {!maturityDatePassed && (
{
+ const principalValue = typeof val === 'number' ? Dec(val) : (val as Decimal)
+ if (maxAvailable !== UNLIMITED && principalValue.gt(maxAvailable)) {
+ return `Principal exceeds available financing`
+ }
+ return ''
+ })}
>
- {({ field, meta, form }: FieldProps) => {
+ {({ field, form }: FieldProps) => {
return (
form.setFieldValue('amount', value)}
- onSetMax={() => form.setFieldValue('amount', maxBorrow)}
+ label={isCashLoan(loan) ? 'Amount' : 'Principal'}
+ currency={displayCurrency}
+ onChange={(value) => form.setFieldValue('principal', value)}
+ onSetMax={
+ maxAvailable !== UNLIMITED ? () => form.setFieldValue('principal', maxAvailable) : undefined
+ }
/>
)
}}
- {withdraw.render()}
- {poolReserve.lessThan(availableFinancing) && loan.pricing.valuationMethod !== 'cash' && (
-
- The pool's available reserve ({formatBalance(poolReserve, pool?.currency.symbol)}) is smaller than
- the available financing
-
+ {source === 'other' && (
+
+ {({ field }: FieldProps) => {
+ return (
+
+ )
+ }}
+
)}
-
-
- Finance asset
+ {source === 'reserve' && withdraw.render()}
+
+ {poolFees.render()}
+
+
+ {isCashLoan(loan) ? 'Deposit amount' : 'Financing amount'} (
+ {formatBalance(totalFinance, displayCurrency, 2)}) is greater than the available balance (
+ {formatBalance(maxAvailable, displayCurrency, 2)}).
+
+
+
+ There is an additional{' '}
+ {formatBalance(
+ new CurrencyBalance(pool.reserve.total.sub(pool.reserve.available), pool.currency.decimals),
+ displayCurrency
+ )}{' '}
+ available from repayments or deposits. This requires first executing the orders on the{' '}
+ Liquidity tab.
+
+
+
+ Transaction summary
+
+
+
+ Available balance
+
+
+
+ {maxAvailable === UNLIMITED ? 'No limit' : formatBalance(maxAvailable, displayCurrency, 2)}
+
+
+
+
+
+
+
+ {isCashLoan(loan) ? 'Deposit amount' : 'Financing amount'}
+
+ {formatBalance(totalFinance, displayCurrency, 2)}
+
+
+
+ {poolFees.renderSummary()}
+
+
+ {source === 'reserve' ? (
+
+
+ Stablecoins will be transferred to the specified withdrawal addresses, on the specified networks. A
+ delay until the transfer is completed is to be expected.
+
+
+ ) : source === 'other' ? (
+
+
+ Virtual accounting process. No onchain stablecoin transfers are expected. This action will lead to
+ an increase in the NAV of the pool.
+
+
+ ) : (
+
+
+ Virtual accounting process. No onchain stablecoin transfers are expected.
+
+
+ )}
+
+
+
+
+ {isCashLoan(loan) ? 'Deposit' : 'Finance'}
)}
-
+ >
)
}
diff --git a/centrifuge-app/src/pages/Loan/PricingValues.tsx b/centrifuge-app/src/pages/Loan/PricingValues.tsx
index 5c10c44da6..f366dfadad 100644
--- a/centrifuge-app/src/pages/Loan/PricingValues.tsx
+++ b/centrifuge-app/src/pages/Loan/PricingValues.tsx
@@ -4,6 +4,7 @@ import { formatDate, getAge } from '../../utils/date'
import { formatBalance, formatPercentage } from '../../utils/formatting'
import { getLatestPrice } from '../../utils/getLatestPrice'
import { TinlakePool } from '../../utils/tinlake/useTinlakePools'
+import { useAvailableFinancing } from '../../utils/useLoans'
import { useAssetTransactions } from '../../utils/usePools'
import { MetricsTable } from './MetricsTable'
@@ -16,6 +17,7 @@ export function PricingValues({ loan, pool }: Props) {
const { pricing } = loan
const assetTransactions = useAssetTransactions(loan.poolId)
+ const { current: availableFinancing } = useAvailableFinancing(loan.poolId, loan.id)
const isOutstandingDebtOrDiscountedCashFlow =
'valuationMethod' in pricing &&
@@ -52,6 +54,14 @@ export function PricingValues({ loan, pool }: Props) {
value: latestPrice ? `${formatBalance(latestPrice, pool.currency.symbol, 6, 2)}` : '-',
},
{ label: 'Price last updated', value: days === '0' ? `${days} ago` : `Today` },
+ ...(pricing.interestRate
+ ? [
+ {
+ label: 'Interest rate',
+ value: pricing.interestRate && formatPercentage(pricing.interestRate.toPercent()),
+ },
+ ]
+ : []),
]}
/>
@@ -67,9 +77,30 @@ export function PricingValues({ loan, pool }: Props) {
:
-}
+const UNLIMITED = Dec(1000000000000000)
+
+export function RepayForm({ loan }: { loan: CreatedLoan | ActiveLoan }) {
+ const [destination, setDestination] = React.useState('reserve')
+
+ if (isExternalLoan(loan)) {
+ return (
+
+ Sell
+
+
+
+ )
+ }
-function InternalRepayForm({ loan }: { loan: ActiveLoan }) {
+ return (
+
+ {isCashLoan(loan) ? 'Withdraw' : 'Repay'}
+
+
+
+ )
+}
+/**
+ * Repay form for loans with `valuationMethod: outstandingDebt, discountedCashflow, cash`
+ */
+function InternalRepayForm({ loan, destination }: { loan: ActiveLoan | CreatedLoan; destination: string }) {
const pool = usePool(loan.poolId)
const account = useBorrower(loan.poolId, loan.id)
- if (!account) throw new Error('No borrower')
- const balances = useBalances(account.actingAddress)
+ const balances = useBalances(account?.actingAddress)
const balance = (balances && findBalance(balances.currencies, pool.currency.key)?.balance.toDecimal()) || Dec(0)
- const { debtWithMargin } = useAvailableFinancing(loan.poolId, loan.id)
+ const poolFees = useChargePoolFees(loan.poolId, loan.id)
+ const loans = useLoans(loan.poolId)
+ const api = useCentrifugeApi()
+ const destinationLoan = loans?.find((l) => l.id === destination) as Loan
+ const displayCurrency = destination === 'reserve' ? pool.currency.symbol : 'USD'
+ const utils = useCentrifugeUtils()
const { execute: doRepayTransaction, isLoading: isRepayLoading } = useCentrifugeTransaction(
- 'Repay asset',
- (cent) => cent.pools.repayLoanPartially,
+ isCashLoan(loan) ? 'Withdraw funds' : 'Repay asset',
+ (cent) =>
+ (args: [principal: CurrencyBalance, interest: CurrencyBalance, amountAdditional: CurrencyBalance], options) => {
+ const [principal, interest, amountAdditional] = args
+ if (!account) throw new Error('No borrower')
+ let repayTx
+ if (destination === 'reserve') {
+ repayTx = cent.pools.repayLoanPartially([pool.id, loan.id, principal, interest, amountAdditional], {
+ batch: true,
+ })
+ } else if (destination === 'other') {
+ if (!repayForm.values.category) throw new Error('No category selected')
+ const decreaseDebtTx = api.tx.loans.decreaseDebt(pool.id, loan.id, { internal: principal })
+ const encoded = new TextEncoder().encode(repayForm.values.category)
+ const categoryHex = Array.from(encoded)
+ .map((byte) => byte.toString(16).padStart(2, '0'))
+ .join('')
+ repayTx = cent.remark.remark([[{ Named: categoryHex }], decreaseDebtTx], { batch: true })
+ } else {
+ const repay = { principal, interest, unscheduled: amountAdditional }
+ const borrowAmount = new CurrencyBalance(
+ principal.add(interest).add(amountAdditional),
+ pool.currency.decimals
+ )
+ let borrow = { amount: borrowAmount }
+ repayTx = cent.pools.transferLoanDebt([pool.id, loan.id, destinationLoan.id, repay, borrow], { batch: true })
+ }
+ return combineLatest([repayTx, poolFees.getBatch(repayForm)]).pipe(
+ switchMap(([repayTx, batch]) => {
+ let tx = wrapProxyCallsForAccount(api, repayTx, account, 'Borrow')
+ if (batch.length) {
+ tx = api.tx.utility.batchAll([tx, ...batch])
+ }
+ return cent.wrapSignAndSend(api, tx, { ...options, proxies: undefined })
+ })
+ )
+ },
{
onSuccess: () => {
repayForm.resetForm()
@@ -41,47 +130,50 @@ function InternalRepayForm({ loan }: { loan: ActiveLoan }) {
}
)
- const { execute: doRepayAllTransaction, isLoading: isRepayAllLoading } = useCentrifugeTransaction(
- 'Repay asset',
- (cent) => cent.pools.repayAndCloseLoan
- )
-
- const { execute: doCloseTransaction, isLoading: isCloseLoading } = useCentrifugeTransaction(
- 'Close asset',
- (cent) => cent.pools.closeLoan
- )
-
- function repayAll() {
- doRepayAllTransaction([loan.poolId, loan.id, loan.totalBorrowed.sub(loan.repaid.principal)], {
- account,
- forceProxyType: 'Borrow',
- })
- }
-
const repayForm = useFormik({
initialValues: {
- amount: '',
+ principal: '',
+ amountAdditional: '',
+ interest: '',
+ fees: [],
+ category: 'correction',
},
onSubmit: (values, actions) => {
- // Pay the interest with a small margin first, then the principal
- let interest: BN = CurrencyBalance.fromFloat(values.amount, pool.currency.decimals)
- let principal = new BN(0)
-
- // Calculate interest from the time the loan was fetched until now
- const time = Date.now() - loan.fetchedAt.getTime()
- const margin = CurrencyBalance.fromFloat(
- loan.outstandingPrincipal
- .toDecimal()
- .mul(Rate.fractionFromApr(loan.pricing.interestRate.toDecimal()).toDecimal())
- .mul(time),
- pool.currency.decimals
- )
- const interestWithMargin = loan.outstandingInterest.add(margin)
- if (interest.gt(interestWithMargin)) {
- principal = interest.sub(interestWithMargin)
- interest = interestWithMargin
+ let interest = CurrencyBalance.fromFloat(values.interest || 0, pool.currency.decimals)
+ const additionalAmount = CurrencyBalance.fromFloat(values.amountAdditional || 0, pool.currency.decimals)
+ const principal = CurrencyBalance.fromFloat(values.principal || 0, pool.currency.decimals)
+
+ if (interest.toDecimal().eq(maxInterest) && principal.toDecimal().eq(maxPrincipal)) {
+ const outstandingInterest =
+ 'outstandingInterest' in loan
+ ? loan.outstandingInterest
+ : CurrencyBalance.fromFloat(0, pool.currency.decimals)
+ const outstandingPrincipal =
+ 'outstandingPrincipal' in loan
+ ? loan.outstandingPrincipal
+ : CurrencyBalance.fromFloat(0, pool.currency.decimals)
+
+ const fiveMinuteBuffer = 5 * 60
+ const time = Date.now() + fiveMinuteBuffer - loan.fetchedAt.getTime()
+ const mostUpToDateInterest = CurrencyBalance.fromFloat(
+ outstandingPrincipal
+ .toDecimal()
+ .mul(Rate.fractionFromAprPercent(loan.pricing.interestRate.toDecimal()).toDecimal())
+ .mul(time)
+ .add(outstandingInterest.toDecimal()),
+ pool.currency.decimals
+ )
+ interest = mostUpToDateInterest
+ console.log(
+ `Repaying with interest including buffer ${mostUpToDateInterest.toDecimal()} instead of ${outstandingInterest.toDecimal()}`,
+ loan.pricing.interestRate.toDecimal().toString()
+ )
}
- doRepayTransaction([loan.poolId, loan.id, principal, interest, new BN(0)], { account, forceProxyType: 'Borrow' })
+
+ doRepayTransaction([principal, interest, additionalAmount], {
+ account,
+ forceProxyType: 'Borrow',
+ })
actions.setSubmitting(false)
},
validateOnMount: true,
@@ -90,81 +182,206 @@ function InternalRepayForm({ loan }: { loan: ActiveLoan }) {
const repayFormRef = React.useRef(null)
useFocusInvalidInput(repayForm, repayFormRef)
- const debt = loan.outstandingDebt?.toDecimal() || Dec(0)
- const maxRepay = balance.lessThan(loan.outstandingDebt.toDecimal()) ? balance : loan.outstandingDebt.toDecimal()
- const canRepayAll = debtWithMargin?.lte(balance)
+ const { maxAvailable, maxPrincipal, maxInterest, totalRepay } = React.useMemo(() => {
+ const { interest, principal, amountAdditional } = repayForm.values
+ const outstandingInterest = 'outstandingInterest' in loan ? loan.outstandingInterest.toDecimal() : Dec(0)
+ let maxAvailable
+ let maxPrincipal
+ let maxInterest
+ if (destination === 'reserve') {
+ maxAvailable = balance
+ maxPrincipal = loan.outstandingDebt.toDecimal().sub(outstandingInterest)
+ maxInterest = outstandingInterest
+ } else if (destination === 'other') {
+ maxAvailable = UNLIMITED
+ maxPrincipal = loan.outstandingDebt.toDecimal().sub(outstandingInterest)
+ maxInterest = Dec(0)
+ } else {
+ maxAvailable = UNLIMITED
+ maxPrincipal = loan.outstandingDebt.toDecimal().sub(outstandingInterest)
+ maxInterest = outstandingInterest
+ }
+ const totalRepay = Dec(principal || 0)
+ .add(Dec(interest || 0))
+ .add(Dec(amountAdditional || 0))
+ return {
+ maxAvailable,
+ maxPrincipal,
+ maxInterest,
+ totalRepay,
+ }
+ }, [loan, balance, repayForm.values, destination])
return (
-
-
-
- Outstanding
- {/* outstandingDebt needs to be rounded down, b/c onSetMax displays the rounded down value as well */}
- {formatBalance(roundDown(debt), pool?.currency.symbol, 2)}
-
-
- Total repaid
- {formatBalance(loan?.totalRepaid || 0, pool?.currency.symbol, 2)}
-
-
-
- {debt.gt(0) ? (
-
-
+ <>
+
+
+
+ {({ field, form }: FieldProps) => {
+ return (
+ form.setFieldValue('principal', value)}
+ onSetMax={() => {
+ form.setFieldValue('principal', maxPrincipal.gte(0) ? maxPrincipal : 0)
+ }}
+ secondaryLabel={`${formatBalance(maxPrincipal, displayCurrency)} outstanding`}
+ />
+ )
+ }}
+
+ {'outstandingInterest' in loan && loan.outstandingInterest.toDecimal().gt(0) && !isCashLoan(loan) && (
- {({ field, meta, form }: FieldProps) => {
+ {({ field, form }: FieldProps) => {
return (
form.setFieldValue('amount', value)}
- onSetMax={() => form.setFieldValue('amount', maxRepay)}
+ label="Interest"
+ secondaryLabel={`${formatBalance(loan.outstandingInterest, displayCurrency, 2)} interest accrued`}
+ disabled={isRepayLoading}
+ currency={displayCurrency}
+ onChange={(value) => form.setFieldValue('interest', value)}
+ onSetMax={() => form.setFieldValue('interest', maxInterest.gte(0) ? maxInterest : 0)}
/>
)
}}
- {balance.lessThan(debt) && (
-
- Your wallet balance ({formatBalance(roundDown(balance), pool?.currency.symbol, 2)}) is smaller than the
- outstanding balance.
+ )}
+ {!isCashLoan(loan) && (
+
+ {({ field, form }: FieldProps) => {
+ return (
+ }
+ disabled={isRepayLoading}
+ currency={displayCurrency}
+ onChange={(value) => form.setFieldValue('amountAdditional', value)}
+ />
+ )
+ }}
+
+ )}
+ {destination === 'other' && (
+
+ {({ field }: FieldProps) => {
+ return (
+
+ )
+ }}
+
+ )}
+ {poolFees.render()}
+
+
+ {isCashLoan(loan) ? 'Amount' : 'Principal'} (
+ {formatBalance(Dec(repayForm.values.principal || 0), displayCurrency, 2)}) is greater than the outstanding{' '}
+ {isCashLoan(loan) ? 'balance' : 'principal'} ({formatBalance(maxPrincipal, displayCurrency, 2)}).
+
+
+
+ Interest ({formatBalance(Dec(repayForm.values.interest || 0), displayCurrency, 2)}) is greater than the
+ outstanding interest ({formatBalance(maxInterest, displayCurrency, 2)}).
+
+
+
+ The balance of the asset originator account ({formatBalance(balance, displayCurrency, 2)}) is insufficient.
+ Transfer {formatBalance(totalRepay.sub(balance), displayCurrency, 2)} to{' '}
+ {copyable(utils.formatAddress(account?.actingAddress || ''))} on Centrifuge.
+
+
+
+ Transaction summary
+
+
+
+
+ {maxAvailable === UNLIMITED ? 'No limit' : formatBalance(maxAvailable, displayCurrency, 2)}
+
+
+
+
+
+
+ {isCashLoan(loan) ? 'Withdrawal amount' : 'Repayment amount'}
+
+ {formatBalance(totalRepay, displayCurrency, 2)}
+
+
+
+ {poolFees.renderSummary()}
+
+
+ {destination === 'reserve' ? (
+
+ Stablecoins will be transferred to the onchain reserve.
+
+ ) : destination === 'other' ? (
+
+
+ Virtual accounting process. No onchain stablecoin transfers are expected. This action will lead to a
+ decrease in the NAV of the pool.
+
+
+ ) : (
+
+
+ Virtual accounting process. No onchain stablecoin transfers are expected.
+
)}
-
-
- Repay asset
-
- repayAll()}
- >
- Repay all and close
-
-
-
- ) : (
- doCloseTransaction([loan.poolId, loan.id], { account, forceProxyType: 'Borrow' })}
- >
- Close
-
- )}
-
+
+
+
+ {isCashLoan(loan) ? 'Withdraw' : 'Repay'}
+
+
+
+
+ >
)
}
diff --git a/centrifuge-app/src/pages/Loan/SourceSelect.tsx b/centrifuge-app/src/pages/Loan/SourceSelect.tsx
new file mode 100644
index 0000000000..c7d26d8539
--- /dev/null
+++ b/centrifuge-app/src/pages/Loan/SourceSelect.tsx
@@ -0,0 +1,52 @@
+import { ActiveLoan, Loan } from '@centrifuge/centrifuge-js'
+import { Select } from '@centrifuge/fabric'
+import { nftMetadataSchema } from '../../schemas'
+import { useLoans } from '../../utils/useLoans'
+import { useMetadata } from '../../utils/useMetadata'
+import { useCentNFT } from '../../utils/useNFTs'
+import { useBorrower } from '../../utils/usePermissions'
+
+type SourceSelectProps = {
+ loan: Loan
+ value: 'reserve' | 'other' | string
+ onChange: (option: string) => void
+ action: 'repay' | 'finance'
+}
+
+export function SourceSelect({ loan, value, onChange, action }: SourceSelectProps) {
+ const unfilteredLoans = useLoans(loan.poolId)
+ const account = useBorrower(loan.poolId, loan.id)
+
+ // acceptable options are active loans with cash valuation ONLY if connected account is the borrower
+ const loans = unfilteredLoans?.filter(
+ (l) =>
+ l.id !== loan.id &&
+ l.status !== 'Closed' &&
+ (l as ActiveLoan).borrower === account?.actingAddress &&
+ 'valuationMethod' in l.pricing &&
+ l.pricing.valuationMethod === 'cash'
+ ) as Loan[] | undefined
+
+ const options = [
+ { label: 'Onchain reserve', value: 'reserve' },
+ ...(loans?.map((l) => ({ value: l.id, label: })) ?? []),
+ ]
+ if (loan.pricing.valuationMethod === 'cash') {
+ options.push({ label: 'Other', value: 'other' })
+ }
+
+ return (
+