From 4ae53e65391c28eda9f3017b31cab88f94eb0b4b Mon Sep 17 00:00:00 2001 From: JP Date: Tue, 3 Oct 2023 08:26:12 -0500 Subject: [PATCH] feat: implement oracle-v2 (#1558) --- .../src/components/AssetSummary.tsx | 45 ++ centrifuge-app/src/components/LoanLabel.tsx | 12 +- centrifuge-app/src/components/LoanList.tsx | 13 +- centrifuge-app/src/components/PageSection.tsx | 2 +- .../src/components/Report/AssetList.tsx | 2 +- centrifuge-app/src/components/Tooltips.tsx | 24 +- .../src/pages/IssuerCreatePool/validate.ts | 4 + .../pages/IssuerPool/Assets/CreateLoan.tsx | 12 +- .../pages/IssuerPool/Assets/PricingInput.tsx | 49 +- .../src/pages/Loan/ExternalFinanceForm.tsx | 253 +++++---- .../src/pages/Loan/FinancingRepayment.tsx | 26 +- .../src/pages/Loan/HoldingsValues.tsx | 68 +++ .../src/pages/Loan/OraclePriceForm.tsx | 53 +- .../src/pages/Loan/PricingValues.tsx | 25 +- .../src/pages/Loan/TransactionTable.tsx | 167 ++++++ centrifuge-app/src/pages/Loan/index.tsx | 425 ++++++++------- .../src/pages/Pool/Assets/index.tsx | 22 +- centrifuge-app/src/utils/date.ts | 5 + centrifuge-app/src/utils/getLatestPrice.ts | 21 + centrifuge-app/src/utils/usePools.ts | 44 +- centrifuge-app/src/utils/validation/index.ts | 49 +- centrifuge-js/src/modules/pools.ts | 495 ++++++++---------- centrifuge-js/src/types/subquery.ts | 2 + 23 files changed, 1108 insertions(+), 710 deletions(-) create mode 100644 centrifuge-app/src/components/AssetSummary.tsx create mode 100644 centrifuge-app/src/pages/Loan/HoldingsValues.tsx create mode 100644 centrifuge-app/src/pages/Loan/TransactionTable.tsx create mode 100644 centrifuge-app/src/utils/getLatestPrice.ts diff --git a/centrifuge-app/src/components/AssetSummary.tsx b/centrifuge-app/src/components/AssetSummary.tsx new file mode 100644 index 0000000000..f187b57253 --- /dev/null +++ b/centrifuge-app/src/components/AssetSummary.tsx @@ -0,0 +1,45 @@ +import { Loan, TinlakeLoan } from '@centrifuge/centrifuge-js' +import { Box, Shelf, Stack, Text } from '@centrifuge/fabric' +import * as React from 'react' +import { useTheme } from 'styled-components' +import LoanLabel from './LoanLabel' + +type Props = { + data?: { + label: React.ReactNode + value: React.ReactNode + }[] + children?: React.ReactNode + loan: Loan | TinlakeLoan +} + +export const AssetSummary: React.FC = ({ data, children, loan }) => { + const theme = useTheme() + return ( + + + + Details + + + + + {data?.map(({ label, value }, index) => ( + + + {label} + + {value} + + ))} + {children} + + + ) +} diff --git a/centrifuge-app/src/components/LoanLabel.tsx b/centrifuge-app/src/components/LoanLabel.tsx index 8b8487ed9b..c1e77a6141 100644 --- a/centrifuge-app/src/components/LoanLabel.tsx +++ b/centrifuge-app/src/components/LoanLabel.tsx @@ -9,11 +9,11 @@ interface Props { loan: Loan | TinlakeLoan } -export function getLoanLabelStatus(l: Loan | TinlakeLoan): [LabelStatus, string] { +export function getLoanLabelStatus(l: Loan | TinlakeLoan, isExternalAssetRepaid?: boolean): [LabelStatus, string] { const today = new Date() today.setUTCHours(0, 0, 0, 0) if (l.status === 'Active' && (l as ActiveLoan).writeOffStatus) return ['critical', 'Write-off'] - if (l.status === 'Closed') return ['ok', 'Repaid'] + if (l.status === 'Closed' || isExternalAssetRepaid) return ['ok', 'Repaid'] if ( l.status === 'Active' && 'interestRate' in l.pricing && @@ -38,7 +38,13 @@ export function getLoanLabelStatus(l: Loan | TinlakeLoan): [LabelStatus, string] } const LoanLabel: React.FC = ({ loan }) => { - const [status, text] = getLoanLabelStatus(loan) + const currentFace = + loan.pricing && 'outstandingQuantity' in loan.pricing + ? loan.pricing.outstandingQuantity.toDecimal().mul(loan.pricing.notional.toDecimal()) + : null + + const isExternalAssetRepaid = currentFace?.isZero() && loan.status === 'Active' + const [status, text] = getLoanLabelStatus(loan, isExternalAssetRepaid) return {text} } diff --git a/centrifuge-app/src/components/LoanList.tsx b/centrifuge-app/src/components/LoanList.tsx index 20d92ef717..c650a039ea 100644 --- a/centrifuge-app/src/components/LoanList.tsx +++ b/centrifuge-app/src/components/LoanList.tsx @@ -23,11 +23,10 @@ import { useCentNFT } from '../utils/useNFTs' import { usePool, usePoolMetadata } from '../utils/usePools' import { Column, DataTable, SortableTableHeader } from './DataTable' import { LoadBoundary } from './LoadBoundary' -import LoanLabel, { getLoanLabelStatus } from './LoanLabel' +import LoanLabel from './LoanLabel' type Row = (Loan | TinlakeLoan) & { idSortKey: number - statusLabel: string originationDateSortKey: string } @@ -120,7 +119,6 @@ export function LoanList({ loans }: Props) { const rows: Row[] = loans.map((loan) => { return { - statusLabel: getLoanLabelStatus(loan)[1], nftIdSortKey: loan.asset.nftId, idSortKey: parseInt(loan.id, 10), outstandingDebtSortKey: loan.status !== 'Closed' && loan?.outstandingDebt?.toDecimal().toNumber(), @@ -206,6 +204,11 @@ function Amount({ loan }: { loan: Row }) { const pool = usePool(loan.poolId) const { current } = useAvailableFinancing(loan.poolId, loan.id) + const currentFace = + loan?.pricing && 'outstandingQuantity' in loan.pricing + ? loan.pricing.outstandingQuantity.toDecimal().mul(loan.pricing.notional.toDecimal()) + : null + function getAmount(l: Row) { switch (l.status) { case 'Closed': @@ -220,6 +223,10 @@ function Amount({ loan }: { loan: Row }) { return formatBalance(l.totalRepaid, pool?.currency.symbol) } + if ('valuationMethod' in loan.pricing && loan.pricing.valuationMethod === 'oracle' && currentFace) { + return formatBalance(currentFace, pool?.currency.symbol) + } + return formatBalance(l.outstandingDebt, pool?.currency.symbol) default: diff --git a/centrifuge-app/src/components/PageSection.tsx b/centrifuge-app/src/components/PageSection.tsx index 11afb319f6..ff1763e72c 100644 --- a/centrifuge-app/src/components/PageSection.tsx +++ b/centrifuge-app/src/components/PageSection.tsx @@ -89,7 +89,7 @@ export const PageSection: React.FC = ({ {headerRight} )} - {collapsible ? {children} : children} + {collapsible ? {children} : 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/CreateLoan.tsx b/centrifuge-app/src/pages/IssuerPool/Assets/CreateLoan.tsx index 58b4f511c3..86569956c7 100644 --- a/centrifuge-app/src/pages/IssuerPool/Assets/CreateLoan.tsx +++ b/centrifuge-app/src/pages/IssuerPool/Assets/CreateLoan.tsx @@ -1,4 +1,4 @@ -import { CurrencyBalance, Rate } from '@centrifuge/centrifuge-js' +import { CurrencyBalance, Price, Rate } from '@centrifuge/centrifuge-js' import { formatBalance, Transaction, @@ -70,6 +70,7 @@ export type CreateLoanFormValues = { maxBorrowQuantity: number | '' Isin: string notional: number | '' + maxPriceVariation: number | '' } } @@ -224,6 +225,7 @@ function IssuerCreateLoan() { maxBorrowQuantity: '', Isin: '', notional: '', + maxPriceVariation: '', }, }, onSubmit: async (values, { setSubmitting }) => { @@ -233,12 +235,12 @@ function IssuerCreateLoan() { values.pricing.valuationMethod === 'oracle' ? { valuationMethod: values.pricing.valuationMethod, + maxPriceVariation: Rate.fromPercent(values.pricing.maxPriceVariation), maxBorrowAmount: values.pricing.maxBorrowQuantity - ? CurrencyBalance.fromFloat(values.pricing.maxBorrowQuantity, decimals) + ? Price.fromFloat(values.pricing.maxBorrowQuantity) : null, Isin: values.pricing.Isin || '', maturityDate: new Date(values.pricing.maturityDate), - maturityExtensionDays: values.pricing.maturityExtensionDays, interestRate: Rate.fromPercent(values.pricing.interestRate), notional: CurrencyBalance.fromFloat(values.pricing.notional, decimals), } @@ -259,7 +261,7 @@ function IssuerCreateLoan() { const tx: Transaction = { id: txId, - title: 'Create document', + title: 'Create asset', status: 'creating', args: [], } @@ -325,7 +327,7 @@ function IssuerCreateLoan() { doTransaction([submittable], undefined, txId) } catch (e) { console.error(e) - updateTransaction(txId, { status: 'failed', failedReason: 'Failed to create document NFT' }) + updateTransaction(txId, { status: 'failed', failedReason: 'Failed to create asset' }) } setSubmitting(false) diff --git a/centrifuge-app/src/pages/IssuerPool/Assets/PricingInput.tsx b/centrifuge-app/src/pages/IssuerPool/Assets/PricingInput.tsx index 23666b29b9..22a1da638d 100644 --- a/centrifuge-app/src/pages/IssuerPool/Assets/PricingInput.tsx +++ b/centrifuge-app/src/pages/IssuerPool/Assets/PricingInput.tsx @@ -3,7 +3,7 @@ import { Field, FieldProps, useFormikContext } from 'formik' import { FieldWithErrorMessage } from '../../../components/FieldWithErrorMessage' import { Tooltips } from '../../../components/Tooltips' import { usePool } from '../../../utils/usePools' -import { combine, max, positiveNumber, required } from '../../../utils/validation' +import { combine, max, nonNegativeNumber, positiveNumber, required } from '../../../utils/validation' import { validate } from '../../IssuerCreatePool/validate' import { CreateLoanFormValues } from './CreateLoan' @@ -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,15 +41,23 @@ 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" + currency={pool.currency.symbol} /> )} + } + placeholder={0} + rightElement="%" + name="pricing.maxPriceVariation" + validate={validate.maxPriceVariation} + /> )} @@ -94,9 +95,17 @@ export function PricingInput({ poolId }: { poolId: string }) { )} + } + placeholder="0.00" + rightElement="%" + name="pricing.interestRate" + validate={combine(required(), nonNegativeNumber(), max(100))} + /> - } - placeholder={0} - rightElement="days" - name="pricing.maturityExtensionDays" - validate={validate.maturityExtensionDays} - /> - } - placeholder="0.00" - rightElement="%" - name="pricing.interestRate" - validate={validate.fee} - /> {(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..7c0c68e23b 100644 --- a/centrifuge-app/src/pages/Loan/ExternalFinanceForm.tsx +++ b/centrifuge-app/src/pages/Loan/ExternalFinanceForm.tsx @@ -1,39 +1,33 @@ -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 { CurrencyBalance, ExternalPricingInfo, findBalance, Loan as LoanType, 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 Decimal from 'decimal.js-light' import { Field, FieldProps, Form, FormikProvider, useFormik } from 'formik' import * as React from 'react' import { Dec } from '../../utils/Decimal' -import { formatBalance, roundDown } from '../../utils/formatting' +import { formatBalance } from '../../utils/formatting' import { useFocusInvalidInput } from '../../utils/useFocusInvalidInput' import { useAvailableFinancing } from '../../utils/useLoans' import { useBorrower } from '../../utils/usePermissions' import { usePool } from '../../utils/usePools' -import { combine, max, positiveNumber } from '../../utils/validation' +import { combine, maxPriceVariance, positiveNumber, settlementPrice } 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 }) { +type ExternalLoan = LoanType & { + pricing: ExternalPricingInfo +} + +export function ExternalFinanceForm({ loan }: { loan: ExternalLoan }) { const pool = usePool(loan.poolId) const account = useBorrower(loan.poolId, loan.id) const balances = useBalances(account.actingAddress) @@ -67,47 +61,37 @@ 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 quantity = Price.fromFloat(Dec(values.faceValue).div(loan.pricing.notional.toDecimal())) - doFinanceTransaction([ - loan.poolId, - loan.id, - quantity, - price, - (loan.pricing as ExternalPricingInfo).Isin, - account.actingAddress, - ]) + doFinanceTransaction([loan.poolId, loan.id, quantity, price], { + account, + }) actions.setSubmitting(false) }, validateOnMount: true, }) + const currentFace = + loan?.pricing && 'outstandingQuantity' in loan.pricing + ? loan.pricing.outstandingQuantity.toDecimal().mul(loan.pricing.notional.toDecimal()) + : null + 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 = Price.fromFloat(Dec(values.faceValue).div(loan.pricing.notional.toDecimal())) - doRepayTransaction([ - loan.poolId, - loan.id, - quantity, - new BN(0), - new BN(0), - price, - (loan.pricing as ExternalPricingInfo).Isin, - account.actingAddress, - ]) + doRepayTransaction([loan.poolId, loan.id, quantity, new BN(0), new BN(0), price], { + account, + }) actions.setSubmitting(false) }, validateOnMount: true, @@ -132,38 +116,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 transaction. + + {availableFinancing.greaterThan(0) && !maturityDatePassed && ( - - {({ field, meta }: FieldProps) => { + + {({ field, meta, form }: FieldProps) => { return ( - form.setFieldValue('faceValue', value)} + currency={pool.currency.symbol} /> ) }} @@ -171,19 +144,38 @@ export function ExternalFinanceForm({ loan }: { loan: LoanType }) { { + const num = val instanceof Decimal ? val.toNumber() : val + const financeAmount = Dec(num) + .mul(financeForm.values.faceValue || 1) + .div(loan.pricing.notional.toDecimal()) + + return financeAmount.gt(availableFinancing) + ? `Amount exceeds available reserve (${formatBalance( + availableFinancing, + pool?.currency.symbol, + 2 + )})` + : '' + }, + (val) => { + const financeAmount = Dec(val) + .mul(financeForm.values.faceValue || 1) + .div(loan.pricing.notional.toDecimal()) + + return financeAmount.gt(maxBorrow) + ? `Amount exceeds max borrow (${formatBalance(maxBorrow, pool.currency.symbol, 2)})` + : '' + }, + maxPriceVariance(loan.pricing) )} > {({ field, meta, form }: FieldProps) => { return ( - Total amount - + Total amount + {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)) + .div(loan.pricing.notional.toDecimal()), pool?.currency.symbol, 2 ) : `0.00 ${pool.currency.symbol}`} - - This is calculated through the amount multiplied by the current price of the asset - - {(poolReserve.lessThan(availableFinancing) || - ('valuationMethod' in loan.pricing && !loan.pricing.maxBorrowAmount)) && ( - - - - The pool's available reserve ({formatBalance(poolReserve, pool?.currency.symbol)}) is smaller - than the available financing - - - )} diff --git a/centrifuge-app/src/pages/Loan/PricingValues.tsx b/centrifuge-app/src/pages/Loan/PricingValues.tsx index 7ccdddb626..0699e26704 100644 --- a/centrifuge-app/src/pages/Loan/PricingValues.tsx +++ b/centrifuge-app/src/pages/Loan/PricingValues.tsx @@ -2,14 +2,20 @@ import { Loan, Pool, TinlakeLoan } from '@centrifuge/centrifuge-js' import { LabelValueStack } from '../../components/LabelValueStack' import { formatDate, getAge } from '../../utils/date' import { formatBalance, formatPercentage } from '../../utils/formatting' +import { getLatestPrice } from '../../utils/getLatestPrice' import { TinlakePool } from '../../utils/tinlake/useTinlakePools' +import { useBorrowerTransactions } from '../../utils/usePools' type Props = { loan: Loan | TinlakeLoan pool: Pool | TinlakePool } -export function PricingValues({ loan: { pricing }, pool }: Props) { +export function PricingValues({ loan, pool }: Props) { + const { pricing } = loan + + const borrowerTransactions = useBorrowerTransactions(loan.poolId) + const isOutstandingDebtOrDiscountedCashFlow = 'valuationMethod' in pricing && (pricing.valuationMethod === 'outstandingDebt' || pricing.valuationMethod === 'discountedCashFlow') @@ -20,18 +26,19 @@ export function PricingValues({ loan: { pricing }, pool }: Props) { const days = getAge(new Date(pricing.oracle.timestamp).toISOString()) + const borrowerAssetTransactions = borrowerTransactions?.filter( + (borrowerTransaction) => borrowerTransaction.loanId === `${loan.poolId}-${loan.id}` + ) + const latestPrice = getLatestPrice(pricing.oracle.value, borrowerAssetTransactions, pool.currency.decimals) + return ( <> - - {pricing.maxBorrowAmount && ( - - )} ) } @@ -39,7 +46,7 @@ export function PricingValues({ loan: { pricing }, pool }: Props) { return ( <> {pricing.maturityDate && } - {pricing.maturityExtensionDays && ( + {'maturityExtensionDays' in pricing && ( )} {isOutstandingDebtOrDiscountedCashFlow && ( @@ -49,7 +56,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..313eca1808 --- /dev/null +++ b/centrifuge-app/src/pages/Loan/TransactionTable.tsx @@ -0,0 +1,167 @@ +import { BorrowerTransaction, CurrencyBalance, ExternalPricingInfo, PricingInfo } from '@centrifuge/centrifuge-js' +import { BorrowerTransactionType } from '@centrifuge/centrifuge-js/dist/types/subquery' +import { StatusChip, Tooltip } from '@centrifuge/fabric' +import BN from 'bn.js' +import { useMemo } from 'react' +import { DataTable } from '../../components/DataTable' +import { formatDate } from '../../utils/date' +import { Dec } from '../../utils/Decimal' +import { formatBalance } from '../../utils/formatting' + +type Props = { + transactions: BorrowerTransaction[] + currency: string + decimals: number + loanType: 'external' | 'internal' + pricing: PricingInfo +} + +export const TransactionTable = ({ transactions, currency, loanType, decimals, pricing }: 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, + amount: transaction.amount, + quantity: transaction.quantity ? new CurrencyBalance(transaction.quantity, 18) : null, + transactionDate: transaction.timestamp, + settlePrice: transaction.settlementPrice + ? new CurrencyBalance(new BN(transaction.settlementPrice), decimals) + : null, + faceFlow: + transaction.quantity && (pricing as ExternalPricingInfo).notional + ? new CurrencyBalance(transaction.quantity, 18) + .toDecimal() + .mul((pricing as ExternalPricingInfo).notional.toDecimal()) + : null, + position: array.slice(0, index + 1).reduce((sum, trx) => { + if (trx.type === 'BORROWED') { + sum = sum.add( + trx.quantity + ? new CurrencyBalance(trx.quantity, 18) + .toDecimal() + .mul((pricing as ExternalPricingInfo).notional.toDecimal()) + : trx.amount + ? trx.amount.toDecimal() + : Dec(0) + ) + } + if (trx.type === 'REPAID') { + sum = sum.sub( + trx.quantity + ? new CurrencyBalance(trx.quantity, 18) + .toDecimal() + .mul((pricing as ExternalPricingInfo).notional.toDecimal()) + : trx.amount + ? trx.amount.toDecimal() + : Dec(0) + ) + } + return sum + }, Dec(0)), + })) + }, [transactions, decimals, pricing]) + + const getStatusChipType = (type: BorrowerTransactionType) => { + if (type === 'BORROWED' || type === 'CREATED' || type === 'PRICED') return 'info' + if (type === 'REPAID') return 'ok' + return 'default' + } + + const getStatusText = (type: BorrowerTransactionType) => { + if (loanType === 'external' && type === 'BORROWED') return 'Purchase' + if (loanType === 'external' && type === 'REPAID') return 'Sale' + + if (loanType === 'internal' && type === 'BORROWED') return 'Financed' + if (loanType === 'internal' && type === 'REPAID') return 'Repaid' + + return `${type[0]}${type.slice(1).toLowerCase()}` + } + + return ( + ( + {getStatusText(row.type)} + ), + flex: '3', + }, + { + align: 'left', + header: 'Transaction date', + cell: (row) => ( + + {formatDate(row.transactionDate)} + + ), + flex: '3', + }, + + { + align: 'left', + header: 'Face flow', + cell: (row) => + row.faceFlow ? `${row.type === 'REPAID' ? '-' : ''}${formatBalance(row.faceFlow, currency, 2, 2)}` : '-', + flex: '3', + }, + { + align: 'left', + header: 'Quantity', + cell: (row) => (row.quantity ? formatBalance(row.quantity, undefined, 2, 0) : '-'), + flex: '2', + }, + { + align: 'left', + header: 'Settle price', + cell: (row) => (row.settlePrice ? formatBalance(row.settlePrice, currency, 6, 2) : '-'), + flex: '3', + }, + { + align: 'left', + header: 'Net cash flow', + cell: (row) => + row.amount ? `${row.type === 'BORROWED' ? '-' : ''}${formatBalance(row.amount, currency, 2, 2)}` : '-', + flex: '3', + }, + { + align: 'left', + header: 'Position', + cell: (row) => (row.type === 'CREATED' ? '-' : formatBalance(row.position, currency, 2, 2)), + 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..208d9d8629 100644 --- a/centrifuge-app/src/pages/Loan/index.tsx +++ b/centrifuge-app/src/pages/Loan/index.tsx @@ -1,10 +1,11 @@ -import { CurrencyBalance, Loan as LoanType, TinlakeLoan } from '@centrifuge/centrifuge-js' -import { useCentrifuge } from '@centrifuge/centrifuge-react' +import { CurrencyBalance, Loan as LoanType, Pool, PricingInfo, TinlakeLoan } from '@centrifuge/centrifuge-js' import { + AnchorButton, Box, Button, - IconNft, - InteractiveCard, + Flex, + IconChevronLeft, + IconExternalLink, Shelf, Stack, Text, @@ -12,41 +13,42 @@ 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' +import { AssetSummary } from '../../components/AssetSummary' import { LabelValueStack } from '../../components/LabelValueStack' -import LoanLabel from '../../components/LoanLabel' import { PageHeader } from '../../components/PageHeader' import { PageSection } from '../../components/PageSection' -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, isValidDate } 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 +58,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 +70,7 @@ const LoanSidebar: React.FC<{ showOraclePricing?: boolean }> = ({ showOraclePric return ( - {showOraclePricing && } + {showOraclePricing && } ) @@ -81,14 +86,21 @@ const Loan: React.FC<{ setShowOraclePricing?: () => void }> = ({ setShowOraclePr const nft = useCentNFT(loan?.asset.collectionId, loan?.asset.nftId, false) 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 currentFace = + loan?.pricing && 'outstandingQuantity' in loan.pricing + ? loan.pricing.outstandingQuantity.toDecimal().mul(loan.pricing.notional.toDecimal()) + : null + + 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) : '' const { data: templateData } = useMetadata( nftMetadata?.properties?._template && `ipfs://${nftMetadata?.properties?._template}` @@ -104,146 +116,198 @@ 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', - }, - ] - : []), - ] - } - /> + {loan && + pool && + (loan.pricing.maturityDate || templateMetadata?.keyAttributes?.length || 'oracle' in loan.pricing) && ( + <> + templateMetadata?.attributes?.[key].public) + .map((key) => ({ + label: templateMetadata?.attributes?.[key].label, + value: isValidDate(nftMetadata?.properties[key]) + ? formatDate(nftMetadata?.properties[key]) + : nftMetadata?.properties[key], + })) || []), + ...(loan.pricing.maturityDate + ? [ + { + label: 'Maturity date', + value: formatDate(loan.pricing.maturityDate), + }, + ] + : []), + ...[ + { + label: 'Current value', + value: `${formatBalance( + 'presentValue' in loan ? loan.presentValue : new CurrencyBalance(0, pool.currency.decimals), + pool.currency.symbol, + 2, + 2 + )}`, + }, + ], + ]} + /> - {(!isTinlakePool || (isTinlakePool && loan.status === 'Closed' && 'dateClosed' in loan)) && - 'valuationMethod' in loan.pricing && - loan.pricing.valuationMethod !== 'oracle' ? ( - - - {isTinlakePool && loan.status === 'Closed' && 'dateClosed' in loan ? ( - - ) : ( - Financing & repayment cash flow}> + + {isTinlakePool && loan.status === 'Closed' && 'dateClosed' in loan ? ( + + ) : ( + + )} + + + ) : null} + + {'valuationMethod' in loan.pricing && loan.pricing.valuationMethod === 'oracle' && ( + Holdings}> + + - )} - + + + )} + + Pricing}> + + + + + {canOraclePrice && + setShowOraclePricing && + loan.status !== 'Closed' && + 'valuationMethod' in loan.pricing && + loan.pricing.valuationMethod === 'oracle' && ( + + + + )} + - ) : null} - setShowOraclePricing()} small> - Update price - - ) - } - > - - - - - - )} - {(loan && nft) || loan?.poolId.startsWith('0x') ? ( + {loan.status === 'Active' && loan.pricing.maturityDate && ( + Remaining maturity}> + + + + + + + + {maturityPercentage !== 1 && ( + + + + )} + + + + + )} + + {borrowerAssetTransactions?.length ? ( + + Transaction history + + } + > + + + ) : null} + + )} + {(loan && nft) || isTinlakePool ? ( <> {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,8 +325,8 @@ const Loan: React.FC<{ setShowOraclePricing?: () => void }> = ({ setShowOraclePr ) })} - - {isTinlakePool && 'owner' in loan ? ( + {isTinlakePool && loan && 'owner' in loan ? ( + NFT}> } value={assetId} /> void }> = ({ setShowOraclePr } /> - ) : ( - } - title={{nftMetadata?.name}} - variant="button" - onClick={() => history.push(`/nfts/collection/${loan?.asset.collectionId}/object/${loan?.asset.nftId}`)} - secondaryHeader={ - - } value={assetId} /> - : ''} - /> - - } - > - {(nftMetadata?.description || imageUrl) && ( - - - {imageUrl ? ( - - ) : ( - - )} - - - - {nftMetadata?.description || 'No description'} - - } - /> - - {imageUrl && ( - - Source file - - } - /> - )} - - - )} - - )} - + + ) : null} ) : null} diff --git a/centrifuge-app/src/pages/Pool/Assets/index.tsx b/centrifuge-app/src/pages/Pool/Assets/index.tsx index 39eff769ee..cb961f4c9a 100644 --- a/centrifuge-app/src/pages/Pool/Assets/index.tsx +++ b/centrifuge-app/src/pages/Pool/Assets/index.tsx @@ -10,7 +10,7 @@ import { Tooltips } from '../../../components/Tooltips' import { Dec } from '../../../utils/Decimal' import { formatBalance, formatPercentage } from '../../../utils/formatting' import { useLoans } from '../../../utils/useLoans' -import { usePool } from '../../../utils/usePools' +import { useAverageAmount, usePool } from '../../../utils/usePools' import { PoolDetailHeader } from '../Header' import { PoolDetailSideBar } from '../Overview' @@ -29,6 +29,7 @@ export const PoolDetailAssets: React.FC = () => { const { pid: poolId } = useParams<{ pid: string }>() const pool = usePool(poolId) const loans = useLoans(poolId) + const averageAmount = useAverageAmount(poolId) if (!pool) return null @@ -52,10 +53,14 @@ export const PoolDetailAssets: React.FC = () => { .toFixed(2) .toString() - const avgAmount = ongoingAssets - .reduce((curr, prev) => curr.add(prev.outstandingDebt.toDecimal() || Dec(0)), Dec(0)) - .dividedBy(ongoingAssets.length) - .toDecimalPlaces(2) + const isExternal = 'valuationMethod' in loans[0].pricing && loans[0].pricing.valuationMethod === 'oracle' + + const avgAmount = isExternal + ? averageAmount + : ongoingAssets + .reduce((curr, prev) => curr.add(prev.outstandingDebt.toDecimal() || Dec(0)), Dec(0)) + .dividedBy(ongoingAssets.length) + .toDecimalPlaces(2) const assetValue = formatBalance(pool.nav.latest.toDecimal().toNumber(), pool.currency.symbol) @@ -65,8 +70,11 @@ export const PoolDetailAssets: React.FC = () => { value: assetValue, }, { label: , value: ongoingAssets.length || 0 }, - { label: , value: formatPercentage(avgInterestRatePerSec) }, - { label: , value: formatBalance(avgAmount, pool.currency.symbol) }, + { label: , value: formatPercentage(avgInterestRatePerSec) }, + { + label: , + value: formatBalance(avgAmount, pool.currency.symbol), + }, ] return ( diff --git a/centrifuge-app/src/utils/date.ts b/centrifuge-app/src/utils/date.ts index aa726ae9a0..ff949fe89f 100644 --- a/centrifuge-app/src/utils/date.ts +++ b/centrifuge-app/src/utils/date.ts @@ -45,3 +45,8 @@ export function millisecondsToDays(milliseconds: number): number { const days = milliseconds / (1000 * 60 * 60 * 24) return Math.round(days) } + +export function isValidDate(value: string) { + const date = new Date(value) + return !isNaN(date.getTime()) +} diff --git a/centrifuge-app/src/utils/getLatestPrice.ts b/centrifuge-app/src/utils/getLatestPrice.ts new file mode 100644 index 0000000000..8fc2aa54cf --- /dev/null +++ b/centrifuge-app/src/utils/getLatestPrice.ts @@ -0,0 +1,21 @@ +import { BorrowerTransaction, CurrencyBalance } from '@centrifuge/centrifuge-js' + +export const getLatestPrice = ( + oracleValue: CurrencyBalance, + borrowerAssetTransactions: BorrowerTransaction[] | undefined, + decimals: number +) => { + if (!borrowerAssetTransactions) return null + + const latestSettlementPrice = borrowerAssetTransactions[borrowerAssetTransactions.length - 1]?.settlementPrice + + if (latestSettlementPrice && oracleValue.isZero()) { + return new CurrencyBalance(latestSettlementPrice, decimals) + } + + if (oracleValue.isZero()) { + return null + } + + return new CurrencyBalance(oracleValue, 18) +} diff --git a/centrifuge-app/src/utils/usePools.ts b/centrifuge-app/src/utils/usePools.ts index 02394cba68..9f202b312a 100644 --- a/centrifuge-app/src/utils/usePools.ts +++ b/centrifuge-app/src/utils/usePools.ts @@ -1,9 +1,12 @@ -import Centrifuge, { Pool, PoolMetadata } from '@centrifuge/centrifuge-js' +import Centrifuge, { ActiveLoan, BorrowerTransaction, Pool, PoolMetadata } from '@centrifuge/centrifuge-js' import { useCentrifuge, useCentrifugeQuery, useWallet } from '@centrifuge/centrifuge-react' +import BN from 'bn.js' import { useEffect } from 'react' import { useQuery } from 'react-query' import { combineLatest, map, Observable } from 'rxjs' +import { Dec } from './Decimal' import { TinlakePool, useTinlakePools } from './tinlake/useTinlakePools' +import { useLoan, useLoans } from './useLoans' import { useMetadata } from './useMetadata' export function usePools(suspense = true) { @@ -79,6 +82,45 @@ export function useBorrowerTransactions(poolId: string, from?: Date, to?: Date) (cent) => cent.pools.getBorrowerTransactions([poolId, from, to]), { suspense: true, + enabled: !poolId.startsWith('0x'), + } + ) + + return result +} + +export function useAverageAmount(poolId: string) { + const pool = usePool(poolId) + const loans = useLoans(poolId) + + if (!loans?.length || !pool) return new BN(0) + + return loans + .reduce((sum, loan) => { + if (loan.status !== 'Active') return sum + return sum.add((loan as ActiveLoan).presentValue.toDecimal()) + }, Dec(0)) + .div(loans.filter((loan) => loan.status === 'Active').length) +} + +export function useBorrowerAssetTransactions(poolId: string, assetId: string, from?: Date, to?: Date) { + const pool = usePool(poolId) + const loan = useLoan(poolId, assetId) + + 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, + enabled: !!pool && !poolId.startsWith('0x') && !!loan, } ) diff --git a/centrifuge-app/src/utils/validation/index.ts b/centrifuge-app/src/utils/validation/index.ts index 71e1416e64..f4bc0b2205 100644 --- a/centrifuge-app/src/utils/validation/index.ts +++ b/centrifuge-app/src/utils/validation/index.ts @@ -1,5 +1,9 @@ +import { CurrencyBalance, ExternalPricingInfo } from '@centrifuge/centrifuge-js' import { isAddress } from '@polkadot/util-crypto' import Decimal from 'decimal.js-light' +import { daysBetween } from '../date' +import { Dec } from '../Decimal' +import { formatPercentage } from '../formatting' import { getImageDimensions } from '../getImageDimensions' const MB = 1024 ** 2 @@ -30,6 +34,32 @@ export const positiveNumber = (err?: CustomError) => (val?: any) => { return Number.isFinite(num) && num > 0 ? '' : getError(`Value must be positive`, err, num) } +export const settlementPrice = (err?: CustomError) => (val?: any) => { + if (val < 1) { + return getError('Value must be equal to or larger than 1', err, val) + } + + const regex = new RegExp(/^\d{1,3}(?:\.\d{1,6})?$/) + return regex.test(val) ? '' : getError('Value must be in the format of (1-3).(0-6) digits', err, val) +} + +export const maxPriceVariance = (pricing: ExternalPricingInfo, err?: CustomError) => (val?: any) => { + if (pricing.oracle.value.isZero()) return '' + + const maxVariation = new CurrencyBalance(pricing.oracle.value, 18) + .toDecimal() + .mul(pricing.maxPriceVariation.toDecimal()) + + if ( + Dec(val).gt(new CurrencyBalance(pricing.oracle.value, 18).toDecimal().add(maxVariation)) || + Dec(val).lt(new CurrencyBalance(pricing.oracle.value, 18).toDecimal().sub(maxVariation)) + ) { + return `Settlement price exceeds max price variation of ${formatPercentage(pricing.maxPriceVariation.toPercent())}` + } + + return '' +} + export const maxDecimals = (decimals: number, err?: CustomError) => (val?: any) => { const num = val instanceof Decimal ? val.toNumber() : val return Number.isFinite(num) && roundToFraction(num, decimals) === num @@ -128,13 +158,30 @@ export const isin = (err?: CustomError) => (val?: any) => { /(AD|AE|AF|AG|AI|AL|AM|AO|AQ|AR|AS|AT|AU|AW|AX|AZ|BA|BB|BD|BE|BF|BG|BH|BI|BJ|BL|BM|BN|BO|BQ|BR|BS|BT|BV|BW|BY|BZ|CA|CC|CD|CF|CG|CH|CI|CK|CL|CM|CN|CO|CR|CU|CV|CW|CX|CY|CZ|DE|DJ|DK|DM|DO|DZ|EC|EE|EG|EH|ER|ES|ET|FI|FJ|FK|FM|FO|FR|GA|GB|GD|GE|GF|GG|GH|GI|GL|GM|GN|GP|GQ|GR|GS|GT|GU|GW|GY|HK|HM|HN|HR|HT|HU|ID|IE|IL|IM|IN|IO|IQ|IR|IS|IT|JE|JM|JO|JP|KE|KG|KH|KI|KM|KN|KP|KR|KW|KY|KZ|LA|LB|LC|LI|LK|LR|LS|LT|LU|LV|LY|MA|MC|MD|ME|MF|MG|MH|MK|ML|MM|MN|MO|MP|MQ|MR|MS|MT|MU|MV|MW|MX|MY|MZ|NA|NC|NE|NF|NG|NI|NL|NO|NP|NR|NU|NZ|OM|PA|PE|PF|PG|PH|PK|PL|PM|PN|PR|PS|PT|PW|PY|QA|RE|RO|RS|RU|RW|SA|SB|SC|SD|SE|SG|SH|SI|SJ|SK|SL|SM|SN|SO|SR|SS|ST|SV|SX|SY|SZ|TC|TD|TF|TG|TH|TJ|TK|TL|TM|TN|TO|TR|TT|TV|TW|TZ|UA|UG|UM|US|UY|UZ|VA|VC|VE|VG|VI|VN|VU|WF|WS|YE|YT|ZA|ZM|ZW)([0-9A-Z]{9})([0-9])/gm const match = regex.exec(val.toString()) - console.log(match) if (match?.length !== 4) return getError(`Not a valid ISIN`, err, val) // validate the check digit 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 e830df38f8..85e290bbe0 100644 --- a/centrifuge-js/src/modules/pools.ts +++ b/centrifuge-js/src/modules/pools.ts @@ -86,9 +86,9 @@ type LoanInfoInput = | { valuationMethod: 'oracle' maxBorrowAmount: BN | null + maxPriceVariation: BN Isin: string maturityDate: Date - maturityExtensionDays: number interestRate: BN notional: BN } @@ -127,6 +127,7 @@ export type LoanInfoData = { } maxBorrowAmount: { noLimit: null } | { quantity: string } notional: string + maxPriceVariation: string } } | { @@ -176,6 +177,7 @@ export type ActiveLoanInfoData = { } maxBorrowAmount: { noLimit: null } | { quantity: string } notional: string + maxPriceVariation: string } outstandingQuantity: string interest: { @@ -337,12 +339,6 @@ export enum LoanStatus { // type LoanStatus = 'Created' | 'Active' | 'Closed' -type InterestAccrual = { - interestRatePerSec: string - accumulatedRate: string - referenceCount: number -} - // type from chain type CreatedLoanData = { borrower: string @@ -389,6 +385,7 @@ export type InternalPricingInfo = { export type ExternalPricingInfo = { valuationMethod: 'oracle' maxBorrowAmount: CurrencyBalance | null + maxPriceVariation: Rate outstandingQuantity: CurrencyBalance Isin: string maturityDate: string @@ -397,6 +394,8 @@ export type ExternalPricingInfo = { value: CurrencyBalance timestamp: number } + notional: CurrencyBalance + interestRate: Rate } type TinlakePricingInfo = { @@ -468,6 +467,7 @@ export type ActiveLoan = { outstandingDebt: CurrencyBalance outstandingPrincipal: CurrencyBalance outstandingInterest: CurrencyBalance + presentValue: CurrencyBalance } // transformed type for UI @@ -671,7 +671,7 @@ type InvestorTransaction = { transactionFee: CurrencyBalance | null } -type BorrowerTransaction = { +export type BorrowerTransaction = { id: string timestamp: string poolId: string @@ -680,6 +680,12 @@ type BorrowerTransaction = { loanId: string type: BorrowerTransactionType amount: CurrencyBalance | undefined + settlementPrice: string | null + quantity: string | null +} + +export type ExternalLoan = Loan & { + pricing: ExternalPricingInfo } export type Permissions = { @@ -1318,7 +1324,7 @@ export function getPoolsModule(inst: Centrifuge) { maturity: { fixed: { date: Math.round(infoInput.maturityDate.getTime() / 1000), - extension: infoInput.maturityExtensionDays * SEC_PER_DAY, + extension: 'maturityExtensionDays' in infoInput ? infoInput.maturityExtensionDays * SEC_PER_DAY : 0, }, }, interestPayments: 'None', @@ -1342,6 +1348,7 @@ export function getPoolsModule(inst: Centrifuge) { infoInput.maxBorrowAmount === null ? { noLimit: null } : { quantity: infoInput.maxBorrowAmount.toString() }, + maxPriceVariation: infoInput.maxPriceVariation!.toString(), notional: infoInput.notional.toString(), }, } @@ -1381,23 +1388,17 @@ export function getPoolsModule(inst: Centrifuge) { } function financeExternalLoan( - args: [poolId: string, loanId: string, quantity: BN, price: BN, isin: string, aoProxy: string], + args: [poolId: string, loanId: string, quantity: BN, price: BN], options?: TransactionOptions ) { - const [poolId, loanId, quantity, price, isin, aoProxy] = args + const [poolId, loanId, quantity, price] = args const $api = inst.getApi() return $api.pipe( switchMap((api) => { - const borrowSubmittable = api.tx.proxy.proxy( - aoProxy, - undefined, - api.tx.loans.borrow(poolId, loanId, { - external: { quantity: quantity.toString(), settlementPrice: price.toString() }, - }) - ) - const oracleFeedSubmittable = api.tx.priceOracle.feedValues([[{ Isin: isin }, price]]) - const batchSubmittable = api.tx.utility.batchAll([oracleFeedSubmittable, borrowSubmittable]) - return inst.wrapSignAndSend(api, batchSubmittable, options) + const borrowSubmittable = api.tx.loans.borrow(poolId, loanId, { + external: { quantity: quantity.toString(), settlementPrice: price.toString() }, + }) + return inst.wrapSignAndSend(api, borrowSubmittable, options) }) ) } @@ -1433,35 +1434,20 @@ export function getPoolsModule(inst: Centrifuge) { } function repayExternalLoanPartially( - args: [ - poolId: string, - loanId: string, - quantity: BN, - interest: BN, - unscheduled: BN, - price: BN, - isin: string, - aoProxy: string - ], + args: [poolId: string, loanId: string, quantity: BN, interest: BN, unscheduled: BN, price: BN], options?: TransactionOptions ) { - const [poolId, loanId, quantity, interest, unscheduled, price, isin, aoProxy] = args + const [poolId, loanId, quantity, interest, unscheduled, price] = args const $api = inst.getApi() return $api.pipe( switchMap((api) => { - const repaySubmittable = api.tx.proxy.proxy( - aoProxy, - undefined, - api.tx.loans.repay(poolId, loanId, { - principal: { external: { quantity: quantity.toString(), settlementPrice: price.toString() } }, - interest: interest.toString(), - unscheduled: unscheduled.toString(), - }) - ) - const oracleFeedSubmittable = api.tx.priceOracle.feedValues([[{ Isin: isin }, price]]) - const batchSubmittable = api.tx.utility.batchAll([oracleFeedSubmittable, repaySubmittable]) - return inst.wrapSignAndSend(api, batchSubmittable, options) + const repaySubmittable = api.tx.loans.repay(poolId, loanId, { + principal: { external: { quantity: quantity.toString(), settlementPrice: price.toString() } }, + interest: interest.toString(), + unscheduled: unscheduled.toString(), + }) + return inst.wrapSignAndSend(api, repaySubmittable, options) }) ) } @@ -2248,7 +2234,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 }, }) { @@ -2258,6 +2244,8 @@ export function getPoolsModule(inst: Centrifuge) { type timestamp amount + settlementPrice + quantity } } } @@ -2276,7 +2264,7 @@ export function getPoolsModule(inst: Centrifuge) { return data!.borrowerTransactions.nodes.map((tx) => ({ ...tx, amount: tx.amount ? new CurrencyBalance(tx.amount, currency.decimals) : undefined, - timestamp: new Date(tx.timestamp), + timestamp: new Date(`${tx.timestamp}+00:00`), })) as unknown as BorrowerTransaction[] }) ) @@ -2517,229 +2505,204 @@ export function getPoolsModule(inst: Centrifuge) { api.query.loans.activeLoans(poolId), api.query.loans.closedLoan.entries(poolId), api.query.priceOracle.values.entries(), - api.query.interestAccrual.rates(), - api.query.interestAccrual.lastUpdated(), api.query.ormlAssetRegistry.metadata((poolValue.toPrimitive() as any).currency), + api.call.loansApi.portfolio(poolId), // TODO: remove loans.activeLoans and use values from this runtime call ]).pipe(take(1)) }), - map( - ([ - createdLoanValues, - activeLoanValues, - closedLoanValues, - oracles, - rateValues, - interestLastUpdated, - rawCurrency, - ]) => { - const currency = rawCurrency.toPrimitive() as AssetCurrencyData - const rates = rateValues.toPrimitive() as InterestAccrual[] - - const oraclePrices: Record< - string, - { - timestamp: number - value: CurrencyBalance - } - > = {} - oracles.forEach((oracle) => { - const { timestamp, value } = oracle[1].toPrimitive() as any - oraclePrices[(oracle[0].toHuman() as any)[0].Isin] = { - timestamp, - value: new CurrencyBalance(value, currency.decimals), - } - }) + map(([createdLoanValues, activeLoanValues, closedLoanValues, oracles, rawCurrency, rawPortfolio]) => { + const currency = rawCurrency.toPrimitive() as AssetCurrencyData + + const oraclePrices: Record< + string, + { + timestamp: number + value: CurrencyBalance + } + > = {} + oracles.forEach((oracle) => { + const { timestamp, value } = oracle[1].toPrimitive() as any + oraclePrices[(oracle[0].toHuman() as any)[0].Isin] = { + timestamp, + value: new CurrencyBalance(value, currency.decimals), + } + }) - function getSharedLoanInfo(loan: CreatedLoanData | ActiveLoanData | ClosedLoanData) { - const info = 'info' in loan ? loan.info : loan - const [collectionId, nftId] = info.collateral - - // Active loans have additinal info layer - const pricingInfo = - 'info' in loan - ? 'external' in loan.info.pricing - ? loan.info.pricing.external - : loan.info.pricing.internal - : 'external' in loan.pricing - ? loan.pricing.external.info - : loan.pricing.internal.info - - const interestRate = - 'info' in loan - ? loan.info.interestRate.fixed.ratePerYear - : 'external' in loan.pricing - ? loan.pricing.external.interest.interestRate.fixed.ratePerYear - : loan.pricing.internal.interest.interestRate.fixed.ratePerYear - - const discount = - 'valuationMethod' in pricingInfo && 'discountedCashFlow' in pricingInfo.valuationMethod - ? pricingInfo.valuationMethod.discountedCashFlow - : undefined - return { - asset: { - collectionId: collectionId.toString(), - nftId: nftId.toString(), - }, - pricing: - 'priceId' in pricingInfo - ? { - valuationMethod: 'oracle' as any, - // If the max borrow quantity is larger than 10k, this is assumed to be "limitless" - // TODO: replace by Option once data structure on chain changes - maxBorrowAmount: - 'noLimit' in pricingInfo.maxBorrowAmount - ? null - : new CurrencyBalance(pricingInfo.maxBorrowAmount.quantity, 27), - Isin: pricingInfo.priceId.isin, - maturityDate: new Date(info.schedule.maturity.fixed.date * 1000).toISOString(), - maturityExtensionDays: info.schedule.maturity.fixed.extension / SEC_PER_DAY, - oracle: oraclePrices[pricingInfo.priceId.isin] || { - value: new CurrencyBalance(0, currency.decimals), - timestamp: 0, - }, - outstandingQuantity: - 'external' in info.pricing && 'outstandingQuantity' in info.pricing.external - ? new CurrencyBalance(info.pricing.external.outstandingQuantity, 27) // TODO: Will be 18 after next chain update - : new CurrencyBalance(0, 27), - interestRate: new Rate(interestRate), - notional: new CurrencyBalance(pricingInfo.notional, currency.decimals), - } - : { - valuationMethod: ('outstandingDebt' in pricingInfo.valuationMethod - ? 'outstandingDebt' - : 'discountedCashFlow') as any, - maxBorrowAmount: Object.keys(pricingInfo.maxBorrowAmount)[0] as any, - value: new CurrencyBalance(pricingInfo.collateralValue, currency.decimals), - advanceRate: new Rate(Object.values(pricingInfo.maxBorrowAmount)[0].advanceRate), - probabilityOfDefault: discount?.probabilityOfDefault - ? new Rate(discount.probabilityOfDefault) - : undefined, - lossGivenDefault: discount?.lossGivenDefault ? new Rate(discount.lossGivenDefault) : undefined, - discountRate: discount?.discountRate - ? new Rate(discount.discountRate.fixed.ratePerYear) - : undefined, - interestRate: new Rate(interestRate), - maturityDate: new Date(info.schedule.maturity.fixed.date * 1000).toISOString(), - maturityExtensionDays: info.schedule.maturity.fixed.extension / SEC_PER_DAY, - }, - } + const activeLoansPortfolio: Record< + string, + { + presentValue: CurrencyBalance + outstandingPrincipal: CurrencyBalance + outstandingInterest: CurrencyBalance + } + > = {} + + ;(rawPortfolio as any).forEach(([key, value]: [Codec, Codec]) => { + const data = value.toPrimitive() as any + activeLoansPortfolio[String(key.toPrimitive())] = { + presentValue: new CurrencyBalance(data.presentValue, currency.decimals), + outstandingPrincipal: new CurrencyBalance(data.outstandingPrincipal, currency.decimals), + outstandingInterest: new CurrencyBalance(data.outstandingInterest, currency.decimals), } + }) - const createdLoans: CreatedLoan[] = (createdLoanValues as any[]).map(([key, value]) => { - const loan = value.toPrimitive() as unknown as CreatedLoanData - const nil = new CurrencyBalance(0, currency.decimals) - return { - ...getSharedLoanInfo(loan), - id: formatLoanKey(key as StorageKey<[u32, u32]>), - poolId, - status: 'Created', - borrower: addressToHex(loan.borrower), - totalBorrowed: nil, - totalRepaid: nil, - outstandingDebt: nil, - normalizedDebt: nil, - } - }) + function getSharedLoanInfo(loan: CreatedLoanData | ActiveLoanData | ClosedLoanData) { + const info = 'info' in loan ? loan.info : loan + const [collectionId, nftId] = info.collateral + + // Active loans have additinal info layer + const pricingInfo = + 'info' in loan + ? 'external' in loan.info.pricing + ? loan.info.pricing.external + : loan.info.pricing.internal + : 'external' in loan.pricing + ? loan.pricing.external.info + : loan.pricing.internal.info + + const interestRate = + 'info' in loan + ? loan.info.interestRate.fixed.ratePerYear + : 'external' in loan.pricing + ? loan.pricing.external.interest.interestRate.fixed.ratePerYear + : loan.pricing.internal.interest.interestRate.fixed.ratePerYear + + const discount = + 'valuationMethod' in pricingInfo && 'discountedCashFlow' in pricingInfo.valuationMethod + ? pricingInfo.valuationMethod.discountedCashFlow + : undefined + return { + asset: { + collectionId: collectionId.toString(), + nftId: nftId.toString(), + }, + pricing: + 'priceId' in pricingInfo + ? { + valuationMethod: 'oracle' as any, + // If the max borrow quantity is larger than 10k, this is assumed to be "limitless" + // TODO: replace by Option once data structure on chain changes + maxBorrowAmount: + 'noLimit' in pricingInfo.maxBorrowAmount + ? null + : new CurrencyBalance(pricingInfo.maxBorrowAmount.quantity, 18), + Isin: pricingInfo.priceId.isin, + maturityDate: new Date(info.schedule.maturity.fixed.date * 1000).toISOString(), + maturityExtensionDays: info.schedule.maturity.fixed.extension / SEC_PER_DAY, + oracle: oraclePrices[pricingInfo.priceId.isin] || { + value: new CurrencyBalance(0, currency.decimals), + timestamp: 0, + }, + outstandingQuantity: + 'external' in info.pricing && 'outstandingQuantity' in info.pricing.external + ? new CurrencyBalance(info.pricing.external.outstandingQuantity, 18) + : new CurrencyBalance(0, 18), + interestRate: new Rate(interestRate), + notional: new CurrencyBalance(pricingInfo.notional, currency.decimals), + maxPriceVariation: new Rate(pricingInfo.maxPriceVariation), + } + : { + valuationMethod: ('outstandingDebt' in pricingInfo.valuationMethod + ? 'outstandingDebt' + : 'discountedCashFlow') as any, + maxBorrowAmount: Object.keys(pricingInfo.maxBorrowAmount)[0] as any, + value: new CurrencyBalance(pricingInfo.collateralValue, currency.decimals), + advanceRate: new Rate(Object.values(pricingInfo.maxBorrowAmount)[0].advanceRate), + probabilityOfDefault: discount?.probabilityOfDefault + ? new Rate(discount.probabilityOfDefault) + : undefined, + lossGivenDefault: discount?.lossGivenDefault ? new Rate(discount.lossGivenDefault) : undefined, + discountRate: discount?.discountRate + ? new Rate(discount.discountRate.fixed.ratePerYear) + : undefined, + interestRate: new Rate(interestRate), + maturityDate: new Date(info.schedule.maturity.fixed.date * 1000).toISOString(), + maturityExtensionDays: info.schedule.maturity.fixed.extension / SEC_PER_DAY, + }, + } + } - const activeLoans: ActiveLoan[] = (activeLoanValues.toPrimitive() as any[]).map( - ([loanId, loan]: [number, ActiveLoanData]) => { - const sharedInfo = getSharedLoanInfo(loan) - const interestData = rates.find( - (rate) => - new Rate(rate.interestRatePerSec).toApr().toDecimalPlaces(4).toString() === - sharedInfo.pricing.interestRate.toDecimal().toString() - ) - const penaltyRate = - 'external' in loan.pricing - ? loan.pricing.external.interest.penalty - : loan.pricing.internal.interest.penalty - const normalizedDebt = - 'external' in loan.pricing - ? loan.pricing.external.interest.normalizedAcc - : loan.pricing.internal.interest.normalizedAcc - - const writeOffStatus = { - penaltyInterestRate: new Rate(penaltyRate), - percentage: new Rate(loan.writeOffPercentage), - } + const createdLoans: CreatedLoan[] = (createdLoanValues as any[]).map(([key, value]) => { + const loan = value.toPrimitive() as unknown as CreatedLoanData + const nil = new CurrencyBalance(0, currency.decimals) + return { + ...getSharedLoanInfo(loan), + id: formatLoanKey(key as StorageKey<[u32, u32]>), + poolId, + status: 'Created', + borrower: addressToHex(loan.borrower), + totalBorrowed: nil, + totalRepaid: nil, + outstandingDebt: nil, + normalizedDebt: nil, + } + }) - const repaidPrincipal = new CurrencyBalance(loan.totalRepaid.principal, currency.decimals) - const repaidInterest = new CurrencyBalance(loan.totalRepaid.interest, currency.decimals) - const repaidUnscheduled = new CurrencyBalance(loan.totalRepaid.unscheduled, currency.decimals) - const outstandingDebt = getOutstandingDebt( - loan, - currency.decimals, - interestLastUpdated.toPrimitive() as number, - interestData - ) - let outstandingPrincipal: CurrencyBalance - let outstandingInterest: CurrencyBalance - if ('internal' in loan.pricing) { - outstandingPrincipal = new CurrencyBalance( - new BN(loan.totalBorrowed).sub(repaidPrincipal), - currency.decimals - ) - outstandingInterest = new CurrencyBalance(outstandingDebt.sub(outstandingPrincipal), currency.decimals) - } else { - const quantity = new CurrencyBalance(loan.pricing.external.outstandingQuantity, 27).toDecimal() - outstandingPrincipal = CurrencyBalance.fromFloat( - quantity.mul( - new CurrencyBalance(sharedInfo.pricing.oracle?.value ?? new BN(0), currency.decimals).toDecimal() - ), - currency.decimals - ) - outstandingInterest = CurrencyBalance.fromFloat( - outstandingDebt - .toDecimal() - .sub( - quantity.mul( - new CurrencyBalance(loan.pricing.external.info.notional, currency.decimals).toDecimal() - ) - ), - currency.decimals - ) - } - return { - ...sharedInfo, - id: loanId.toString(), - poolId, - status: 'Active', - borrower: addressToHex(loan.borrower), - writeOffStatus: writeOffStatus.percentage.isZero() ? undefined : writeOffStatus, - totalBorrowed: new CurrencyBalance(loan.totalBorrowed, currency.decimals), - totalRepaid: new CurrencyBalance( - repaidPrincipal.add(repaidInterest).add(repaidUnscheduled), - currency.decimals - ), - repaid: { - principal: repaidPrincipal, - interest: repaidInterest, - unscheduled: repaidUnscheduled, - }, - originationDate: new Date(loan.originationDate * 1000).toISOString(), - outstandingDebt, - normalizedDebt: new CurrencyBalance(normalizedDebt, currency.decimals), - outstandingPrincipal, - outstandingInterest, - } + const activeLoans: ActiveLoan[] = (activeLoanValues.toPrimitive() as any[]).map( + ([loanId, loan]: [number, ActiveLoanData]) => { + const sharedInfo = getSharedLoanInfo(loan) + const portfolio = activeLoansPortfolio[loanId.toString()] + const penaltyRate = + 'external' in loan.pricing + ? loan.pricing.external.interest.penalty + : loan.pricing.internal.interest.penalty + const normalizedDebt = + 'external' in loan.pricing + ? loan.pricing.external.interest.normalizedAcc + : loan.pricing.internal.interest.normalizedAcc + + const writeOffStatus = { + penaltyInterestRate: new Rate(penaltyRate), + percentage: new Rate(loan.writeOffPercentage), } - ) - const closedLoans: ClosedLoan[] = (closedLoanValues as any[]).map(([key, value]) => { - const loan = value.toPrimitive() as unknown as ClosedLoanData + const repaidPrincipal = new CurrencyBalance(loan.totalRepaid.principal, currency.decimals) + const repaidInterest = new CurrencyBalance(loan.totalRepaid.interest, currency.decimals) + const repaidUnscheduled = new CurrencyBalance(loan.totalRepaid.unscheduled, currency.decimals) + const outstandingDebt = new CurrencyBalance( + portfolio.outstandingInterest.add(portfolio.outstandingPrincipal), + currency.decimals + ) return { - ...getSharedLoanInfo(loan), - id: formatLoanKey(key as StorageKey<[u32, u32]>), + ...sharedInfo, + id: loanId.toString(), poolId, - status: 'Closed', + status: 'Active', + borrower: addressToHex(loan.borrower), + writeOffStatus: writeOffStatus.percentage.isZero() ? undefined : writeOffStatus, totalBorrowed: new CurrencyBalance(loan.totalBorrowed, currency.decimals), - totalRepaid: new CurrencyBalance(loan.totalRepaid, currency.decimals), + totalRepaid: new CurrencyBalance( + repaidPrincipal.add(repaidInterest).add(repaidUnscheduled), + currency.decimals + ), + repaid: { + principal: repaidPrincipal, + interest: repaidInterest, + unscheduled: repaidUnscheduled, + }, + originationDate: new Date(loan.originationDate * 1000).toISOString(), + outstandingDebt, + normalizedDebt: new CurrencyBalance(normalizedDebt, currency.decimals), + outstandingPrincipal: portfolio.outstandingPrincipal, + outstandingInterest: portfolio.outstandingInterest, + presentValue: portfolio.presentValue, } - }) + } + ) - return [...createdLoans, ...activeLoans, ...closedLoans] as Loan[] - } - ), + const closedLoans: ClosedLoan[] = (closedLoanValues as any[]).map(([key, value]) => { + const loan = value.toPrimitive() as unknown as ClosedLoanData + return { + ...getSharedLoanInfo(loan), + id: formatLoanKey(key as StorageKey<[u32, u32]>), + poolId, + status: 'Closed', + totalBorrowed: new CurrencyBalance(loan.totalBorrowed, currency.decimals), + totalRepaid: new CurrencyBalance(loan.totalRepaid, currency.decimals), + } + }) + + return [...createdLoans, ...activeLoans, ...closedLoans] as Loan[] + }), repeatWhen(() => $events) ) } @@ -3062,30 +3025,6 @@ function hexToBN(value: string | number) { return new BN(value.toString().substring(2), 'hex') } -function getOutstandingDebt( - loan: ActiveLoanData, - currencyDecimals: number, - lastUpdated: number, - accrual?: InterestAccrual -) { - if (!accrual) return new CurrencyBalance(0, currencyDecimals) - const accRate = new Rate(accrual.accumulatedRate).toDecimal() - const rate = new Rate(accrual.interestRatePerSec).toDecimal() - const balance = - 'internal' in loan.pricing - ? loan.pricing.internal.interest.normalizedAcc - : loan.pricing.external.interest.normalizedAcc - - const normalizedDebt = new CurrencyBalance(balance, currencyDecimals).toDecimal() - const secondsSinceUpdated = Date.now() / 1000 - lastUpdated - - const debtFromAccRate = normalizedDebt.mul(accRate) - const debtSinceUpdated = normalizedDebt.mul(rate.minus(1).mul(secondsSinceUpdated)) - const debt = debtFromAccRate.add(debtSinceUpdated) - - return CurrencyBalance.fromFloat(debt, currencyDecimals) -} - function getEpochStatus(epochExecution: Pick, blockNumber: number) { if (!!epochExecution && !epochExecution?.challengePeriodEnd) { return 'submissionPeriod' diff --git a/centrifuge-js/src/types/subquery.ts b/centrifuge-js/src/types/subquery.ts index 9f67a8b96a..9015048c0b 100644 --- a/centrifuge-js/src/types/subquery.ts +++ b/centrifuge-js/src/types/subquery.ts @@ -80,6 +80,8 @@ export type SubqueryBorrowerTransaction = { loanId: string type: BorrowerTransactionType amount?: number | null + settlementPrice: string | null + quantity: string | null } export type SubqueryEpoch = {