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 ( + + + + ) + }} + )} - - )} - + ) } 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 ( + onChange(e.target.value)} + /> + ) +} + +function LoanOption({ loan }: { loan: Loan }) { + const nft = useCentNFT(loan.asset.collectionId, loan.asset.nftId, false, false) + const { data: metadata } = useMetadata(nft?.metadataUri, nftMetadataSchema) + return <>{metadata?.name} +} diff --git a/centrifuge-app/src/pages/Loan/TransactionTable.tsx b/centrifuge-app/src/pages/Loan/TransactionTable.tsx index eb0071f065..f64022d146 100644 --- a/centrifuge-app/src/pages/Loan/TransactionTable.tsx +++ b/centrifuge-app/src/pages/Loan/TransactionTable.tsx @@ -43,7 +43,7 @@ export const TransactionTable = ({ maturityDate, }: Props) => { const assetTransactions = useMemo(() => { - const sortedTransactions = transactions.sort((a, b) => { + const sortedTransactions = transactions?.sort((a, b) => { if (a.timestamp > b.timestamp) { return 1 } @@ -60,10 +60,10 @@ export const TransactionTable = ({ }) return sortedTransactions - .filter((transaction) => { + ?.filter((transaction) => { return !transaction.amount?.isZero() }) - .map((transaction, index, array) => { + ?.map((transaction, index, array) => { const termDays = maturityDate ? transaction.timestamp ? daysBetween(transaction.timestamp, maturityDate) diff --git a/centrifuge-app/src/pages/Loan/TransferDebtForm.tsx b/centrifuge-app/src/pages/Loan/TransferDebtForm.tsx deleted file mode 100644 index 11a7d6b66b..0000000000 --- a/centrifuge-app/src/pages/Loan/TransferDebtForm.tsx +++ /dev/null @@ -1,273 +0,0 @@ -import { ActiveLoan, CurrencyBalance, Loan, Loan as LoanType, Pool, Price } from '@centrifuge/centrifuge-js' -import { useCentrifugeTransaction } from '@centrifuge/centrifuge-react' -import { Button, Card, CurrencyInput, Select, Shelf, Stack, Text } from '@centrifuge/fabric' -import BN from 'bn.js' -import Decimal from 'decimal.js-light' -import { Field, FieldProps, Form, FormikProvider, setIn, useFormik } from 'formik' -import * as React from 'react' -import { nftMetadataSchema } from '../../schemas' -import { Dec } from '../../utils/Decimal' -import { formatBalance } from '../../utils/formatting' -import { useFocusInvalidInput } from '../../utils/useFocusInvalidInput' -import { useAvailableFinancing, useLoans } from '../../utils/useLoans' -import { useMetadata } from '../../utils/useMetadata' -import { useCentNFT } from '../../utils/useNFTs' -import { useBorrower } from '../../utils/usePermissions' -import { usePool } from '../../utils/usePools' -import { combine, maxPriceVariance, positiveNumber } from '../../utils/validation' -import { ExternalFinanceFields } from './ExternalFinanceForm' -import { isExternalLoan } from './utils' - -type FormValues = { - targetLoan: string - amount: number | '' | Decimal - price: number | '' | Decimal - quantity: number | '' - targetLoanQuantity: number | '' | Decimal - targetLoanPrice: number | '' | Decimal -} - -export function TransferDebtForm({ loan }: { loan: LoanType }) { - const pool = usePool(loan.poolId) as Pool - const account = useBorrower(loan.poolId, loan.id) - - if (!account) throw new Error('No borrower') - const { current: availableFinancing } = useAvailableFinancing(loan.poolId, loan.id) - const unfilteredLoans = useLoans(loan.poolId) - - const loans = unfilteredLoans?.filter( - (l) => - l.id !== loan.id && - l.status === 'Active' && - (l as ActiveLoan).borrower === account?.actingAddress && - (isExternalLoan(loan) ? !isExternalLoan(l as Loan) : true) - ) as Loan[] | undefined - - const { execute, isLoading } = useCentrifugeTransaction('Transfer debt', (cent) => cent.pools.transferLoanDebt, { - onSuccess: () => { - form.resetForm() - }, - }) - - const form = useFormik({ - initialValues: { - targetLoan: '', - amount: '', - price: '', - quantity: '', - targetLoanPrice: '', - targetLoanQuantity: '', - }, - onSubmit: (values, actions) => { - if (!selectedLoan) return - - let borrow: any - let borrowAmount: BN - if (isExternalLoan(loan)) { - borrow = { - price: CurrencyBalance.fromFloat(values.price, pool.currency.decimals), - quantity: Price.fromFloat(values.quantity), - } - borrowAmount = borrow.quantity.mul(borrow.price).div(Price.fromFloat(1)) - } else if (isExternalLoan(selectedLoan)) { - borrow = { amount: CurrencyBalance.fromFloat(financeAmount, pool.currency.decimals) } - borrowAmount = borrow.amount - } else { - borrow = { amount: CurrencyBalance.fromFloat(values.amount, pool.currency.decimals) } - borrowAmount = borrow.amount - } - - const { outstandingInterest } = selectedLoan - let interest = new BN(borrowAmount) - let principal = new BN(0) - if (interest.gt(outstandingInterest)) { - principal = interest.sub(outstandingInterest) - interest = outstandingInterest - } - let repay: any = { principal, interest } - if (isExternalLoan(selectedLoan)) { - const repayPriceBN = CurrencyBalance.fromFloat(form.values.targetLoanPrice || 1, pool.currency.decimals) - const repayQuantityBN = Price.fromFloat(Dec(values.targetLoanQuantity || 0)) - repay = { quantity: repayQuantityBN, price: repayPriceBN, interest } - } - - execute([loan.poolId, form.values.targetLoan, loan.id, repay, borrow], { - account, - forceProxyType: 'Borrow', - }) - actions.setSubmitting(false) - }, - validate(values) { - const financeAmount = isExternalLoan(loan) - ? Dec(values.price || 0).mul(Dec(values.quantity || 0)) - : selectedLoan && isExternalLoan(selectedLoan) - ? Dec(values.targetLoanPrice || 0).mul(Dec(values.targetLoanQuantity || 0)) - : Dec(values.amount || 0) - - let errors: any = {} - - const error = validate(financeAmount) - if (error) { - if (selectedLoan && isExternalLoan(selectedLoan)) { - errors = setIn(errors, 'targetLoanPrice', error) - } else { - errors = setIn(errors, 'amount', error) - } - } - - return errors - }, - }) - - const financeFormRef = React.useRef(null) - useFocusInvalidInput(form, financeFormRef) - - if (loan.status === 'Closed') { - return null - } - - const maturityDatePassed = loan.pricing.maturityDate && new Date() > new Date(loan.pricing.maturityDate) - const selectedLoan = loans?.find((l) => l.id === form.values.targetLoan) as ActiveLoan | undefined - - function validate(financeAmount: Decimal) { - if (financeAmount.lte(0)) return 'Value must be positive' - return financeAmount.gt(availableFinancing) - ? `Amount exceeds max borrow (${formatBalance(availableFinancing, pool.currency.symbol, 2)})` - : financeAmount.gt(selectedLoan?.outstandingDebt.toDecimal() ?? Dec(0)) - ? `Amount ${financeAmount.toNumber()} exceeds settlement asset outstanding debt (${formatBalance( - selectedLoan?.outstandingDebt.toFloat() ?? 0, - pool?.currency.symbol, - 2 - )})` - : '' - } - - if (availableFinancing.lte(0) || maturityDatePassed || !loans?.length) return null - - const financeAmount = isExternalLoan(loan) - ? Dec(form.values.price || 0).mul(Dec(form.values.quantity || 0)) - : selectedLoan && isExternalLoan(selectedLoan) - ? Dec(form.values.targetLoanPrice || 0).mul(Dec(form.values.targetLoanQuantity || 0)) - : Dec(form.values.amount || 0) - - return ( - - - To receive funds from another asset, choose the asset, enter new face value and settlement price. This will - trigger a repay of the settlement asset and a borrow transaction for this asset. - - - - - {selectedLoan ? ( - - Outstanding debt - {formatBalance(selectedLoan.outstandingDebt, pool.currency.symbol, 2, 2)} - - ) : null} - - {({ field, meta, form }: FieldProps) => ( -