From 5dfb4bda5eabae78a2b47f89ba43e790ced59b1b Mon Sep 17 00:00:00 2001 From: JP Angelle Date: Wed, 30 Aug 2023 16:20:00 -0500 Subject: [PATCH] implement oracle-v2 --- centrifuge-app/src/components/PageSummary.tsx | 41 +-- .../src/components/Report/AssetList.tsx | 2 +- centrifuge-app/src/components/Tooltips.tsx | 24 +- .../src/pages/IssuerCreatePool/validate.ts | 4 + .../pages/IssuerPool/Assets/PricingInput.tsx | 38 +-- .../src/pages/Loan/ExternalFinanceForm.tsx | 112 ++++---- .../src/pages/Loan/HoldingsValues.tsx | 57 ++++ .../src/pages/Loan/OraclePriceForm.tsx | 54 +++- .../src/pages/Loan/PricingValues.tsx | 17 +- .../src/pages/Loan/TransactionTable.tsx | 107 +++++++ centrifuge-app/src/pages/Loan/index.tsx | 262 ++++++++++-------- .../src/pages/Pool/Assets/index.tsx | 2 +- centrifuge-app/src/utils/usePools.ts | 22 +- centrifuge-app/src/utils/validation/index.ts | 19 ++ centrifuge-js/src/modules/pools.ts | 6 +- 15 files changed, 522 insertions(+), 245 deletions(-) create mode 100644 centrifuge-app/src/pages/Loan/HoldingsValues.tsx create mode 100644 centrifuge-app/src/pages/Loan/TransactionTable.tsx diff --git a/centrifuge-app/src/components/PageSummary.tsx b/centrifuge-app/src/components/PageSummary.tsx index 679ab84a60..26d12b50af 100644 --- a/centrifuge-app/src/components/PageSummary.tsx +++ b/centrifuge-app/src/components/PageSummary.tsx @@ -8,27 +8,32 @@ type Props = { value: React.ReactNode }[] children?: React.ReactNode + title?: React.ReactNode } -export const PageSummary: React.FC = ({ data, children }) => { +export const PageSummary: React.FC = ({ data, children, title }) => { const theme = useTheme() return ( - - {data?.map(({ label, value }, index) => ( - - {label} - {value} - - ))} - {children} - + + {title} + + {data?.map(({ label, value }, index) => ( + + + {label} + + {value} + + ))} + {children} + + ) } diff --git a/centrifuge-app/src/components/Report/AssetList.tsx b/centrifuge-app/src/components/Report/AssetList.tsx index c8586730b4..32c7ee1eb2 100644 --- a/centrifuge-app/src/components/Report/AssetList.tsx +++ b/centrifuge-app/src/components/Report/AssetList.tsx @@ -20,7 +20,7 @@ const headers = [ 'Total repaid', 'Financing date', 'Maturity date', - 'Financing fee', + 'Interest rate', 'Advance rate', 'PD', 'LGD', diff --git a/centrifuge-app/src/components/Tooltips.tsx b/centrifuge-app/src/components/Tooltips.tsx index 28940a0b6e..02855717c8 100644 --- a/centrifuge-app/src/components/Tooltips.tsx +++ b/centrifuge-app/src/components/Tooltips.tsx @@ -82,9 +82,9 @@ export const tooltipText = { label: 'Ongoing assets', body: 'Number of assets currently being financed in the pool and awaiting repayment.', }, - averageFinancingFee: { - label: 'Average financing fee', - body: 'The average financing fee of the active assets in the pool.', + averageInterestRate: { + label: 'Average interest rate', + body: 'The average interest rate of the active assets in the pool.', }, averageAmount: { label: 'Average amount', @@ -134,9 +134,9 @@ export const tooltipText = { label: 'Advance rate', body: 'The advance rate is the percentage amount of the value of the collateral that an issuer can borrow from the pool against the NFT representing the collateral.', }, - financingFee: { - label: 'Financing fee', - body: 'The financing fee is the rate at which the outstanding amount of an individual financing accrues interest. It is expressed as an "APR" (Annual Percentage Rate) and compounds interest every second.', + interestRate: { + label: 'Interest Rate', + body: 'The interest rate is the rate at which the outstanding amount of an individual financing accrues interest. It is expressed as an “APR” (Annual Percentage Rate) and compounds every second.', }, probabilityOfDefault: { label: 'Prob of default', @@ -242,6 +242,18 @@ export const tooltipText = { label: 'Extension period', body: 'Number of days the maturity can be extended without restrictions.', }, + maxPriceVariation: { + label: 'Max price variation', + body: 'The maximum price variation defines the price difference between settlement and oracle price.', + }, + isin: { + label: 'ISIN', + body: 'An International Securities Identification Number (ISIN) is a code that uniquely identifies a security globally for the purposes of facilitating clearing, reporting and settlement of trades.', + }, + notionalValue: { + label: 'Notional value', + body: 'The notional value is the total value of the underlying asset.', + }, } export type TooltipsProps = { diff --git a/centrifuge-app/src/pages/IssuerCreatePool/validate.ts b/centrifuge-app/src/pages/IssuerCreatePool/validate.ts index 75824febb5..053175e22a 100644 --- a/centrifuge-app/src/pages/IssuerCreatePool/validate.ts +++ b/centrifuge-app/src/pages/IssuerCreatePool/validate.ts @@ -4,12 +4,14 @@ import { imageFile, integer, isin, + maturityDate, max, maxDecimals, maxFileSize, maxImageSize, maxLength, mimeType, + min, minLength, nonNegativeNumber, pattern, @@ -49,6 +51,8 @@ export const validate = { minInvestment: combine(required(), nonNegativeNumber(), max(Number.MAX_SAFE_INTEGER)), interestRate: combine(required(), positiveNumber(), max(Number.MAX_SAFE_INTEGER)), minRiskBuffer: combine(required(), positiveNumber(), max(100)), + maxPriceVariation: combine(required(), min(0), max(10000)), + maturityDate: combine(required(), maturityDate()), // risk groups groupName: maxLength(30), diff --git a/centrifuge-app/src/pages/IssuerPool/Assets/PricingInput.tsx b/centrifuge-app/src/pages/IssuerPool/Assets/PricingInput.tsx index 23666b29b9..eb0d475afb 100644 --- a/centrifuge-app/src/pages/IssuerPool/Assets/PricingInput.tsx +++ b/centrifuge-app/src/pages/IssuerPool/Assets/PricingInput.tsx @@ -30,16 +30,9 @@ export function PricingInput({ poolId }: { poolId: string }) { {values.pricing.valuationMethod === 'oracle' && ( <> - {/* } - placeholder="0" - name="pricing.maxBorrowQuantity" - validate={validate.maxBorrowQuantity} - /> */} } + label={} placeholder="010101010000" name="pricing.Isin" validate={validate.Isin} @@ -48,10 +41,9 @@ export function PricingInput({ poolId }: { poolId: string }) { {({ field, meta, form }: FieldProps) => ( } placeholder="0.00" errorMessage={meta.touched ? meta.error : undefined} - currency={pool?.currency.symbol} onChange={(value) => form.setFieldValue('pricing.notional', value)} variant="small" /> @@ -94,9 +86,17 @@ export function PricingInput({ poolId }: { poolId: string }) { )} + } + placeholder="0.00" + rightElement="%" + name="pricing.interestRate" + validate={validate.interestRate} + /> } + label={} placeholder={0} - rightElement="days" - name="pricing.maturityExtensionDays" - validate={validate.maturityExtensionDays} - /> - - } - placeholder="0.00" rightElement="%" - name="pricing.interestRate" - validate={validate.fee} + name="pricing.maxPriceVariation" + validate={validate.maxPriceVariation} /> + {(values.pricing.valuationMethod === 'discountedCashFlow' || values.pricing.valuationMethod === 'outstandingDebt') && ( <> diff --git a/centrifuge-app/src/pages/Loan/ExternalFinanceForm.tsx b/centrifuge-app/src/pages/Loan/ExternalFinanceForm.tsx index 63b858510c..03a7cea6e0 100644 --- a/centrifuge-app/src/pages/Loan/ExternalFinanceForm.tsx +++ b/centrifuge-app/src/pages/Loan/ExternalFinanceForm.tsx @@ -1,16 +1,6 @@ import { CurrencyBalance, ExternalPricingInfo, findBalance, Loan as LoanType } from '@centrifuge/centrifuge-js' import { useBalances, useCentrifugeTransaction } from '@centrifuge/centrifuge-react' -import { - Button, - Card, - CurrencyInput, - IconInfo, - InlineFeedback, - NumberInput, - Shelf, - Stack, - Text, -} from '@centrifuge/fabric' +import { Box, Button, Card, CurrencyInput, IconInfo, 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' @@ -25,12 +15,12 @@ import { combine, max, positiveNumber } from '../../utils/validation' type FinanceValues = { price: number | '' | Decimal - quantity: number | '' + faceValue: number | '' } type RepayValues = { price: number | '' | Decimal - quantity: number | '' + faceValue: number | '' } export function ExternalFinanceForm({ loan }: { loan: LoanType }) { @@ -67,19 +57,16 @@ export function ExternalFinanceForm({ loan }: { loan: LoanType }) { const financeForm = useFormik({ initialValues: { price: '', - quantity: '', + faceValue: '', }, onSubmit: (values, actions) => { const price = CurrencyBalance.fromFloat(values.price, pool.currency.decimals) - const quantity = CurrencyBalance.fromFloat( - values.quantity, - 27 // TODO: Will be 18 decimals after next chain update - ) + const faceValue = CurrencyBalance.fromFloat(values.faceValue, 18) doFinanceTransaction([ loan.poolId, loan.id, - quantity, + faceValue, price, (loan.pricing as ExternalPricingInfo).Isin, account.actingAddress, @@ -92,11 +79,14 @@ export function ExternalFinanceForm({ loan }: { loan: LoanType }) { const repayForm = useFormik({ initialValues: { price: '', - quantity: '', + faceValue: '', }, onSubmit: (values, actions) => { const price = CurrencyBalance.fromFloat(values.price, pool.currency.decimals) - const quantity = CurrencyBalance.fromFloat(values.quantity, 27) + const quantity = CurrencyBalance.fromFloat( + (values.faceValue as number) / (values.price as number), + pool.currency.decimals + ) doRepayTransaction([ loan.poolId, @@ -132,38 +122,27 @@ export function ExternalFinanceForm({ loan }: { loan: LoanType }) { return ( - - - Total financed - - {formatBalance(loan.totalBorrowed?.toDecimal() ?? 0, pool?.currency.symbol, 2)} - - - + + + To finance the asset, enter face value and settlement price of the treasury bill. + + {availableFinancing.greaterThan(0) && !maturityDatePassed && ( - - {({ field, meta }: FieldProps) => { + + {({ field, meta, form }: FieldProps) => { return ( - form.setFieldValue('faceValue', value)} + currency={pool.currency.symbol} /> ) }} @@ -183,7 +162,7 @@ export function ExternalFinanceForm({ loan }: { loan: LoanType }) { return ( {financeForm.values.price && !Number.isNaN(financeForm.values.price as number) ? formatBalance( - Dec(financeForm.values.price || 0).mul(Dec(financeForm.values.quantity || 0)), + Dec(financeForm.values.price || 0).mul(Dec(financeForm.values.faceValue || 0)), pool?.currency.symbol, 2 ) @@ -231,16 +210,19 @@ export function ExternalFinanceForm({ loan }: { loan: LoanType }) { )} - + + To repay the asset, enter face value and settlement price of the asset. + + - Outstanding + Outstanding {/* outstandingDebt needs to be rounded down, b/c onSetMax displays the rounded down value as well */} - + {'valuationMethod' in loan.pricing && loan.pricing.valuationMethod === 'oracle' ? `${loan.pricing.outstandingQuantity.toFloat()} @ ${formatBalance( - loan.pricing.oracle.value, + new CurrencyBalance(loan.pricing.oracle.value, 18), pool?.currency.symbol, 2 )}` @@ -254,21 +236,21 @@ export function ExternalFinanceForm({ loan }: { loan: LoanType }) { - {({ field, meta }: FieldProps) => { + {({ field, meta, form }: FieldProps) => { return ( - form.setFieldValue('faceValue', value)} + currency={pool.currency.symbol} /> ) }} @@ -286,11 +268,13 @@ export function ExternalFinanceForm({ loan }: { loan: LoanType }) { form.setFieldValue('price', value)} + placeholder="0.0" + precision={6} /> ) }} @@ -301,7 +285,7 @@ export function ExternalFinanceForm({ loan }: { loan: LoanType }) { {repayForm.values.price && !Number.isNaN(repayForm.values.price as number) ? formatBalance( - Dec(repayForm.values.price || 0).mul(Dec(repayForm.values.quantity || 0)), + Dec(repayForm.values.price || 0).mul(Dec(repayForm.values.faceValue || 0)), pool?.currency.symbol, 2 ) diff --git a/centrifuge-app/src/pages/Loan/HoldingsValues.tsx b/centrifuge-app/src/pages/Loan/HoldingsValues.tsx new file mode 100644 index 0000000000..9abdaad996 --- /dev/null +++ b/centrifuge-app/src/pages/Loan/HoldingsValues.tsx @@ -0,0 +1,57 @@ +import { BorrowerTransaction, CurrencyBalance, ExternalPricingInfo, Loan, Pool } from '@centrifuge/centrifuge-js' +import { LabelValueStack } from '../../components/LabelValueStack' +import { formatBalance } from '../../utils/formatting' + +type Props = { + loan: Loan & { pricing: ExternalPricingInfo } + pool: Pool + transactions?: BorrowerTransaction[] | null +} + +export function HoldingsValues({ loan: { pricing }, pool, transactions }: Props) { + const totalFinanced = + transactions?.reduce((sum, trx) => { + if (trx.type === 'BORROWED') { + sum = new CurrencyBalance(sum.add(trx.amount || new CurrencyBalance(0, 27)), 27) + } + + return sum + }, new CurrencyBalance(0, 27)) || new CurrencyBalance(0, 27) + + const totalRepaid = + transactions?.reduce((sum, trx) => { + if (trx.type === 'REPAID') { + sum = new CurrencyBalance(sum.add(trx.amount || new CurrencyBalance(0, 27)), 27) + } + + return sum + }, new CurrencyBalance(0, 27)) || new CurrencyBalance(0, 27) + + return ( + <> + + + + + + ) +} diff --git a/centrifuge-app/src/pages/Loan/OraclePriceForm.tsx b/centrifuge-app/src/pages/Loan/OraclePriceForm.tsx index dbf291b1ef..f16e050916 100644 --- a/centrifuge-app/src/pages/Loan/OraclePriceForm.tsx +++ b/centrifuge-app/src/pages/Loan/OraclePriceForm.tsx @@ -1,6 +1,7 @@ -import { Loan as LoanType, Rate } from '@centrifuge/centrifuge-js' +import { CurrencyBalance, Loan as LoanType, Rate } from '@centrifuge/centrifuge-js' import { useAddress, useCentrifugeTransaction } from '@centrifuge/centrifuge-react' -import { Button, Card, CurrencyInput, Stack } from '@centrifuge/fabric' +import { Box, Button, Card, CurrencyInput, Flex, IconArrowDown, 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' @@ -11,17 +12,24 @@ import { usePool } from '../../utils/usePools' import { combine, positiveNumber } from '../../utils/validation' type PriceValues = { - price: number | '' | Decimal + currentPrice: number | Decimal + newPrice: number | '' } -export function OraclePriceForm({ loan }: { loan: LoanType }) { +export function OraclePriceForm({ + loan, + setShowOraclePricing, +}: { + loan: LoanType + setShowOraclePricing: (showOraclePricing: boolean) => void +}) { const address = useAddress() const canPrice = useCanSetOraclePrice(address) const pool = usePool(loan.poolId) const { execute: doOraclePriceTransaction, isLoading: isOraclePriceLoading } = useCentrifugeTransaction( 'Set oracle price', - (cent) => (args: [price: Rate], options) => { + (cent) => (args: [price: string], options) => { const [price] = args return cent.getApi().pipe( switchMap((api) => { @@ -37,17 +45,20 @@ export function OraclePriceForm({ loan }: { loan: LoanType }) { { onSuccess: () => { oraclePriceForm.resetForm() + setShowOraclePricing(false) }, } ) const oraclePriceForm = useFormik({ initialValues: { - price: '', + currentPrice: + 'oracle' in loan.pricing ? new CurrencyBalance(loan.pricing.oracle.value.toString(), 18).toDecimal() : 0, + newPrice: '', }, onSubmit: (values, actions) => { - const price = Rate.fromFloat(values.price) - doOraclePriceTransaction([price]) + const newPrice = new BN(Rate.fromFloat(values.newPrice).toString()).div(new BN(10).pow(new BN(9))).toString() + doOraclePriceTransaction([newPrice]) actions.setSubmitting(false) }, validateOnMount: true, @@ -68,24 +79,45 @@ export function OraclePriceForm({ loan }: { loan: LoanType }) { return ( + + Update price + - + + {({ field }: FieldProps) => { + return ( + + ) + }} + + + + + {({ field, meta, form }: FieldProps) => { return ( form.setFieldValue('price', value)} + onChange={(value) => form.setFieldValue('newPrice', value)} precision={6} /> ) }} + Current exchange rate: 1 USD = 1 {pool.currency.symbol} diff --git a/centrifuge-app/src/pages/Loan/PricingValues.tsx b/centrifuge-app/src/pages/Loan/PricingValues.tsx index 7ccdddb626..17599b3a75 100644 --- a/centrifuge-app/src/pages/Loan/PricingValues.tsx +++ b/centrifuge-app/src/pages/Loan/PricingValues.tsx @@ -1,4 +1,4 @@ -import { Loan, Pool, TinlakeLoan } from '@centrifuge/centrifuge-js' +import { CurrencyBalance, Loan, Pool, TinlakeLoan } from '@centrifuge/centrifuge-js' import { LabelValueStack } from '../../components/LabelValueStack' import { formatDate, getAge } from '../../utils/date' import { formatBalance, formatPercentage } from '../../utils/formatting' @@ -25,13 +25,14 @@ export function PricingValues({ loan: { pricing }, pool }: Props) { - - {pricing.maxBorrowAmount && ( - - )} ) } @@ -39,7 +40,7 @@ export function PricingValues({ loan: { pricing }, pool }: Props) { return ( <> {pricing.maturityDate && } - {pricing.maturityExtensionDays && ( + {'maturityExtensionDays' in pricing && ( )} {isOutstandingDebtOrDiscountedCashFlow && ( @@ -49,7 +50,7 @@ export function PricingValues({ loan: { pricing }, pool }: Props) { /> )} {isOutstandingDebtOrDiscountedCashFlow && pricing.valuationMethod === 'discountedCashFlow' && ( diff --git a/centrifuge-app/src/pages/Loan/TransactionTable.tsx b/centrifuge-app/src/pages/Loan/TransactionTable.tsx new file mode 100644 index 0000000000..a676eea8d3 --- /dev/null +++ b/centrifuge-app/src/pages/Loan/TransactionTable.tsx @@ -0,0 +1,107 @@ +import { BorrowerTransaction, CurrencyBalance } from '@centrifuge/centrifuge-js' +import { BorrowerTransactionType } from '@centrifuge/centrifuge-js/dist/types/subquery' +import { StatusChip } from '@centrifuge/fabric' +import { useMemo } from 'react' +import { DataTable } from '../../components/DataTable' +import { formatDate } from '../../utils/date' +import { formatBalance } from '../../utils/formatting' + +type Props = { + transactions: BorrowerTransaction[] +} + +export const TransactionTable = ({ transactions }: Props) => { + const assetTransactions = useMemo(() => { + const sortedTransactions = transactions.sort((a, b) => { + if (a.timestamp > b.timestamp) { + return 1 + } + + if (a.timestamp < b.timestamp) { + return -1 + } + + if (a.type === 'CLOSED' || b.type === 'CLOSED') { + return -1 + } + + return 0 + }) + + return sortedTransactions.map((transaction, index, array) => ({ + type: transaction.type, + transactionDate: transaction.timestamp, + // settlePrice: TODO: add subquery query for fetching settle price + netFlow: transaction.amount, + position: array.slice(0, index + 1).reduce((sum, trx) => { + if (trx.type === 'BORROWED') { + sum = new CurrencyBalance(sum.add(trx.amount || new CurrencyBalance(0, 27)), 27) + } + if (trx.type === 'REPAID') { + sum = new CurrencyBalance(sum.sub(trx.amount || new CurrencyBalance(0, 27)), 27) + } + return sum + }, new CurrencyBalance(0, 27)), + })) + }, [transactions]) + + const getStatusChipType = (type: BorrowerTransactionType) => { + if (type === 'BORROWED' || type === 'CREATED' || type === 'PRICED') return 'info' + if (type === 'REPAID') return 'ok' + return 'default' + } + + return ( + ( + {`${row.type[0]}${row.type + .slice(1) + .toLowerCase()}`} + ), + flex: '3', + }, + { + align: 'left', + header: 'Transaction date', + cell: (row) => formatDate(row.transactionDate), + flex: '3', + }, + // TODO: add subquery query for fetching settle price + // { + // align: 'left', + // header: 'Settle price', + // cell: (row) => formatBalance(row.settlePrice, 'USD'), + // flex: '3', + // }, + { + align: 'left', + header: 'Net flow', + cell: (row) => (row.netFlow ? formatBalance(new CurrencyBalance(row.netFlow, 27), 'USD') : '-'), + flex: '3', + }, + { + align: 'left', + header: 'Position', + cell: (row) => formatBalance(row.position, 'USD'), + flex: '3', + }, + // TODO: add link to transaction + // { + // align: 'right', + // header: '', + // cell: () => ( + // + // + // + // ), + // flex: '3', + // }, + ]} + /> + ) +} diff --git a/centrifuge-app/src/pages/Loan/index.tsx b/centrifuge-app/src/pages/Loan/index.tsx index 39c1621f3f..3b486082b9 100644 --- a/centrifuge-app/src/pages/Loan/index.tsx +++ b/centrifuge-app/src/pages/Loan/index.tsx @@ -1,8 +1,12 @@ -import { CurrencyBalance, Loan as LoanType, TinlakeLoan } from '@centrifuge/centrifuge-js' +import { CurrencyBalance, ExternalPricingInfo, Loan as LoanType, Pool, TinlakeLoan } from '@centrifuge/centrifuge-js' import { useCentrifuge } from '@centrifuge/centrifuge-react' import { + AnchorButton, Box, Button, + Flex, + IconChevronLeft, + IconExternalLink, IconNft, InteractiveCard, Shelf, @@ -12,7 +16,6 @@ import { Thumbnail, truncate, } from '@centrifuge/fabric' -import BN from 'bn.js' import * as React from 'react' import { useHistory, useParams, useRouteMatch } from 'react-router' import { Identity } from '../../components/Identity' @@ -24,29 +27,34 @@ import { PageSummary } from '../../components/PageSummary' import { PageWithSideBar } from '../../components/PageWithSideBar' import { AnchorPillButton } from '../../components/PillButton' import { PodAuthSection } from '../../components/PodAuthSection' +import { RouterLinkButton } from '../../components/RouterLinkButton' import { Tooltips } from '../../components/Tooltips' import { nftMetadataSchema } from '../../schemas' import { LoanTemplate } from '../../types' import { copyToClipboard } from '../../utils/copyToClipboard' -import { formatDate } from '../../utils/date' +import { daysBetween, formatDate } from '../../utils/date' import { formatBalance, truncateText } from '../../utils/formatting' import { useAddress } from '../../utils/useAddress' -import { useAvailableFinancing, useLoan, useNftDocumentId } from '../../utils/useLoans' +import { useLoan, useNftDocumentId } from '../../utils/useLoans' import { useMetadata } from '../../utils/useMetadata' import { useCentNFT } from '../../utils/useNFTs' import { useCanBorrowAsset, useCanSetOraclePrice } from '../../utils/usePermissions' import { usePodDocument } from '../../utils/usePodDocument' -import { usePool, usePoolMetadata } from '../../utils/usePools' +import { useBorrowerAssetTransactions, usePool, usePoolMetadata } from '../../utils/usePools' import { FinanceForm } from './FinanceForm' import { FinancingRepayment } from './FinancingRepayment' +import { HoldingsValues } from './HoldingsValues' import { OraclePriceForm } from './OraclePriceForm' import { PricingValues } from './PricingValues' +import { TransactionTable } from './TransactionTable' import { formatNftAttribute } from './utils' export const LoanPage: React.FC = () => { const [showOraclePricing, setShowOraclePricing] = React.useState(false) return ( - }> + } + > setShowOraclePricing(true)} /> ) @@ -56,7 +64,10 @@ function isTinlakeLoan(loan: LoanType | TinlakeLoan): loan is TinlakeLoan { return loan.poolId.startsWith('0x') } -const LoanSidebar: React.FC<{ showOraclePricing?: boolean }> = ({ showOraclePricing }) => { +const LoanSidebar: React.FC<{ + showOraclePricing?: boolean + setShowOraclePricing: (showOraclePricing: boolean) => void +}> = ({ showOraclePricing, setShowOraclePricing }) => { const { pid, aid } = useParams<{ pid: string; aid: string }>() const loan = useLoan(pid, aid) const canBorrow = useCanBorrowAsset(pid, aid) @@ -65,7 +76,7 @@ const LoanSidebar: React.FC<{ showOraclePricing?: boolean }> = ({ showOraclePric return ( - {showOraclePricing && } + {showOraclePricing && } ) @@ -82,10 +93,14 @@ const Loan: React.FC<{ setShowOraclePricing?: () => void }> = ({ setShowOraclePr const { data: nftMetadata, isLoading: nftMetadataIsLoading } = useMetadata(nft?.metadataUri, nftMetadataSchema) const history = useHistory() const cent = useCentrifuge() - const { current: availableFinancing } = useAvailableFinancing(poolId, assetId) const metadataIsLoading = poolMetadataIsLoading || nftMetadataIsLoading const address = useAddress() const canOraclePrice = useCanSetOraclePrice(address) + const borrowerAssetTransactions = useBorrowerAssetTransactions(poolId, assetId) + + const templateIds = poolMetadata?.loanTemplates?.map((s) => s.id) ?? [] + const templateId = templateIds.at(-1) + const { data: templateMetadata } = useMetadata(templateId) const name = truncateText((isTinlakePool ? loan?.asset.nftId : nftMetadata?.name) || 'Unnamed asset', 30) const imageUrl = nftMetadata?.image ? cent.metadata.parseMetadataUrl(nftMetadata.image) : '' @@ -104,105 +119,84 @@ const Loan: React.FC<{ setShowOraclePricing?: () => void }> = ({ setShowOraclePr ? Object.fromEntries(Object.entries(document.attributes).map(([key, obj]: any) => [key, obj.value])) : {} + const originationDate = loan && 'originationDate' in loan ? new Date(loan?.originationDate).toISOString() : undefined + + const maturityPercentage = React.useMemo(() => { + if (originationDate && loan?.pricing.maturityDate) { + const termDays = daysBetween(originationDate, loan?.pricing.maturityDate) + const daysSinceIssuance = daysBetween(originationDate, new Date()) + + if (daysSinceIssuance >= termDays) return 1 + + return daysSinceIssuance / termDays + } + return 0 + }, [originationDate, loan?.pricing.maturityDate]) + return ( + + + {poolMetadata?.pool?.name ?? 'Pool assets'} + + + + + Asset Overview + + } title={{name}} - titleAddition={loan && } - parent={{ to: `${basePath}/${poolId}/assets`, label: poolMetadata?.pool?.name ?? 'Pool assets' }} subtitle={ - - {poolMetadata?.pool?.asset.class} asset by{' '} - {isTinlakePool && loan && 'owner' in loan - ? truncate(loan.owner) - : nft?.owner && } - + + history.push(`/nfts/collection/${loan?.asset.collectionId}/object/${loan?.asset.nftId}`)} + icon={IconExternalLink} + small + variant="tertiary" + > + View NFT + + } /> {loan && pool && ( <> , - value: isTinlakePool - ? 'riskGroup' in loan && loan.riskGroup - : 'value' in loan.pricing && loan.pricing.value - ? formatBalance(loan.pricing.value, pool?.currency.symbol) - : 'TBD', - }, - { - label: , - value: formatBalance(availableFinancing, pool?.currency.symbol), - }, - { - label: , - value: - 'outstandingDebt' in loan - ? isTinlakePool && 'writeOffPercentage' in loan - ? formatBalance( - new CurrencyBalance( - loan.outstandingDebt.sub( - loan.outstandingDebt.mul(new BN(loan.writeOffPercentage).div(new BN(100))) - ), - pool.currency.decimals - ), - pool?.currency.symbol - ) - : formatBalance(loan.outstandingDebt, pool?.currency.symbol) - : 'n/a', - }, - ...(isTinlakePool - ? [ - { - label: , - value: - 'writeOffPercentage' in loan - ? formatBalance( - new CurrencyBalance( - loan.outstandingDebt.mul(new BN(loan.writeOffPercentage).div(new BN(100))), - pool.currency.decimals - ), - pool?.currency.symbol - ) - : 'n/a', - }, - ] - : []), - ] + title={ + + + Details + + + } + data={[ + ...(templateMetadata?.keyAttributes + ?.filter((key) => templateMetadata?.attributes?.[key].public) + .map((key) => ({ + label: templateMetadata?.attributes?.[key].label, + value: nftMetadata?.properties[key], + })) || []), + { + label: 'Maturity date', + value: formatDate(loan.pricing.maturityDate), + }, + { + label: 'Current value', + value: formatBalance( + 'outstandingDebt' in loan ? loan.outstandingDebt : new CurrencyBalance(0, pool.currency.decimals), + pool?.currency.symbol + ), + }, + ]} /> {(!isTinlakePool || (isTinlakePool && loan.status === 'Closed' && 'dateClosed' in loan)) && 'valuationMethod' in loan.pricing && loan.pricing.valuationMethod !== 'oracle' ? ( - + Financing & repayment cash flow}> {isTinlakePool && loan.status === 'Closed' && 'dateClosed' in loan ? ( @@ -218,24 +212,68 @@ const Loan: React.FC<{ setShowOraclePricing?: () => void }> = ({ setShowOraclePr ) : null} - setShowOraclePricing()} small> - Update price - - ) - } - > - - - + {'valuationMethod' in loan.pricing && loan.pricing.valuationMethod === 'oracle' && ( + Holdings}> + + + + + )} + + Pricing}> + + + + + {canOraclePrice && + setShowOraclePricing && + loan.status !== 'Closed' && + 'valuationMethod' in loan.pricing && + loan.pricing.valuationMethod === 'oracle' && ( + + + + )} + + + {loan.status === 'Active' && ( + Remaining maturity}> + + + + + + + + {maturityPercentage !== 1 && ( + + + + )} + + + + + )} + + {borrowerAssetTransactions?.length ? ( + + Transaction history + + } + > + + + ) : null} )} {(loan && nft) || loan?.poolId.startsWith('0x') ? ( @@ -243,7 +281,11 @@ const Loan: React.FC<{ setShowOraclePricing?: () => void }> = ({ setShowOraclePr {templateData?.sections?.map((section, i) => { const isPublic = section.attributes.every((key) => templateData.attributes?.[key]?.public) return ( - + {section.name}} + titleAddition={isPublic ? undefined : 'Private'} + key={i} + > {isPublic || document ? ( {section.attributes.map((key) => { @@ -261,7 +303,7 @@ const Loan: React.FC<{ setShowOraclePricing?: () => void }> = ({ setShowOraclePr ) })} - + NFT}> {isTinlakePool && 'owner' in loan ? ( } value={assetId} /> diff --git a/centrifuge-app/src/pages/Pool/Assets/index.tsx b/centrifuge-app/src/pages/Pool/Assets/index.tsx index 39eff769ee..277a62394c 100644 --- a/centrifuge-app/src/pages/Pool/Assets/index.tsx +++ b/centrifuge-app/src/pages/Pool/Assets/index.tsx @@ -65,7 +65,7 @@ export const PoolDetailAssets: React.FC = () => { value: assetValue, }, { label: , value: ongoingAssets.length || 0 }, - { label: , value: formatPercentage(avgInterestRatePerSec) }, + { label: , value: formatPercentage(avgInterestRatePerSec) }, { label: , value: formatBalance(avgAmount, pool.currency.symbol) }, ] diff --git a/centrifuge-app/src/utils/usePools.ts b/centrifuge-app/src/utils/usePools.ts index 9e8fdf3539..419bb91a3f 100644 --- a/centrifuge-app/src/utils/usePools.ts +++ b/centrifuge-app/src/utils/usePools.ts @@ -1,4 +1,4 @@ -import Centrifuge, { Pool, PoolMetadata } from '@centrifuge/centrifuge-js' +import Centrifuge, { BorrowerTransaction, Pool, PoolMetadata } from '@centrifuge/centrifuge-js' import { useCentrifuge, useCentrifugeQuery, useWallet } from '@centrifuge/centrifuge-react' import { useEffect } from 'react' import { useQuery } from 'react-query' @@ -73,6 +73,26 @@ export function useBorrowerTransactions(poolId: string, from?: Date, to?: Date) return result } +export function useBorrowerAssetTransactions(poolId: string, assetId: string, from?: Date, to?: Date) { + const [result] = useCentrifugeQuery( + ['borrowerAssetTransactions', poolId, assetId, from, to], + (cent) => { + const borrowerTransactions = cent.pools.getBorrowerTransactions([poolId, from, to]) + + return borrowerTransactions.pipe( + map((transactions: BorrowerTransaction[]) => + transactions.filter((transaction) => transaction.loanId.split('-')[1] === assetId) + ) + ) + }, + { + suspense: true, + } + ) + + return result +} + export function useDailyPoolStates(poolId: string, from?: Date, to?: Date) { if (poolId.startsWith('0x')) throw new Error('Only works with Centrifuge Pools') const [result] = useCentrifugeQuery( diff --git a/centrifuge-app/src/utils/validation/index.ts b/centrifuge-app/src/utils/validation/index.ts index 71e1416e64..fee2ab13e6 100644 --- a/centrifuge-app/src/utils/validation/index.ts +++ b/centrifuge-app/src/utils/validation/index.ts @@ -1,5 +1,6 @@ import { isAddress } from '@polkadot/util-crypto' import Decimal from 'decimal.js-light' +import { daysBetween } from '../date' import { getImageDimensions } from '../getImageDimensions' const MB = 1024 ** 2 @@ -135,6 +136,24 @@ export const isin = (err?: CustomError) => (val?: any) => { return match[3] === calcISINCheck(match[1] + match[2]).toString() ? '' : getError(`Not a valid ISIN`, err, val) } +export const maturityDate = (err?: CustomError) => (val?: any) => { + const date = new Date(val) + + const isOneDayFromNow = daysBetween(date, new Date(Date.now() + 24 * 60 * 60 * 1000)) > 0 + + if (isOneDayFromNow) { + return getError(`Must be at least one day from now`, err, val) + } + + const isWithinFiveYearsFromNow = daysBetween(date, new Date(Date.now() + 5 * 365 * 24 * 60 * 60 * 1000)) < 0 + + if (isWithinFiveYearsFromNow) { + return getError(`Must be within 5 years from now`, err, val) + } + + return '' +} + /** * Returns a combination of validation functions. * The returned function runs each validation function in sequence diff --git a/centrifuge-js/src/modules/pools.ts b/centrifuge-js/src/modules/pools.ts index 5b2bb11352..61cc2a934c 100644 --- a/centrifuge-js/src/modules/pools.ts +++ b/centrifuge-js/src/modules/pools.ts @@ -397,6 +397,8 @@ export type ExternalPricingInfo = { value: CurrencyBalance timestamp: number } + notional: CurrencyBalance + interestRate: Rate } type TinlakePricingInfo = { @@ -670,7 +672,7 @@ type InvestorTransaction = { transactionFee: CurrencyBalance | null } -type BorrowerTransaction = { +export type BorrowerTransaction = { id: string timestamp: string poolId: string @@ -2047,7 +2049,7 @@ export function getPoolsModule(inst: Centrifuge) { `query($poolId: String!, $from: Datetime!, $to: Datetime!) { borrowerTransactions( orderBy: TIMESTAMP_ASC, - filter: { + filter: { poolId: { equalTo: $poolId }, timestamp: { greaterThan: $from, lessThan: $to }, }) {