From b17f6c545780c44fa050efafb78ee2840d08602f Mon Sep 17 00:00:00 2001 From: Sophia <littlejohn.sophia@gmail.com> Date: Fri, 18 Aug 2023 15:52:43 -0400 Subject: [PATCH 01/39] Log error instead of throwing (#1540) --- .../src/utils/tinlake/useTinlakePools.ts | 106 +++++++++--------- 1 file changed, 55 insertions(+), 51 deletions(-) diff --git a/centrifuge-app/src/utils/tinlake/useTinlakePools.ts b/centrifuge-app/src/utils/tinlake/useTinlakePools.ts index 50c924761c..e0e994d3c4 100644 --- a/centrifuge-app/src/utils/tinlake/useTinlakePools.ts +++ b/centrifuge-app/src/utils/tinlake/useTinlakePools.ts @@ -583,64 +583,68 @@ async function getPools(pools: IpfsPools): Promise<{ pools: TinlakePool[] }> { const capacityGivenMaxReservePerPool: { [key: string]: BN } = {} const capacityGivenMaxDropRatioPerPool: { [key: string]: BN } = {} Object.keys(multicallData).forEach((poolId: string) => { - const state = multicallData[poolId] - - // Investments will reduce the creditline and therefore reduce the senior debt - const newUsedCreditline = state.unusedCreditline - ? BN.max( - new BN(0), - (state.usedCreditline || new BN(0)) - .sub(state.pendingSeniorInvestments) - .sub(state.pendingJuniorInvestments) - .add(state.pendingSeniorRedemptions) - .add(state.pendingJuniorRedemptions) - ) - : new BN(0) - - const newUnusedCreditline = state.unusedCreditline ? state.availableCreditline?.sub(newUsedCreditline) : new BN(0) - - const newReserve = BN.max( - new BN(0), - state.reserve - .add(state.pendingSeniorInvestments) - .add(state.pendingJuniorInvestments) - .sub(state.pendingSeniorRedemptions) - .sub(state.pendingJuniorRedemptions) - .sub(newUsedCreditline) - ) + try { + const state = multicallData[poolId] + + // Investments will reduce the creditline and therefore reduce the senior debt + const newUsedCreditline = state.unusedCreditline + ? BN.max( + new BN(0), + (state.usedCreditline || new BN(0)) + .sub(state.pendingSeniorInvestments) + .sub(state.pendingJuniorInvestments) + .add(state.pendingSeniorRedemptions) + .add(state.pendingJuniorRedemptions) + ) + : new BN(0) + + const newUnusedCreditline = state.unusedCreditline ? state.availableCreditline?.sub(newUsedCreditline) : new BN(0) + + const newReserve = BN.max( + new BN(0), + state.reserve + .add(state.pendingSeniorInvestments) + .add(state.pendingJuniorInvestments) + .sub(state.pendingSeniorRedemptions) + .sub(state.pendingJuniorRedemptions) + .sub(newUsedCreditline) + ) - const capacityGivenMaxReserve = BN.max( - new BN(0), - state.maxReserve.sub(newReserve).sub(newUnusedCreditline || new BN(0)) - ) + const capacityGivenMaxReserve = BN.max( + new BN(0), + state.maxReserve.sub(newReserve).sub(newUnusedCreditline || new BN(0)) + ) - // senior debt is reduced by any increase in the used creditline or increased by any decrease in the used creditline - const newSeniorDebt = (state.usedCreditline || new BN(0)).gt(newUsedCreditline) - ? state.seniorDebt.sub((state.usedCreditline || new BN(0)).sub(newUsedCreditline)) - : state.seniorDebt.add(newUsedCreditline.sub(state.usedCreditline || new BN(0))) + // senior debt is reduced by any increase in the used creditline or increased by any decrease in the used creditline + const newSeniorDebt = (state.usedCreditline || new BN(0)).gt(newUsedCreditline) + ? state.seniorDebt.sub((state.usedCreditline || new BN(0)).sub(newUsedCreditline)) + : state.seniorDebt.add(newUsedCreditline.sub(state.usedCreditline || new BN(0))) - // TODO: the change in senior balance should be multiplied by the mat here - const newSeniorBalance = (state.usedCreditline || new BN(0)).gt(newUsedCreditline) - ? state.seniorBalance.sub((state.usedCreditline || new BN(0)).sub(newUsedCreditline)) - : state.seniorBalance.add(newUsedCreditline.sub(state.usedCreditline || new BN(0))) + // TODO: the change in senior balance should be multiplied by the mat here + const newSeniorBalance = (state.usedCreditline || new BN(0)).gt(newUsedCreditline) + ? state.seniorBalance.sub((state.usedCreditline || new BN(0)).sub(newUsedCreditline)) + : state.seniorBalance.add(newUsedCreditline.sub(state.usedCreditline || new BN(0))) - const newSeniorAsset = newSeniorDebt - .add(newSeniorBalance) - .add(state.pendingSeniorInvestments) - .sub(state.pendingSeniorRedemptions) + const newSeniorAsset = newSeniorDebt + .add(newSeniorBalance) + .add(state.pendingSeniorInvestments) + .sub(state.pendingSeniorRedemptions) - const newJuniorAsset = state.netAssetValue.add(newReserve).sub(newSeniorAsset) - const maxPoolSize = newJuniorAsset - .mul(Fixed27Base.mul(new BN(10).pow(new BN(6))).div(Fixed27Base.sub(state.maxSeniorRatio))) - .div(new BN(10).pow(new BN(6))) + const newJuniorAsset = state.netAssetValue.add(newReserve).sub(newSeniorAsset) + const maxPoolSize = newJuniorAsset + .mul(Fixed27Base.mul(new BN(10).pow(new BN(6))).div(Fixed27Base.sub(state?.maxSeniorRatio))) + .div(new BN(10).pow(new BN(6))) - const maxSeniorAsset = maxPoolSize.sub(newJuniorAsset) + const maxSeniorAsset = maxPoolSize.sub(newJuniorAsset) - const capacityGivenMaxDropRatio = BN.max(new BN(0), maxSeniorAsset.sub(newSeniorAsset)) + const capacityGivenMaxDropRatio = BN.max(new BN(0), maxSeniorAsset.sub(newSeniorAsset)) - capacityPerPool[poolId] = BN.min(capacityGivenMaxReserve, capacityGivenMaxDropRatio) - capacityGivenMaxReservePerPool[poolId] = capacityGivenMaxReserve - capacityGivenMaxDropRatioPerPool[poolId] = capacityGivenMaxDropRatio + capacityPerPool[poolId] = BN.min(capacityGivenMaxReserve, capacityGivenMaxDropRatio) + capacityGivenMaxReservePerPool[poolId] = capacityGivenMaxReserve + capacityGivenMaxDropRatioPerPool[poolId] = capacityGivenMaxDropRatio + } catch (e) { + console.error(e) + } }) const combined = pools.active.map((p) => { @@ -788,7 +792,7 @@ async function getPools(pools: IpfsPools): Promise<{ pools: TinlakePool[] }> { id: `${id}-1`, seniority: 1, balance: data.seniorBalance, - minRiskBuffer: Rate.fromFloat(Dec(1).sub(data.maxSeniorRatio.toDecimal())), + minRiskBuffer: Rate.fromFloat(Dec(1).sub(data.maxSeniorRatio.toDecimal() || Dec(0))), currentRiskBuffer: Rate.fromFloat(Dec(1).sub(data.seniorRatio.toDecimal())), interestRatePerSec: data.seniorInterestRate, lastUpdatedInterest: new Date().toISOString(), From 5aa1b52a880ab586574ba78ced94076ed9f2a46f Mon Sep 17 00:00:00 2001 From: Hornebom <you@hornebom.com> Date: Mon, 21 Aug 2023 14:55:27 +0200 Subject: [PATCH 02/39] Verified | Reporting tab (#1520) * verified commit * refactor: Add Reporting tab to investor view and dry out into PoolReportPage * add missing dependencies * add missing dependencies * move headers and columns outside of component * add missing dependencies * disable eslint * missing dependencies * refactor: Rename to ReportContextType * remove not used startDate * eslint disable --- centrifuge-app/src/components/DataTable.tsx | 14 +- .../src/components/DataTableGroup.tsx | 10 +- centrifuge-app/src/components/Report.tsx | 355 ------------------ .../src/components/Report/AssetList.tsx | 114 ++++++ .../Report/BorrowerTransactions.tsx | 83 ++++ .../Report/InvestorTransactions.tsx | 118 ++++++ .../src/components/Report/PoolBalance.tsx | 176 +++++++++ .../src/components/Report/PoolReportPage.tsx | 41 ++ .../src/components/Report/ReportContext.tsx | 96 +++++ .../src/components/Report/ReportFilter.tsx | 138 +++++++ .../src/components/Report/UserFeedback.tsx | 18 + .../src/components/Report/index.tsx | 42 +++ centrifuge-app/src/components/Report/utils.ts | 62 +++ centrifuge-app/src/components/Spinner.tsx | 11 +- .../src/pages/IssuerPool/Header.tsx | 2 + .../src/pages/IssuerPool/Reporting/index.tsx | 7 + centrifuge-app/src/pages/IssuerPool/index.tsx | 2 + centrifuge-app/src/pages/Pool/Header.tsx | 4 +- .../src/pages/Pool/Reporting/index.tsx | 179 +-------- .../src/utils/useElementScrollSize.ts | 42 +++ centrifuge-app/src/utils/usePools.ts | 12 + centrifuge-js/src/modules/pools.ts | 85 ++++- centrifuge-js/src/types/subquery.ts | 16 + centrifuge-js/src/utils/index.ts | 5 + .../DateRange/DateRange.stories.tsx | 35 ++ fabric/src/components/DateRange/index.tsx | 71 ++++ fabric/src/components/InputBox/index.tsx | 1 + fabric/src/index.ts | 1 + 28 files changed, 1192 insertions(+), 548 deletions(-) delete mode 100644 centrifuge-app/src/components/Report.tsx create mode 100644 centrifuge-app/src/components/Report/AssetList.tsx create mode 100644 centrifuge-app/src/components/Report/BorrowerTransactions.tsx create mode 100644 centrifuge-app/src/components/Report/InvestorTransactions.tsx create mode 100644 centrifuge-app/src/components/Report/PoolBalance.tsx create mode 100644 centrifuge-app/src/components/Report/PoolReportPage.tsx create mode 100644 centrifuge-app/src/components/Report/ReportContext.tsx create mode 100644 centrifuge-app/src/components/Report/ReportFilter.tsx create mode 100644 centrifuge-app/src/components/Report/UserFeedback.tsx create mode 100644 centrifuge-app/src/components/Report/index.tsx create mode 100644 centrifuge-app/src/components/Report/utils.ts create mode 100644 centrifuge-app/src/pages/IssuerPool/Reporting/index.tsx create mode 100644 centrifuge-app/src/utils/useElementScrollSize.ts create mode 100644 fabric/src/components/DateRange/DateRange.stories.tsx create mode 100644 fabric/src/components/DateRange/index.tsx diff --git a/centrifuge-app/src/components/DataTable.tsx b/centrifuge-app/src/components/DataTable.tsx index 4cbae664fc..086635ba8f 100644 --- a/centrifuge-app/src/components/DataTable.tsx +++ b/centrifuge-app/src/components/DataTable.tsx @@ -4,6 +4,7 @@ import BN from 'bn.js' import * as React from 'react' import { Link, LinkProps } from 'react-router-dom' import styled from 'styled-components' +import { useElementScrollSize } from '../utils/useElementScrollSize' type GroupedProps = { groupIndex?: number @@ -68,6 +69,8 @@ export const DataTable = <T extends Record<string, any>>({ ) const [currentSortKey, setCurrentSortKey] = React.useState(defaultSortKey || '') + const ref = React.useRef(null) + const { scrollWidth } = useElementScrollSize(ref) const updateSortOrder = (sortKey: Column['sortKey']) => { if (!sortKey) return @@ -82,8 +85,9 @@ export const DataTable = <T extends Record<string, any>>({ }, [orderBy, data, currentSortKey, page, pageSize]) const showHeader = groupIndex === 0 || !groupIndex + return ( - <Stack as={rounded && !lastGroupIndex ? Card : Stack}> + <Stack ref={ref} as={rounded && !lastGroupIndex ? Card : Stack} minWidth={scrollWidth > 0 ? scrollWidth : 'auto'}> <Shelf> {showHeader && columns.map((col, i) => ( @@ -189,7 +193,7 @@ const DataCol = styled(Text)<{ align: Column['align'] }>` white-space: nowrap; &:first-child { - padding-right: '16px'; + padding-right: 16px; } ${({ align }) => { switch (align) { @@ -197,14 +201,14 @@ const DataCol = styled(Text)<{ align: Column['align'] }>` return css({ justifyContent: 'flex-start', '&:last-child': { - paddingRight: '16px', + paddingRight: 16, }, }) case 'center': return css({ justifyContent: 'center', '&:last-child': { - paddingRight: '16px', + paddingRight: 16, }, }) case 'right': @@ -214,7 +218,7 @@ const DataCol = styled(Text)<{ align: Column['align'] }>` justifyContent: 'flex-end', '&:last-child': { - paddingRight: '16px', + paddingRight: 16, }, }) } diff --git a/centrifuge-app/src/components/DataTableGroup.tsx b/centrifuge-app/src/components/DataTableGroup.tsx index 90bcc8d8dd..18954db01d 100644 --- a/centrifuge-app/src/components/DataTableGroup.tsx +++ b/centrifuge-app/src/components/DataTableGroup.tsx @@ -2,9 +2,15 @@ import { Card, Stack } from '@centrifuge/fabric' import * as React from 'react' import { DataTableProps } from './DataTable' -export function DataTableGroup({ children }: { children: React.ReactElement<DataTableProps>[] }) { +export function DataTableGroup({ + children, + rounded = true, +}: { + children: React.ReactElement<DataTableProps>[] + rounded?: boolean +}) { return ( - <Stack as={Card} gap="3"> + <Stack as={rounded ? Card : undefined} gap="3"> {React.Children.map(children, (child, index) => { return React.isValidElement(child) ? React.cloneElement(child, { diff --git a/centrifuge-app/src/components/Report.tsx b/centrifuge-app/src/components/Report.tsx deleted file mode 100644 index e4941fad6c..0000000000 --- a/centrifuge-app/src/components/Report.tsx +++ /dev/null @@ -1,355 +0,0 @@ -import { Pool, Rate } from '@centrifuge/centrifuge-js' -import { Stack, Text } from '@centrifuge/fabric' -import * as React from 'react' -import styled from 'styled-components' -import { GroupBy, Report } from '../pages/Pool/Reporting' -import { formatDate } from '../utils/date' -import { formatBalance, formatPercentage } from '../utils/formatting' -import { useLoans } from '../utils/useLoans' -import { useDailyPoolStates, useInvestorTransactions, useMonthlyPoolStates } from '../utils/usePools' -import { Column, DataTable } from './DataTable' -import { DataTableGroup } from './DataTableGroup' - -export type ReportingMoment = { - blockNumber: number - timestamp: Date -} - -export type CustomFilters = { - groupBy: GroupBy - activeTranche?: string -} - -type Props = { - pool: Pool - report: Report - exportRef: React.MutableRefObject<Function> - customFilters: CustomFilters - startDate: Date | undefined - endDate: Date | undefined -} - -type TableDataRow = { - name: string | React.ReactElement - value: string[] | React.ReactElement - heading?: boolean -} - -export const ReportComponent: React.FC<Props> = ({ pool, report, exportRef, customFilters, startDate, endDate }) => { - const dailyPoolStates = useDailyPoolStates(pool.id, startDate, endDate) - const monthlyPoolStates = useMonthlyPoolStates(pool.id, startDate, endDate) - - const poolStates = - report === 'pool-balance' ? (customFilters.groupBy === 'day' ? dailyPoolStates : monthlyPoolStates) : [] - const investorTransactions = useInvestorTransactions( - pool.id, - customFilters.activeTranche === 'all' ? undefined : customFilters.activeTranche - ) - const loans = useLoans(pool.id) - - const columns: Column[] = - report === 'pool-balance' - ? poolStates - ? [ - { - align: 'left', - header: '', - cell: (row: TableDataRow) => <Text variant={row.heading ? 'heading4' : 'body2'}>{row.name}</Text>, - flex: '1 0 200px', - }, - ].concat( - poolStates.map((state, index) => { - return { - align: 'right', - header: `${new Date(state.timestamp).toLocaleDateString('en-US', { - month: 'short', - })} ${ - customFilters.groupBy === 'day' - ? new Date(state.timestamp).toLocaleDateString('en-US', { day: 'numeric' }) - : new Date(state.timestamp).toLocaleDateString('en-US', { year: 'numeric' }) - }`, - cell: (row: TableDataRow) => <Text variant="body2">{(row.value as any)[index]}</Text>, - flex: '0 0 100px', - } - }) - ) - : [] - : report === 'asset-list' - ? [ - 'ID', - 'Status', - 'Collateral value', - 'Outstanding', - 'Total financed', - 'Total repaid', - 'Financing date', - 'Maturity date', - 'Financing fee', - 'Advance rate', - 'PD', - 'LGD', - 'Discount rate', - ].map((col, index) => { - return { - align: 'left', - header: col, - cell: (row: TableDataRow) => <Text variant="body2">{(row.value as any)[index]}</Text>, - flex: index === 0 ? '0 0 50px' : '0 0 100px', - } - }) - : [ - 'Token', - 'Account', - 'Epoch', - 'Date', - 'Type', - `${pool ? `${pool.currency.symbol} amount` : '—'}`, - 'Token amount', - 'Price', - ].map((col, index) => { - return { - align: 'left', - header: col, - cell: (row: TableDataRow) => <Text variant="body2">{(row.value as any)[index]}</Text>, - flex: index === 0 ? '0 0 150px' : index === 4 ? '0 0 200px' : '1', - } - }) - - const exportToCsv = () => { - const rows = [columns.map((col) => col.header.toString())] - - const mapText = (text: string) => text.replaceAll('\u00A0 \u00A0', '-') - - if (report === 'pool-balance') { - overviewRecords.forEach((rec, index) => { - rows.push(columns.map((col) => (col.cell(rec, index) ? mapText(textContent(col.cell(rec, index))) : ''))) - }) - rows.push(['']) - - priceRecords.forEach((rec, index) => { - rows.push(columns.map((col) => (col.cell(rec, index) ? mapText(textContent(col.cell(rec, index))) : ''))) - }) - rows.push(['']) - - inOutFlowRecords.forEach((rec, index) => { - rows.push(columns.map((col) => (col.cell(rec, index) ? mapText(textContent(col.cell(rec, index))) : ''))) - }) - rows.push(['']) - } else if (report === 'asset-list') { - loanListRecords.forEach((rec, index) => { - rows.push(columns.map((col) => (col.cell(rec, index) ? mapText(textContent(col.cell(rec, index))) : ''))) - }) - } else { - investorTxRecords.forEach((rec, index) => { - rows.push(columns.map((col) => (col.cell(rec, index) ? mapText(textContent(col.cell(rec, index))) : ''))) - }) - rows.push(['']) - } - - downloadCSV(rows, `${report}_${new Date().toISOString().slice(0, 10)}.csv`) - } - React.useImperativeHandle(exportRef, () => exportToCsv) - - const overviewRecords: TableDataRow[] = [ - { - name: `Pool value`, - value: - poolStates?.map((state) => { - return formatBalance(state.poolValue) - }) || [], - heading: false, - }, - ].concat([ - { - name: `Asset value`, - value: - poolStates?.map((state) => { - return formatBalance(state.poolState.portfolioValuation) - }) || [], - heading: false, - }, - { - name: `Reserve`, - value: - poolStates?.map((state) => { - return formatBalance(state.poolState.totalReserve) - }) || [], - heading: false, - }, - ]) - - const priceRecords: TableDataRow[] = [ - { - name: `Token price`, - value: poolStates?.map(() => '') || [], - heading: false, - }, - ].concat( - pool?.tranches - .slice() - .reverse() - .map((token) => { - return { - name: `\u00A0 \u00A0 ${token.currency.name.split(' ').at(-1)} tranche`, - value: - poolStates?.map((state) => { - return state.tranches[token.id].price - ? formatBalance(state.tranches[token.id].price?.toFloat()!) - : '1.000' - }) || [], - heading: false, - } - }) || [] - ) - - const inOutFlowRecords: TableDataRow[] = [ - { - name: `Investments`, - value: poolStates?.map(() => '') || [], - heading: false, - }, - ].concat( - pool?.tranches - .slice() - .reverse() - .map((token) => { - return { - name: `\u00A0 \u00A0 ${token.currency.name.split(' ').at(-1)} tranche`, - value: - poolStates?.map((state) => { - return formatBalance(state.tranches[token.id].fulfilledInvestOrders.toDecimal()) - }) || [], - heading: false, - } - }) || [], - [ - { - name: `Redemptions`, - value: poolStates?.map(() => '') || [], - heading: false, - }, - ].concat( - pool?.tranches - .slice() - .reverse() - .map((token) => { - return { - name: `\u00A0 \u00A0 ${token.currency.name.split(' ').at(-1)} tranche`, - value: - poolStates?.map((state) => { - return formatBalance(state.tranches[token.id].fulfilledRedeemOrders.toDecimal()) - }) || [], - heading: false, - } - }) || [] - ) - ) - - const loanListRecords: TableDataRow[] = loans - ? [...loans] - .filter((loan) => loan.status !== 'Created') - .map((loan) => { - return { - name: ``, - value: [ - loan.id, - loan.status === 'Created' ? 'New' : loan.status, - 'value' in loan.pricing ? formatBalance(loan.pricing.value.toDecimal()) : '-', - 'outstandingDebt' in loan ? formatBalance(loan.outstandingDebt.toDecimal()) : '-', - 'totalBorrowed' in loan ? formatBalance(loan.totalBorrowed.toDecimal()) : '-', - 'totalRepaid' in loan ? formatBalance(loan.totalRepaid.toDecimal()) : '-', - 'originationDate' in loan ? formatDate(loan.originationDate) : '-', - formatDate(loan.pricing.maturityDate), - 'interestRate' in loan.pricing ? formatPercentage(loan.pricing.interestRate.toPercent()) : '-', - 'advanceRate' in loan.pricing ? formatPercentage(loan.pricing.advanceRate.toPercent()) : '-', - 'probabilityOfDefault' in loan.pricing - ? formatPercentage((loan.pricing.probabilityOfDefault as Rate).toPercent()) - : '-', - 'lossGivenDefault' in loan.pricing - ? formatPercentage((loan.pricing.lossGivenDefault as Rate).toPercent()) - : '-', - 'discountRate' in loan.pricing ? formatPercentage((loan.pricing.discountRate as Rate).toPercent()) : '-', - // formatDate(loan.maturityDate.toString()), - ], - heading: false, - } - }) - : [] - - const investorTxRecords: TableDataRow[] = - investorTransactions?.map((tx) => { - const tokenId = tx.trancheId.split('-')[1] - const token = pool.tranches.find((t) => t.id === tokenId)! - return { - name: ``, - value: [ - token.currency.name, - tx.accountId, - tx.epochNumber, - formatDate(tx.timestamp.toString()), - tx.type, - formatBalance(tx.currencyAmount.toDecimal()), - formatBalance(tx.tokenAmount.toDecimal()), - tx.tokenPrice ? formatBalance(tx.tokenPrice.toDecimal(), pool.currency.symbol, 4) : '', - ], - heading: false, - } - }) || [] - - return ( - <Stack gap="2"> - <Stack gap="3"> - <GradientOverlay> - {report === 'pool-balance' && ( - <DataTableGroup> - <DataTable data={overviewRecords} columns={columns} hoverable /> - <DataTable data={priceRecords} columns={columns} hoverable /> - <DataTable data={inOutFlowRecords} columns={columns} hoverable /> - </DataTableGroup> - )} - {report === 'asset-list' && <DataTable data={loanListRecords} columns={columns} hoverable />} - {report === 'investor-tx' && <DataTable data={investorTxRecords} columns={columns} hoverable />} - </GradientOverlay> - </Stack> - {(report === 'pool-balance' || report === 'asset-list') && pool && ( - <Text variant="body3" color="textSecondary"> - All amounts are in {pool.currency.symbol}. - </Text> - )} - </Stack> - ) -} - -const GradientOverlay = styled.div` - max-width: 960px; - overflow: auto; - background: linear-gradient(to right, #fff 20%, rgba(0, 0, 0, 0)), - linear-gradient(to right, rgba(0, 0, 0, 0), #fff 80%) 0 100%, linear-gradient(to right, #000, rgba(0, 0, 0, 0) 20%), - linear-gradient(to left, #000, rgba(0, 0, 0, 0) 20%); - background-attachment: local, local, scroll, scroll; -` - -function textContent(elem: any): string { - if (!elem) { - return '' - } - if (typeof elem === 'string') { - return elem - } - const children = elem.props && elem.props.children - if (children instanceof Array) { - return children.map(textContent).join('') - } - return textContent(children) -} - -export const downloadCSV = (rows: any[], filename: string) => { - const csvContent = `data:text/csv;charset=utf-8,${rows.map((e) => e.join(';')).join('\n')}` - const encodedUri = encodeURI(csvContent) - const link = document.createElement('a') - link.setAttribute('href', encodedUri) - link.setAttribute('download', filename) - document.body.appendChild(link) // Required for FF - - link.click() -} diff --git a/centrifuge-app/src/components/Report/AssetList.tsx b/centrifuge-app/src/components/Report/AssetList.tsx new file mode 100644 index 0000000000..c8586730b4 --- /dev/null +++ b/centrifuge-app/src/components/Report/AssetList.tsx @@ -0,0 +1,114 @@ +import { Loan, Pool, Rate } from '@centrifuge/centrifuge-js' +import { Text } from '@centrifuge/fabric' +import * as React from 'react' +import { formatDate } from '../../utils/date' +import { formatBalanceAbbreviated, formatPercentage } from '../../utils/formatting' +import { getCSVDownloadUrl } from '../../utils/getCSVDownloadUrl' +import { useLoans } from '../../utils/useLoans' +import { DataTable } from '../DataTable' +import { Spinner } from '../Spinner' +import type { TableDataRow } from './index' +import { ReportContext } from './ReportContext' +import { UserFeedback } from './UserFeedback' + +const headers = [ + 'ID', + 'Status', + 'Collateral value', + 'Outstanding', + 'Total financed', + 'Total repaid', + 'Financing date', + 'Maturity date', + 'Financing fee', + 'Advance rate', + 'PD', + 'LGD', + 'Discount rate', +] + +const columns = headers.map((col, index) => ({ + align: 'left', + header: col, + cell: (row: TableDataRow) => <Text variant="body2">{(row.value as any)[index]}</Text>, + flex: index === 0 ? '0 0 50px' : '0 0 120px', +})) + +export function AssetList({ pool }: { pool: Pool }) { + const loans = useLoans(pool.id) as Loan[] + const { setCsvData, startDate, endDate } = React.useContext(ReportContext) + + const data: TableDataRow[] = React.useMemo(() => { + if (!loans) { + return [] + } + + return loans + .filter((loan) => loan.status !== 'Created') + .map((loan) => ({ + name: '', + value: [ + loan.id, + loan.status === 'Created' ? 'New' : loan.status, + 'value' in loan.pricing + ? formatBalanceAbbreviated(loan.pricing.value.toDecimal(), pool.currency.symbol) + : '-', + 'outstandingDebt' in loan + ? formatBalanceAbbreviated(loan.outstandingDebt.toDecimal(), pool.currency.symbol) + : '-', + 'totalBorrowed' in loan + ? formatBalanceAbbreviated(loan.totalBorrowed.toDecimal(), pool.currency.symbol) + : '-', + 'totalRepaid' in loan ? formatBalanceAbbreviated(loan.totalRepaid.toDecimal(), pool.currency.symbol) : '-', + 'originationDate' in loan ? formatDate(loan.originationDate) : '-', + formatDate(loan.pricing.maturityDate), + 'interestRate' in loan.pricing ? formatPercentage(loan.pricing.interestRate.toPercent()) : '-', + 'advanceRate' in loan.pricing ? formatPercentage(loan.pricing.advanceRate.toPercent()) : '-', + 'probabilityOfDefault' in loan.pricing + ? formatPercentage((loan.pricing.probabilityOfDefault as Rate).toPercent()) + : '-', + 'lossGivenDefault' in loan.pricing + ? formatPercentage((loan.pricing.lossGivenDefault as Rate).toPercent()) + : '-', + 'discountRate' in loan.pricing ? formatPercentage((loan.pricing.discountRate as Rate).toPercent()) : '-', + ], + heading: false, + })) + }, [loans, pool.currency.symbol]) + + const dataUrl = React.useMemo(() => { + if (!data.length) { + return + } + + const formatted = data + .map(({ value }) => value as string[]) + .map((values) => Object.fromEntries(headers.map((_, index) => [headers[index], `"${values[index]}"`]))) + + return getCSVDownloadUrl(formatted) + }, [data]) + + React.useEffect(() => { + setCsvData( + dataUrl + ? { + dataUrl, + fileName: `${pool.id}-asset-list-${startDate}-${endDate}.csv`, + } + : undefined + ) + + return () => setCsvData(undefined) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dataUrl, pool.id, startDate, endDate]) + + if (!loans) { + return <Spinner /> + } + + return data.length > 0 ? ( + <DataTable data={data} columns={columns} hoverable rounded={false} /> + ) : ( + <UserFeedback reportType="Assets" /> + ) +} diff --git a/centrifuge-app/src/components/Report/BorrowerTransactions.tsx b/centrifuge-app/src/components/Report/BorrowerTransactions.tsx new file mode 100644 index 0000000000..43069d8ddb --- /dev/null +++ b/centrifuge-app/src/components/Report/BorrowerTransactions.tsx @@ -0,0 +1,83 @@ +import { Pool } from '@centrifuge/centrifuge-js/dist/modules/pools' +import { Text } from '@centrifuge/fabric' +import * as React from 'react' +import { formatDate } from '../../utils/date' +import { formatBalanceAbbreviated } from '../../utils/formatting' +import { getCSVDownloadUrl } from '../../utils/getCSVDownloadUrl' +import { useBorrowerTransactions } from '../../utils/usePools' +import { DataTable } from '../DataTable' +import { Spinner } from '../Spinner' +import type { TableDataRow } from './index' +import { ReportContext } from './ReportContext' +import { UserFeedback } from './UserFeedback' +import { formatBorrowerTransactionsType } from './utils' + +const headers = ['Asset ID', 'Epoch', 'Date', 'Type', 'Token amount'] + +export function BorrowerTransactions({ pool }: { pool: Pool }) { + const { startDate, endDate, setCsvData } = React.useContext(ReportContext) + const transactions = useBorrowerTransactions(pool.id, startDate, endDate) + + const data: TableDataRow[] = React.useMemo(() => { + if (!transactions) { + return [] + } + + return transactions?.map((tx) => ({ + name: '', + value: [ + tx.loanId.split('-').at(-1)!, + tx.epochId.split('-').at(-1)!, + formatDate(tx.timestamp.toString()), + formatBorrowerTransactionsType(tx.type), + tx.amount ? formatBalanceAbbreviated(tx.amount, pool.currency.symbol) : '-', + ], + heading: false, + })) + }, [transactions, pool.currency.symbol]) + + const columnWidths = ['150px', '100px', '120px', '100px', '200px'] + + const columns = headers.map((col, index) => ({ + align: 'left', + header: col, + cell: (row: TableDataRow) => <Text variant="body2">{(row.value as any)[index]}</Text>, + flex: `0 0 ${columnWidths[index]}`, + })) + + const dataUrl = React.useMemo(() => { + if (!data.length) { + return + } + + const formatted = data + .map(({ value }) => value as string[]) + .map((values) => Object.fromEntries(headers.map((_, index) => [headers[index], `"${values[index]}"`]))) + + return getCSVDownloadUrl(formatted) + }, [data]) + + React.useEffect(() => { + setCsvData( + dataUrl + ? { + dataUrl, + fileName: `${pool.id}-borrower-transactions-${startDate}-${endDate}.csv`, + } + : undefined + ) + + return () => setCsvData(undefined) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dataUrl, startDate, endDate, pool.id]) + + if (!transactions) { + return <Spinner mt={2} /> + } + + return data.length > 0 ? ( + <DataTable data={data} columns={columns} hoverable rounded={false} /> + ) : ( + <UserFeedback reportType="Borrower transactions" /> + ) +} diff --git a/centrifuge-app/src/components/Report/InvestorTransactions.tsx b/centrifuge-app/src/components/Report/InvestorTransactions.tsx new file mode 100644 index 0000000000..41db2b79d5 --- /dev/null +++ b/centrifuge-app/src/components/Report/InvestorTransactions.tsx @@ -0,0 +1,118 @@ +import { Pool } from '@centrifuge/centrifuge-js/dist/modules/pools' +import { Text } from '@centrifuge/fabric' +import * as React from 'react' +import { formatDate } from '../../utils/date' +import { formatBalance } from '../../utils/formatting' +import { getCSVDownloadUrl } from '../../utils/getCSVDownloadUrl' +import { useInvestorTransactions } from '../../utils/usePools' +import { DataTable } from '../DataTable' +import { Spinner } from '../Spinner' +import type { TableDataRow } from './index' +import { ReportContext } from './ReportContext' +import { UserFeedback } from './UserFeedback' +import { formatInvestorTransactionsType } from './utils' + +function truncate(string: string) { + const first = string.slice(0, 5) + const last = string.slice(-5) + + return `${first}...${last}` +} + +export function InvestorTransactions({ pool }: { pool: Pool }) { + const { activeTranche, setCsvData, startDate, endDate } = React.useContext(ReportContext) + + const transactions = useInvestorTransactions( + pool.id, + activeTranche === 'all' ? undefined : activeTranche, + startDate, + endDate + ) + + const headers = [ + 'Token', + 'Account', + 'Epoch', + 'Date', + 'Type', + `${pool ? `${pool.currency.symbol} amount` : '—'}`, + 'Token amount', + 'Price', + ] + const columnWidths = ['220px', '150px', '100px', '120px', '300px', '180px', '180px', '180px'] + + const columns = headers.map((col, index) => ({ + align: 'left', + header: col, + cell: (row: TableDataRow) => <Text variant="body2">{(row.value as any)[index]}</Text>, + flex: `0 0 ${columnWidths[index]}`, + })) + + const data: TableDataRow[] = React.useMemo(() => { + if (!transactions) { + return [] + } + + return transactions?.map((tx) => { + const tokenId = tx.trancheId.split('-')[1] + const token = pool.tranches.find((t) => t.id === tokenId)! + + return { + name: '', + value: [ + token.currency.name, + truncate(tx.accountId), + tx.epochNumber.toString(), + formatDate(tx.timestamp.toString()), + formatInvestorTransactionsType({ + type: tx.type, + trancheTokenSymbol: token.currency.symbol, + poolCurrencySymbol: pool.currency.symbol, + currencyAmount: tx.currencyAmount ? tx.currencyAmount?.toNumber() : null, + }), + tx.currencyAmount ? formatBalance(tx.currencyAmount.toDecimal(), pool.currency) : '-', + tx.tokenAmount ? formatBalance(tx.tokenAmount.toDecimal(), pool.currency) : '-', + tx.tokenPrice ? formatBalance(tx.tokenPrice.toDecimal(), pool.currency.symbol, 4) : '-', + ], + heading: false, + } + }) + }, [transactions, pool.currency, pool.tranches]) + + const dataUrl = React.useMemo(() => { + if (!data.length) { + return + } + + const formatted = data + .map(({ value }) => value as string[]) + .map((values) => Object.fromEntries(headers.map((_, index) => [headers[index], `"${values[index]}"`]))) + + return getCSVDownloadUrl(formatted) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data]) + + React.useEffect(() => { + setCsvData( + dataUrl + ? { + dataUrl, + fileName: `${pool.id}-investor-transactions-${startDate}-${endDate}.csv`, + } + : undefined + ) + + return () => setCsvData(undefined) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dataUrl, pool.id, startDate, endDate]) + + if (!transactions) { + return <Spinner mt={2} /> + } + + return data.length > 0 ? ( + <DataTable data={data} columns={columns} hoverable rounded={false} /> + ) : ( + <UserFeedback reportType="Investor transactions" /> + ) +} diff --git a/centrifuge-app/src/components/Report/PoolBalance.tsx b/centrifuge-app/src/components/Report/PoolBalance.tsx new file mode 100644 index 0000000000..96cd88e84c --- /dev/null +++ b/centrifuge-app/src/components/Report/PoolBalance.tsx @@ -0,0 +1,176 @@ +import { Pool } from '@centrifuge/centrifuge-js/dist/modules/pools' +import { Text } from '@centrifuge/fabric' +import * as React from 'react' +import { formatBalanceAbbreviated } from '../../utils/formatting' +import { getCSVDownloadUrl } from '../../utils/getCSVDownloadUrl' +import { useDailyPoolStates, useMonthlyPoolStates } from '../../utils/usePools' +import { DataTable } from '../DataTable' +import { DataTableGroup } from '../DataTableGroup' +import { Spinner } from '../Spinner' +import type { TableDataRow } from './index' +import { ReportContext } from './ReportContext' +import { UserFeedback } from './UserFeedback' + +export function PoolBalance({ pool }: { pool: Pool }) { + const { startDate, endDate, groupBy, setCsvData } = React.useContext(ReportContext) + + const dailyPoolStates = useDailyPoolStates(pool.id, startDate, endDate) + const monthlyPoolStates = useMonthlyPoolStates(pool.id, startDate, endDate) + const poolStates = groupBy === 'day' ? dailyPoolStates : monthlyPoolStates + + const columns = React.useMemo(() => { + if (!poolStates) { + return [] + } + + return [ + { + align: 'left', + header: '', + cell: (row: TableDataRow) => <Text variant={row.heading ? 'heading4' : 'body2'}>{row.name}</Text>, + flex: '0 0 200px', + }, + ].concat( + poolStates.map((state, index) => ({ + align: 'right', + header: `${new Date(state.timestamp).toLocaleDateString('en-US', { + month: 'short', + })} ${ + groupBy === 'day' + ? new Date(state.timestamp).toLocaleDateString('en-US', { day: 'numeric' }) + : new Date(state.timestamp).toLocaleDateString('en-US', { year: 'numeric' }) + }`, + cell: (row: TableDataRow) => <Text variant="body2">{(row.value as any)[index]}</Text>, + flex: '0 0 120px', + })) + ) + }, [poolStates, groupBy]) + + const overviewRecords: TableDataRow[] = React.useMemo(() => { + return [ + { + name: 'Pool value', + value: poolStates?.map((state) => formatBalanceAbbreviated(state.poolValue, pool.currency.symbol)) || [], + heading: false, + }, + { + name: 'Asset value', + value: + poolStates?.map((state) => + formatBalanceAbbreviated(state.poolState.portfolioValuation, pool.currency.symbol) + ) || [], + heading: false, + }, + { + name: 'Reserve', + value: + poolStates?.map((state) => formatBalanceAbbreviated(state.poolState.totalReserve, pool.currency.symbol)) || + [], + heading: false, + }, + ] + }, [poolStates, pool.currency.symbol]) + + const priceRecords: TableDataRow[] = React.useMemo(() => { + return [ + { + name: 'Token price', + value: poolStates?.map(() => '') || [], + heading: false, + }, + ].concat( + pool?.tranches + .slice() + .reverse() + .map((token) => ({ + name: `\u00A0 \u00A0 ${token.currency.name.split(' ').at(-1)} tranche`, + value: + poolStates?.map((state) => + state.tranches[token.id].price + ? formatBalanceAbbreviated(state.tranches[token.id].price?.toFloat()!, pool.currency.symbol) + : '1.000' + ) || [], + heading: false, + })) || [] + ) + }, [poolStates, pool.currency.symbol, pool?.tranches]) + + const inOutFlowRecords: TableDataRow[] = React.useMemo(() => { + return [ + { + name: 'Investments', + value: poolStates?.map(() => '') || [], + heading: false, + }, + ].concat( + pool?.tranches + .slice() + .reverse() + .map((token) => ({ + name: `\u00A0 \u00A0 ${token.currency.name.split(' ').at(-1)} tranche`, + value: + poolStates?.map((state) => + formatBalanceAbbreviated(state.tranches[token.id].fulfilledInvestOrders.toDecimal(), pool.currency.symbol) + ) || [], + heading: false, + })) || [], + [ + { + name: 'Redemptions', + value: poolStates?.map(() => '') || [], + heading: false, + }, + ].concat( + pool?.tranches + .slice() + .reverse() + .map((token) => ({ + name: `\u00A0 \u00A0 ${token.currency.name.split(' ').at(-1)} tranche`, + value: + poolStates?.map((state) => + formatBalanceAbbreviated( + state.tranches[token.id].fulfilledRedeemOrders.toDecimal(), + pool.currency.symbol + ) + ) || [], + heading: false, + })) || [] + ) + ) + }, [poolStates, pool.currency.symbol, pool?.tranches]) + + const headers = columns.map(({ header }) => header) + + const dataUrl = React.useMemo(() => { + const formatted = [...overviewRecords, ...priceRecords, ...inOutFlowRecords] + .map(({ name, value }) => [name, ...(value as string[])]) + .map((values) => Object.fromEntries(headers.map((_, index) => [headers[index], `"${values[index]}"`]))) + + return getCSVDownloadUrl(formatted) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [overviewRecords, priceRecords, inOutFlowRecords]) + + React.useEffect(() => { + setCsvData({ + dataUrl, + fileName: `${pool.id}-pool-balance-${startDate}-${endDate}.csv`, + }) + + return () => setCsvData(undefined) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dataUrl, pool.id, startDate, endDate]) + + if (!poolStates) { + return <Spinner mt={2} /> + } + + return poolStates?.length > 0 ? ( + <DataTableGroup rounded={false}> + <DataTable data={overviewRecords} columns={columns} hoverable rounded={false} /> + <DataTable data={priceRecords} columns={columns} hoverable rounded={false} /> + <DataTable data={inOutFlowRecords} columns={columns} hoverable rounded={false} /> + </DataTableGroup> + ) : ( + <UserFeedback reportType="Pool balance" /> + ) +} diff --git a/centrifuge-app/src/components/Report/PoolReportPage.tsx b/centrifuge-app/src/components/Report/PoolReportPage.tsx new file mode 100644 index 0000000000..ccc2277429 --- /dev/null +++ b/centrifuge-app/src/components/Report/PoolReportPage.tsx @@ -0,0 +1,41 @@ +import { Pool } from '@centrifuge/centrifuge-js' +import * as React from 'react' +import { useParams } from 'react-router' +import { ReportComponent } from '.' +import { usePool } from '../../utils/usePools' +import { LoadBoundary } from '../LoadBoundary' +import { PageWithSideBar } from '../PageWithSideBar' +import { Spinner } from '../Spinner' +import { ReportContextProvider } from './ReportContext' +import { ReportFilter } from './ReportFilter' + +export function PoolReportPage({ header }: { header: React.ReactNode }) { + const { pid: poolId } = useParams<{ pid: string }>() + const pool = usePool(poolId) as Pool + + return ( + <ReportContextProvider> + <PageWithSideBar> + {header} + + {pool && <ReportFilter pool={pool} />} + + <LoadBoundary> + <PoolDetailReporting pool={pool} /> + </LoadBoundary> + </PageWithSideBar> + </ReportContextProvider> + ) +} + +function PoolDetailReporting({ pool }: { pool: Pool }) { + if (!pool) { + return <Spinner mt={2} /> + } + + return ( + <React.Suspense fallback={<Spinner mt={2} />}> + <ReportComponent pool={pool} /> + </React.Suspense> + ) +} diff --git a/centrifuge-app/src/components/Report/ReportContext.tsx b/centrifuge-app/src/components/Report/ReportContext.tsx new file mode 100644 index 0000000000..5a3e42e5e5 --- /dev/null +++ b/centrifuge-app/src/components/Report/ReportContext.tsx @@ -0,0 +1,96 @@ +import { RangeOptionValue } from '@centrifuge/fabric' +import * as React from 'react' + +export type GroupBy = 'day' | 'month' + +export type Report = 'pool-balance' | 'asset-list' | 'investor-tx' | 'borrower-tx' + +export type ReportContextType = { + csvData?: CsvDataProps + setCsvData: (data?: CsvDataProps) => void + + startDate: Date + setStartDate: (date: Date) => void + + endDate: Date + setEndDate: (date: Date) => void + + range: RangeOptionValue + setRange: (range: RangeOptionValue) => void + + report: Report + setReport: (report: Report) => void + + groupBy: GroupBy + setGroupBy: (groupBy: GroupBy) => void + + activeTranche?: string + setActiveTranche: (tranche: string) => void +} + +export type CsvDataProps = { + dataUrl: string + fileName: string +} + +const defaultContext = { + csvData: undefined, + setCsvData() {}, + + startDate: new Date(), + setStartDate() {}, + + endDate: new Date(), + setEndDate() {}, + + range: 'last-month' as RangeOptionValue, + setRange() {}, + + report: 'pool-balance' as Report, + setReport() {}, + + groupBy: 'day' as GroupBy, + setGroupBy() {}, + + activeTranche: 'all', + setActiveTranche() {}, +} + +export const ReportContext = React.createContext<ReportContextType>(defaultContext) + +export function ReportContextProvider({ children }: { children: React.ReactNode }) { + const [csvData, setCsvData] = React.useState<CsvDataProps | undefined>(undefined) + + // Global filters + const [startDate, setStartDate] = React.useState(defaultContext.startDate) + const [endDate, setEndDate] = React.useState(defaultContext.endDate) + const [report, setReport] = React.useState(defaultContext.report) + const [range, setRange] = React.useState(defaultContext.range) + + // Custom filters for specific reports + const [groupBy, setGroupBy] = React.useState(defaultContext.groupBy) + const [activeTranche, setActiveTranche] = React.useState(defaultContext.activeTranche) + + return ( + <ReportContext.Provider + value={{ + csvData, + setCsvData, + startDate, + setStartDate, + endDate, + setEndDate, + range, + setRange, + report, + setReport, + groupBy, + setGroupBy, + activeTranche, + setActiveTranche, + }} + > + {children} + </ReportContext.Provider> + ) +} diff --git a/centrifuge-app/src/components/Report/ReportFilter.tsx b/centrifuge-app/src/components/Report/ReportFilter.tsx new file mode 100644 index 0000000000..f214e5f96a --- /dev/null +++ b/centrifuge-app/src/components/Report/ReportFilter.tsx @@ -0,0 +1,138 @@ +import { Pool } from '@centrifuge/centrifuge-js' +import { AnchorButton, Box, DateRange, Select, Shelf } from '@centrifuge/fabric' +import * as React from 'react' +import { GroupBy, Report, ReportContext } from './ReportContext' + +type ReportFilterProps = { + pool: Pool +} + +export function ReportFilter({ pool }: ReportFilterProps) { + const { + csvData, + setStartDate, + endDate, + setEndDate, + range, + setRange, + report, + setReport, + groupBy, + setGroupBy, + activeTranche, + setActiveTranche, + } = React.useContext(ReportContext) + + const reportOptions: { label: string; value: Report }[] = [ + { label: 'Pool balance', value: 'pool-balance' }, + { label: 'Asset list', value: 'asset-list' }, + { label: 'Investor transactions', value: 'investor-tx' }, + { label: 'Borrower transactions', value: 'borrower-tx' }, + ] + + return ( + <Shelf + alignItems="center" + flexWrap="wrap" + gap={2} + p={2} + borderWidth={0} + borderBottomWidth={1} + borderStyle="solid" + borderColor="borderSecondary" + > + <Box minWidth={200} maxWidth={200}> + <Select + name="report" + label="Report" + placeholder="Select a report" + options={reportOptions} + value={report} + onChange={(event) => { + if (event.target.value) { + setReport(event.target.value as Report) + } + }} + /> + </Box> + + <DateRange + end={endDate} + onSelection={(start, end, range) => { + setRange(range) + setStartDate(start) + setEndDate(end) + }} + /> + + {report === 'pool-balance' && ( + <Box minWidth={150} maxWidth={150}> + <Select + name="groupBy" + label="Group by" + placeholder="Select a time period to group by" + options={[ + { + label: 'Day', + value: 'day', + }, + ...(range !== 'last-week' + ? [ + { + label: 'Month', + value: 'month', + }, + ] + : []), + ]} + value={groupBy} + onChange={(event) => { + if (event.target.value) { + setGroupBy(event.target.value as GroupBy) + } + }} + /> + </Box> + )} + + {report === 'investor-tx' && ( + <Box minWidth={150} maxWidth={150}> + <Select + name="activeTranche" + label="Token" + placeholder="Select a token" + options={[ + { + label: 'All tokens', + value: 'all', + }, + ...pool.tranches.map((token) => { + return { + label: token.currency.name, + value: token.id, + } + }), + ]} + value={activeTranche} + onChange={(event) => { + if (event.target.value) { + setActiveTranche(event.target.value) + } + }} + /> + </Box> + )} + <Box ml="auto"> + <AnchorButton + href={csvData?.dataUrl} + download={csvData?.fileName} + variant="secondary" + small + disabled={!csvData} + > + Export CSV + </AnchorButton> + </Box> + </Shelf> + ) +} diff --git a/centrifuge-app/src/components/Report/UserFeedback.tsx b/centrifuge-app/src/components/Report/UserFeedback.tsx new file mode 100644 index 0000000000..90e5595b20 --- /dev/null +++ b/centrifuge-app/src/components/Report/UserFeedback.tsx @@ -0,0 +1,18 @@ +import { Box, InlineFeedback, Shelf, Text } from '@centrifuge/fabric' +import * as React from 'react' + +export function UserFeedback({ reportType }: { reportType: string }) { + return ( + <Shelf px={2} mt={2} justifyContent="center"> + <Box px={2} py={1} borderRadius="input" backgroundColor="secondarySelectedBackground"> + <InlineFeedback status="info"> + No{' '} + <Text as="strong" fontWeight={600}> + {reportType} + </Text>{' '} + data available for this pool. Try to select another report or date range. + </InlineFeedback> + </Box> + </Shelf> + ) +} diff --git a/centrifuge-app/src/components/Report/index.tsx b/centrifuge-app/src/components/Report/index.tsx new file mode 100644 index 0000000000..c72125881f --- /dev/null +++ b/centrifuge-app/src/components/Report/index.tsx @@ -0,0 +1,42 @@ +import { Pool } from '@centrifuge/centrifuge-js/dist/modules/pools' +import { Box, Shelf, Text } from '@centrifuge/fabric' +import * as React from 'react' +import { formatDate } from '../../utils/date' +import { AssetList } from './AssetList' +import { BorrowerTransactions } from './BorrowerTransactions' +import { InvestorTransactions } from './InvestorTransactions' +import { PoolBalance } from './PoolBalance' +import { ReportContext } from './ReportContext' + +export type TableDataRow = { + name: string | React.ReactElement + value: string[] | React.ReactElement + heading?: boolean +} + +export function ReportComponent({ pool }: { pool: Pool }) { + const { report, startDate, endDate } = React.useContext(ReportContext) + + return ( + <Box pb={6}> + <Shelf p={2} justifyContent="space-between"> + <Text as="span" variant="body3" color="textSecondary"> + <time dateTime={startDate.toISOString()}>{formatDate(startDate)}</time> + {' - '} + <time dateTime={endDate.toISOString()}>{formatDate(endDate)}</time> + </Text> + {(report === 'pool-balance' || report === 'asset-list') && pool && ( + <Text as="span" variant="body3" color="textSecondary"> + All amounts are in {pool.currency.symbol} + </Text> + )} + </Shelf> + <Box overflow="auto" width="100%"> + {report === 'pool-balance' && <PoolBalance pool={pool} />} + {report === 'asset-list' && <AssetList pool={pool} />} + {report === 'investor-tx' && <InvestorTransactions pool={pool} />} + {report === 'borrower-tx' && <BorrowerTransactions pool={pool} />} + </Box> + </Box> + ) +} diff --git a/centrifuge-app/src/components/Report/utils.ts b/centrifuge-app/src/components/Report/utils.ts new file mode 100644 index 0000000000..97fc88a3b4 --- /dev/null +++ b/centrifuge-app/src/components/Report/utils.ts @@ -0,0 +1,62 @@ +import { BorrowerTransactionType, InvestorTransactionType } from '@centrifuge/centrifuge-js/dist/types/subquery' + +const investorTransactionTypes: { + [key in InvestorTransactionType]: (args: { trancheTokenSymbol: string; poolCurrencySymbol: string }) => string +} = { + INVEST_ORDER_UPDATE: () => 'Investment order updated', + REDEEM_ORDER_UPDATE: () => 'Redemption order updated', + INVEST_ORDER_CANCEL: () => 'Investment order cancelled', + REDEEM_ORDER_CANCEL: () => 'Redemption order cancelled', + INVEST_EXECUTION: () => 'Investment executed', + REDEEM_EXECUTION: () => 'Redemption executed', + TRANSFER_IN: ({ trancheTokenSymbol }) => `Deposited ${trancheTokenSymbol}`, + TRANSFER_OUT: ({ trancheTokenSymbol }) => `Withdrawn ${trancheTokenSymbol}`, + INVEST_COLLECT: ({ trancheTokenSymbol }) => `${trancheTokenSymbol} received in wallet`, + REDEEM_COLLECT: ({ poolCurrencySymbol }) => `${poolCurrencySymbol} received in wallet`, +} + +export function formatInvestorTransactionsType({ + type, + trancheTokenSymbol, + poolCurrencySymbol, + currencyAmount, +}: { + type: InvestorTransactionType + trancheTokenSymbol: string + poolCurrencySymbol: string + currencyAmount: number | null +}) { + if (!investorTransactionTypes[type]) { + console.warn(`Type '${type}' is not assignable to type 'InvestorTransactionType'`) + return type + } + + if (type === 'INVEST_ORDER_UPDATE' && currencyAmount === 0) { + return investorTransactionTypes['INVEST_ORDER_CANCEL']({ poolCurrencySymbol, trancheTokenSymbol }) + } + + if (type === 'REDEEM_ORDER_UPDATE' && currencyAmount === 0) { + return investorTransactionTypes['REDEEM_ORDER_CANCEL']({ poolCurrencySymbol, trancheTokenSymbol }) + } + + return investorTransactionTypes[type]({ poolCurrencySymbol, trancheTokenSymbol }) +} + +const borrowerTransactionTypes: { + [key in BorrowerTransactionType]: string +} = { + CREATED: 'Created', + PRICED: 'Priced', + BORROWED: 'Financed', + REPAID: 'Repaid', + CLOSED: 'Closed', +} + +export function formatBorrowerTransactionsType(type: BorrowerTransactionType) { + if (!borrowerTransactionTypes[type]) { + console.warn(`Type '${type}' is not assignable to type 'BorrowerTransactionType'`) + return type + } + + return borrowerTransactionTypes[type] +} diff --git a/centrifuge-app/src/components/Spinner.tsx b/centrifuge-app/src/components/Spinner.tsx index e190f2b620..8c7710c9dc 100644 --- a/centrifuge-app/src/components/Spinner.tsx +++ b/centrifuge-app/src/components/Spinner.tsx @@ -1,4 +1,4 @@ -import { Shelf } from '@centrifuge/fabric' +import { Shelf, ShelfProps } from '@centrifuge/fabric' import { ThemeSize } from '@centrifuge/fabric/dist/utils/types' import * as React from 'react' import styled, { keyframes, useTheme } from 'styled-components' @@ -25,11 +25,16 @@ const StyledSpinner = styled.div<{ $size: string }>` animation: ${rotate} 0.6s linear infinite; ` -export const Spinner: React.FC<{ size?: string | number }> = ({ size = '48px' }) => { +type SpinnerProps = ShelfProps & { + size?: string | number +} + +export function Spinner({ size = '48px', ...shelfProps }: SpinnerProps) { const theme = useTheme() const sizePx = toPx(theme.sizes[size as ThemeSize] || size) + return ( - <Shelf justifyContent="center"> + <Shelf justifyContent="center" {...shelfProps}> <StyledSpinner $size={sizePx} /> </Shelf> ) diff --git a/centrifuge-app/src/pages/IssuerPool/Header.tsx b/centrifuge-app/src/pages/IssuerPool/Header.tsx index ec7ad20bb1..4859c3f473 100644 --- a/centrifuge-app/src/pages/IssuerPool/Header.tsx +++ b/centrifuge-app/src/pages/IssuerPool/Header.tsx @@ -19,6 +19,7 @@ export const IssuerPoolHeader: React.FC<Props> = ({ actions }) => { const theme = useTheme() const cent = useCentrifuge() const basePath = useRouteMatch(['/pools', '/issuer'])?.path || '' + const isTinlakePool = pool.id.startsWith('0x') return ( <> @@ -63,6 +64,7 @@ export const IssuerPoolHeader: React.FC<Props> = ({ actions }) => { <NavigationTabsItem to={`${basePath}/${pid}`}>Overview</NavigationTabsItem> <NavigationTabsItem to={`${basePath}/${pid}/assets`}>Assets</NavigationTabsItem> <NavigationTabsItem to={`${basePath}/${pid}/liquidity`}>Liquidity</NavigationTabsItem> + {!isTinlakePool && <NavigationTabsItem to={`${basePath}/${pid}/reporting`}>Reporting</NavigationTabsItem>} <NavigationTabsItem to={`${basePath}/${pid}/investors`}>Investors</NavigationTabsItem> <NavigationTabsItem to={`${basePath}/${pid}/configuration`}>Configuration</NavigationTabsItem> <NavigationTabsItem to={`${basePath}/${pid}/access`}>Access</NavigationTabsItem> diff --git a/centrifuge-app/src/pages/IssuerPool/Reporting/index.tsx b/centrifuge-app/src/pages/IssuerPool/Reporting/index.tsx new file mode 100644 index 0000000000..31c36fc446 --- /dev/null +++ b/centrifuge-app/src/pages/IssuerPool/Reporting/index.tsx @@ -0,0 +1,7 @@ +import * as React from 'react' +import { PoolReportPage } from '../../../components/Report/PoolReportPage' +import { IssuerPoolHeader } from '../Header' + +export function IssuerPoolReportingPage() { + return <PoolReportPage header={<IssuerPoolHeader />} /> +} diff --git a/centrifuge-app/src/pages/IssuerPool/index.tsx b/centrifuge-app/src/pages/IssuerPool/index.tsx index 56a428d706..19db03f840 100644 --- a/centrifuge-app/src/pages/IssuerPool/index.tsx +++ b/centrifuge-app/src/pages/IssuerPool/index.tsx @@ -8,6 +8,7 @@ import { IssuerPoolViewLoanTemplatePage } from './Configuration/ViewLoanTemplate import { IssuerPoolInvestorsPage } from './Investors' import { IssuerPoolLiquidityPage } from './Liquidity' import { IssuerPoolOverviewPage } from './Overview' +import { IssuerPoolReportingPage } from './Reporting' export const IssuerPoolPage: React.FC = () => { const { path } = useRouteMatch() @@ -21,6 +22,7 @@ export const IssuerPoolPage: React.FC = () => { <Route path={`${path}/access`} component={IssuerPoolAccessPage} /> <Route path={`${path}/assets`} component={IssuerPoolAssetPage} /> <Route path={`${path}/liquidity`} component={IssuerPoolLiquidityPage} /> + <Route path={`${path}/reporting`} component={IssuerPoolReportingPage} /> <Route path={path} component={IssuerPoolOverviewPage} /> </Switch> ) diff --git a/centrifuge-app/src/pages/Pool/Header.tsx b/centrifuge-app/src/pages/Pool/Header.tsx index 2e6b323f25..9c6da1044c 100644 --- a/centrifuge-app/src/pages/Pool/Header.tsx +++ b/centrifuge-app/src/pages/Pool/Header.tsx @@ -3,7 +3,6 @@ import { Box, Shelf, Text, TextWithPlaceholder } from '@centrifuge/fabric' import * as React from 'react' import { useLocation, useParams, useRouteMatch } from 'react-router' import { useTheme } from 'styled-components' -import { useDebugFlags } from '../../components/DebugFlags' import { Eththumbnail } from '../../components/EthThumbnail' import { NavigationTabs, NavigationTabsItem } from '../../components/NavigationTabs' import { PageHeader } from '../../components/PageHeader' @@ -23,7 +22,6 @@ export const PoolDetailHeader: React.FC<Props> = ({ actions }) => { const isTinlakePool = pool.id.startsWith('0x') const theme = useTheme() const cent = useCentrifuge() - const { poolReporting } = useDebugFlags() return ( <PageHeader @@ -69,7 +67,7 @@ export const PoolDetailHeader: React.FC<Props> = ({ actions }) => { <NavigationTabsItem to={`${basePath}/${pid}`}>Overview</NavigationTabsItem> <NavigationTabsItem to={`${basePath}/${pid}/assets`}>Assets</NavigationTabsItem> <NavigationTabsItem to={`${basePath}/${pid}/liquidity`}>Liquidity</NavigationTabsItem> - {poolReporting && <NavigationTabsItem to={`${basePath}/${pid}/reporting`}>Reporting</NavigationTabsItem>} + {!isTinlakePool && <NavigationTabsItem to={`${basePath}/${pid}/reporting`}>Reporting</NavigationTabsItem>} </NavigationTabs> </Shelf> </PageHeader> diff --git a/centrifuge-app/src/pages/Pool/Reporting/index.tsx b/centrifuge-app/src/pages/Pool/Reporting/index.tsx index 5d43c6212f..54905a94a7 100644 --- a/centrifuge-app/src/pages/Pool/Reporting/index.tsx +++ b/centrifuge-app/src/pages/Pool/Reporting/index.tsx @@ -1,180 +1,7 @@ -import { Pool } from '@centrifuge/centrifuge-js' -import { Button, Card, DateInput, Select, Shelf, Stack } from '@centrifuge/fabric' import * as React from 'react' -import { useParams } from 'react-router' -import { LoadBoundary } from '../../../components/LoadBoundary' -import { PageSection } from '../../../components/PageSection' -import { PageWithSideBar } from '../../../components/PageWithSideBar' -import { CustomFilters, ReportComponent } from '../../../components/Report' -import { Spinner } from '../../../components/Spinner' -import { formatDate } from '../../../utils/date' -import { usePool } from '../../../utils/usePools' +import { PoolReportPage } from '../../../components/Report/PoolReportPage' import { PoolDetailHeader } from '../Header' -export type GroupBy = 'day' | 'month' - -export type Report = 'pool-balance' | 'asset-list' | 'investor-tx' - -const titleByReport: { [key: string]: string } = { - 'pool-balance': 'Pool balance', - 'asset-list': 'Asset list', - 'investor-tx': 'Investor transactions', -} - -export const PoolDetailReportingTab: React.FC = () => { - const { pid: poolId } = useParams<{ pid: string }>() - const pool = usePool(poolId) as Pool - - // Global filters - const [startDate, setStartDate] = React.useState(pool?.createdAt ? new Date(pool?.createdAt) : new Date()) - const [endDate, setEndDate] = React.useState(new Date()) - const [report, setReport] = React.useState('pool-balance' as Report) - - // Custom filters for specific reports - const [groupBy, setGroupBy] = React.useState('day' as GroupBy) - const [activeTranche, setActiveTranche] = React.useState('all' as string | undefined) - - const exportRef = React.useRef<() => void>(() => {}) - - const reportOptions: { label: string; value: Report }[] = [ - { label: 'Pool balance', value: 'pool-balance' }, - { label: 'Asset list', value: 'asset-list' }, - { label: 'Investor transactions', value: 'investor-tx' }, - ] - - return ( - <PageWithSideBar - sidebar={ - <Stack gap={2}> - <Stack as={Card} gap={2} p={2}> - <Select - name="report" - label="Report" - placeholder="Select a report" - options={reportOptions} - value={report} - onChange={(event) => { - if (event.target.value) { - setReport(event.target.value as Report) - } - }} - /> - <Shelf gap={2}> - <DateInput - label="From date" - value={startDate.toISOString().slice(0, 10)} - onChange={(event) => setStartDate(new Date(event.target.value))} - /> - </Shelf> - <Shelf gap={2}> - <DateInput - label="To date" - value={endDate.toISOString().slice(0, 10)} - onChange={(event) => setEndDate(new Date(event.target.value))} - /> - </Shelf> - {report === 'pool-balance' && ( - <Shelf gap={2}> - <Select - name="groupBy" - label="Group by" - placeholder="Select a time period to group by" - options={[ - { - label: 'Day', - value: 'day', - }, - { - label: 'Month', - value: 'month', - }, - ]} - value={groupBy} - onChange={(event) => { - if (event.target.value) { - setGroupBy(event.target.value as GroupBy) - } - }} - /> - </Shelf> - )} - {report === 'investor-tx' && ( - <Shelf> - <Select - name="activeTranche" - label="Token" - placeholder="Select a token" - options={[ - { - label: 'All tokens', - value: 'all', - }, - ...pool.tranches.map((token) => { - return { - label: token.currency.name, - value: token.id, - } - }), - ]} - value={activeTranche} - onChange={(event) => { - if (event.target.value) { - setActiveTranche(event.target.value) - } - }} - /> - </Shelf> - )} - <Shelf> - <Button type="button" variant="primary" small onClick={() => exportRef.current()}> - Export CSV - </Button> - </Shelf> - </Stack> - </Stack> - } - > - <PoolDetailHeader /> - <LoadBoundary> - <PoolDetailReporting - start={startDate} - end={endDate} - pool={pool} - report={report} - exportRef={exportRef} - customFilters={{ groupBy, activeTranche }} - /> - </LoadBoundary> - </PageWithSideBar> - ) -} - -export const PoolDetailReporting: React.FC<{ - start: Date | undefined - end: Date | undefined - pool: Pool | undefined - report: Report - exportRef: React.MutableRefObject<() => void> - customFilters: CustomFilters -}> = ({ start, end, pool, report, exportRef, customFilters }) => { - if (!pool) return <Spinner /> - return ( - <> - <PageSection - title={titleByReport[report]} - titleAddition={start && end ? `${formatDate(start.toString())} to ${formatDate(end.toString())}` : ''} - > - <React.Suspense fallback={<Spinner />}> - <ReportComponent - pool={pool} - report={report} - exportRef={exportRef} - customFilters={customFilters} - startDate={start} - endDate={end} - /> - </React.Suspense> - </PageSection> - </> - ) +export function PoolDetailReportingTab() { + return <PoolReportPage header={<PoolDetailHeader />} /> } diff --git a/centrifuge-app/src/utils/useElementScrollSize.ts b/centrifuge-app/src/utils/useElementScrollSize.ts new file mode 100644 index 0000000000..8b89ce8058 --- /dev/null +++ b/centrifuge-app/src/utils/useElementScrollSize.ts @@ -0,0 +1,42 @@ +import * as React from 'react' + +interface Size { + scrollWidth: number + scrollHeight: number +} + +function debounce(func: Function) { + let timer: any + + return (event: any) => { + if (timer) clearTimeout(timer) + timer = setTimeout(func, 2000, event) + } +} + +export function useElementScrollSize(ref: React.MutableRefObject<HTMLElement | null>) { + const [size, setSize] = React.useState<Size>({ + scrollWidth: 0, + scrollHeight: 0, + }) + + React.useLayoutEffect(() => { + const handleSize = () => { + if (ref?.current?.scrollWidth && ref?.current?.scrollHeight) { + setSize({ + scrollWidth: ref.current?.scrollWidth || 0, + scrollHeight: ref.current?.scrollHeight || 0, + }) + } + } + + const debouncedHandler = debounce(handleSize) + window.addEventListener('resize', debouncedHandler) + handleSize() + return () => window.removeEventListener('resize', debouncedHandler) + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ref?.current]) + + return { scrollWidth: size.scrollWidth, scrollHeight: size.scrollHeight } +} diff --git a/centrifuge-app/src/utils/usePools.ts b/centrifuge-app/src/utils/usePools.ts index 5fc8a6ea10..145c32d53d 100644 --- a/centrifuge-app/src/utils/usePools.ts +++ b/centrifuge-app/src/utils/usePools.ts @@ -61,6 +61,18 @@ export function useInvestorTransactions(poolId: string, trancheId?: string, from return result } +export function useBorrowerTransactions(poolId: string, from?: Date, to?: Date) { + const [result] = useCentrifugeQuery( + ['borrowerTransactions', poolId, from, to], + (cent) => cent.pools.getBorrowerTransactions([poolId, from, to]), + { + 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-js/src/modules/pools.ts b/centrifuge-js/src/modules/pools.ts index b185d0c543..03e2aac18b 100644 --- a/centrifuge-js/src/modules/pools.ts +++ b/centrifuge-js/src/modules/pools.ts @@ -7,12 +7,21 @@ import { calculateOptimalSolution, SolverResult } from '..' import { Centrifuge } from '../Centrifuge' import { Account, TransactionOptions } from '../types' import { + BorrowerTransactionType, InvestorTransactionType, + SubqueryBorrowerTransaction, SubqueryInvestorTransaction, SubqueryPoolSnapshot, SubqueryTrancheSnapshot, } from '../types/subquery' -import { addressToHex, computeTrancheId, getDateYearsFromNow, getRandomUint, isSameAddress } from '../utils' +import { + addressToHex, + computeTrancheId, + getDateMonthsFromNow, + getDateYearsFromNow, + getRandomUint, + isSameAddress, +} from '../utils' import { CurrencyBalance, Perquintill, Price, Rate, TokenBalance } from '../utils/BN' import { Dec } from '../utils/Decimal' @@ -642,6 +651,30 @@ export type WriteOffGroup = { percentage: Rate } +type InvestorTransaction = { + id: string + timestamp: string + accountId: string + trancheId: string + epochNumber: number + type: InvestorTransactionType + currencyAmount: CurrencyBalance | undefined + tokenAmount: CurrencyBalance | undefined + tokenPrice: Price | undefined + transactionFee: CurrencyBalance | null +} + +type BorrowerTransaction = { + id: string + timestamp: string + poolId: string + accountId: string + epochId: string + loanId: string + type: BorrowerTransactionType + amount: CurrencyBalance | undefined +} + const formatPoolKey = (keys: StorageKey<[u32]>) => (keys.toHuman() as string[])[0].replace(/\D/g, '') const formatLoanKey = (keys: StorageKey<[u32, u32]>) => (keys.toHuman() as string[])[1].replace(/\D/g, '') @@ -1896,8 +1929,8 @@ export function getPoolsModule(inst: Centrifuge) { { poolId, trancheId, - from: from ? from.toISOString() : getDateYearsFromNow(-10).toISOString(), - to: to ? to.toISOString() : getDateYearsFromNow(10).toISOString(), + from: from ? from.toISOString() : getDateMonthsFromNow(-1).toISOString(), + to: to ? to.toISOString() : new Date().toISOString(), } ) @@ -1925,7 +1958,7 @@ export function getPoolsModule(inst: Centrifuge) { tokenPrice: tx.tokenPrice ? new Price(tx.tokenPrice) : undefined, transactionFee: tx.transactionFee ? new CurrencyBalance(tx.transactionFee, 18) : undefined, // native tokenks are always denominated in 18 } - }) as unknown as any[], // TODO: add typing + }) as unknown as InvestorTransaction[], ] }) ) @@ -1933,6 +1966,49 @@ export function getPoolsModule(inst: Centrifuge) { ) } + function getBorrowerTransactions(args: [poolId: string, from?: Date, to?: Date]) { + const [poolId, from, to] = args + + const $query = inst.getSubqueryObservable<{ + borrowerTransactions: { nodes: SubqueryBorrowerTransaction[] } + }>( + `query($poolId: String!, $from: Datetime!, $to: Datetime!) { + borrowerTransactions( + orderBy: TIMESTAMP_ASC, + filter: { + poolId: { equalTo: $poolId }, + timestamp: { greaterThan: $from, lessThan: $to }, + }) { + nodes { + loanId + epochId + type + timestamp + amount + } + } + } + `, + { + poolId, + from: from ? from.toISOString() : getDateMonthsFromNow(-1).toISOString(), + to: to ? to.toISOString() : new Date().toISOString(), + }, + false + ) + + return $query.pipe( + switchMap(() => combineLatest([$query, getPoolCurrency([poolId])])), + map(([data, currency]) => { + return data!.borrowerTransactions.nodes.map((tx) => ({ + ...tx, + amount: tx.amount ? new CurrencyBalance(tx.amount, currency.decimals) : undefined, + timestamp: new Date(tx.timestamp), + })) as unknown as BorrowerTransaction[] + }) + ) + } + function getNativeCurrency() { return inst.getApi().pipe( map((api) => ({ @@ -2693,6 +2769,7 @@ export function getPoolsModule(inst: Centrifuge) { getDailyPoolStates, getMonthlyPoolStates, getInvestorTransactions, + getBorrowerTransactions, getNativeCurrency, getCurrencies, getDailyTrancheStates, diff --git a/centrifuge-js/src/types/subquery.ts b/centrifuge-js/src/types/subquery.ts index 6f73171f74..a496404efc 100644 --- a/centrifuge-js/src/types/subquery.ts +++ b/centrifuge-js/src/types/subquery.ts @@ -47,6 +47,8 @@ export type InvestorTransactionType = | 'REDEEM_EXECUTION' | 'TRANSFER_IN' | 'TRANSFER_OUT' + | 'INVEST_COLLECT' + | 'REDEEM_COLLECT' export type SubqueryInvestorTransaction = { __typename?: 'InvestorTransaction' @@ -62,6 +64,20 @@ export type SubqueryInvestorTransaction = { transactionFee?: number | null } +export type BorrowerTransactionType = 'CREATED' | 'PRICED' | 'BORROWED' | 'REPAID' | 'CLOSED' + +export type SubqueryBorrowerTransaction = { + __typename?: 'BorrowerTransaction' + id: string + timestamp: string + poolId: string + accountId: string + epochId: string + loanId: string + type: BorrowerTransactionType + amount?: number | null +} + export type SubqueryEpoch = { id: string poolId: string diff --git a/centrifuge-js/src/utils/index.ts b/centrifuge-js/src/utils/index.ts index 2963c7871c..8b5f3d027c 100644 --- a/centrifuge-js/src/utils/index.ts +++ b/centrifuge-js/src/utils/index.ts @@ -109,6 +109,11 @@ export function getDateYearsFromNow(years: number) { return new Date(new Date().setFullYear(new Date().getFullYear() + years)) } +export function getDateMonthsFromNow(month: number) { + const date = new Date() + return new Date(date.setMonth(date.getMonth() + month)) +} + export function addressToHex(addr: string) { return u8aToHex(decodeAddress(addr)) } diff --git a/fabric/src/components/DateRange/DateRange.stories.tsx b/fabric/src/components/DateRange/DateRange.stories.tsx new file mode 100644 index 0000000000..63b1eebf61 --- /dev/null +++ b/fabric/src/components/DateRange/DateRange.stories.tsx @@ -0,0 +1,35 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react' +import * as React from 'react' +import { DateRange } from '.' +import { Text } from '../Text' + +export default { + title: 'Components/DateRange', + component: DateRange, +} as ComponentMeta<typeof DateRange> + +type DateRangeStory = ComponentStory<typeof DateRange> +const Template: DateRangeStory = () => { + const [start, setStart] = React.useState(new Date(new Date().setDate(new Date().getDate() - 7))) + const [end, setEnd] = React.useState(new Date()) + + return ( + <div> + <DateRange + end={end} + onSelection={(startDate, endDate) => { + setStart(startDate) + setEnd(endDate) + }} + /> + + <Text as="span" variant="body3" color="textSecondary"> + <time dateTime={start.toISOString()}>{start.toLocaleDateString()}</time> + {' - '} + <time dateTime={end.toISOString()}>{end.toLocaleDateString()}</time> + </Text> + </div> + ) +} + +export const Default = Template.bind({}) diff --git a/fabric/src/components/DateRange/index.tsx b/fabric/src/components/DateRange/index.tsx new file mode 100644 index 0000000000..f84c3e21eb --- /dev/null +++ b/fabric/src/components/DateRange/index.tsx @@ -0,0 +1,71 @@ +import * as React from 'react' +import { Box } from '../Box' +import { Select, SelectOptionItem } from '../Select' + +export type DateRangeProps = { + onSelection: (start: Date, end: Date, range: RangeOptionValue) => void + defaultOption?: RangeOption + end: Date +} + +type RangeOption = SelectOptionItem & { + label: 'Last week' | 'Last month' | 'Last year' + value: 'last-week' | 'last-month' | 'last-year' +} +export type RangeOptionValue = RangeOption['value'] + +export const rangeOptions: RangeOption[] = [ + { + label: 'Last week', + value: 'last-week', + }, + { + label: 'Last month', + value: 'last-month', + }, + { + label: 'Last year', + value: 'last-year', + }, +] + +const getDate = { + 'last-week': ($date: Date) => { + const date = new Date($date) + return new Date(date.setDate(date.getDate() - 7)) + }, + 'last-month': ($date: Date) => { + const date = new Date($date) + return new Date(date.setMonth(date.getMonth() - 1)) + }, + 'last-year': ($date: Date) => { + const date = new Date($date) + return new Date(date.setFullYear(date.getFullYear() - 1)) + }, +} as const + +export function DateRange({ onSelection, defaultOption = rangeOptions[1], end }: DateRangeProps) { + const [value, setValue] = React.useState(defaultOption.value) + const [startDate, setStartDate] = React.useState(getDate[defaultOption.value](end)) + + React.useEffect(() => { + onSelection(startDate, end, value) + }, [startDate, end, value]) + + return ( + <Box> + <Box position="relative"> + <Select + name="date-range" + label="Date range" + options={rangeOptions} + value={value} + onChange={({ target }) => { + setValue(target.value as RangeOptionValue) + setStartDate(getDate[target.value](end)) + }} + /> + </Box> + </Box> + ) +} diff --git a/fabric/src/components/InputBox/index.tsx b/fabric/src/components/InputBox/index.tsx index 38ccb55e79..3ffe332690 100644 --- a/fabric/src/components/InputBox/index.tsx +++ b/fabric/src/components/InputBox/index.tsx @@ -13,6 +13,7 @@ export type InputBoxProps = { rightElement?: React.ReactNode disabled?: boolean active?: boolean + outlined?: boolean } const InputWrapper = styled(Stack)<{ $active?: boolean; $disabled?: boolean }>` diff --git a/fabric/src/index.ts b/fabric/src/index.ts index d2c1b3f3cd..384130c859 100644 --- a/fabric/src/index.ts +++ b/fabric/src/index.ts @@ -7,6 +7,7 @@ export * from './components/Checkbox' export * from './components/Collapsible' export * from './components/Container' export * from './components/CurrencyInput' +export * from './components/DateRange' export * from './components/Dialog' export * from './components/Divider' export * from './components/FabricProvider' From 392c832535a3dadf1148b95971b2a5583474f180 Mon Sep 17 00:00:00 2001 From: Sophia <littlejohn.sophia@gmail.com> Date: Mon, 21 Aug 2023 10:32:37 -0400 Subject: [PATCH 03/39] OnboardingAPI: Fix doc sending to issuer and update remark copy (#1541) * Fix order of middleware to prevent undefined error * Update verbiage for remark signing * Point envs to mainnet for testing --- .../Onboarding/SignSubscriptionAgreement.tsx | 15 ++++++++------- .../src/pages/Onboarding/queries/useSignRemark.ts | 11 ++++++++--- .../controllers/emails/signAndSendDocuments.ts | 3 ++- onboarding-api/src/index.ts | 2 +- 4 files changed, 19 insertions(+), 12 deletions(-) diff --git a/centrifuge-app/src/pages/Onboarding/SignSubscriptionAgreement.tsx b/centrifuge-app/src/pages/Onboarding/SignSubscriptionAgreement.tsx index adcfd44b5e..52b8573a76 100644 --- a/centrifuge-app/src/pages/Onboarding/SignSubscriptionAgreement.tsx +++ b/centrifuge-app/src/pages/Onboarding/SignSubscriptionAgreement.tsx @@ -33,6 +33,11 @@ export const SignSubscriptionAgreement = ({ signedAgreementUrl }: Props) => { const centrifuge = useCentrifuge() const hasSignedAgreement = !!onboardingUser.poolSteps?.[poolId]?.[trancheId]?.signAgreement.completed + const unsignedAgreementUrl = poolMetadata?.onboarding?.tranches?.[trancheId]?.agreement?.uri + ? centrifuge.metadata.parseMetadataUrl(poolMetadata.onboarding.tranches[trancheId].agreement?.uri!) + : !poolId.startsWith('0x') + ? centrifuge.metadata.parseMetadataUrl(GENERIC_SUBSCRIPTION_AGREEMENT) + : null const formik = useFormik({ initialValues: { @@ -40,19 +45,15 @@ export const SignSubscriptionAgreement = ({ signedAgreementUrl }: Props) => { }, validationSchema, onSubmit: () => { - signRemark([`Signed subscription agreement for pool: ${poolId} tranche: ${trancheId}`]) + signRemark([ + `I hereby sign the subscription agreement of pool ${poolId} and tranche ${trancheId}: ${poolMetadata?.onboarding?.tranches?.[trancheId]?.agreement?.uri}`, + ]) }, }) const { mutate: sendDocumentsToIssuer, isLoading: isSending } = useSignAndSendDocuments() const { execute: signRemark, isLoading: isSigningTransaction } = useSignRemark(sendDocumentsToIssuer) - const unsignedAgreementUrl = poolMetadata?.onboarding?.tranches?.[trancheId]?.agreement?.uri - ? centrifuge.metadata.parseMetadataUrl(poolMetadata.onboarding.tranches[trancheId].agreement?.uri!) - : !poolId.startsWith('0x') - ? centrifuge.metadata.parseMetadataUrl(GENERIC_SUBSCRIPTION_AGREEMENT) - : null - // tinlake pools without subdocs cannot accept investors const isPoolClosedToOnboarding = poolId.startsWith('0x') && !unsignedAgreementUrl const isCountrySupported = diff --git a/centrifuge-app/src/pages/Onboarding/queries/useSignRemark.ts b/centrifuge-app/src/pages/Onboarding/queries/useSignRemark.ts index 8ef0443d15..0f450276cb 100644 --- a/centrifuge-app/src/pages/Onboarding/queries/useSignRemark.ts +++ b/centrifuge-app/src/pages/Onboarding/queries/useSignRemark.ts @@ -116,9 +116,14 @@ export const useSignRemark = ( if (selectedAccount && selectedAccount.signer) { const api = await centrifuge.connect(selectedAccount.address, selectedAccount.signer as any) const paymentInfo = await lastValueFrom( - api.remark.signRemark([`Signed subscription agreement for pool: 12324565 tranche: 0xacbdefghijklmn`], { - paymentInfo: selectedAccount.address, - }) + api.remark.signRemark( + [ + `I hereby sign the subscription agreement of pool [POOL_ID] and tranche [TRANCHE_ID]: [IPFS_HASH_OF_TEMPLATE]`, + ], + { + paymentInfo: selectedAccount.address, + } + ) ) const txFee = paymentInfo.partialFee.toDecimal() setExpectedTxFee(txFee) diff --git a/onboarding-api/src/controllers/emails/signAndSendDocuments.ts b/onboarding-api/src/controllers/emails/signAndSendDocuments.ts index 01dca33a63..4e5984223f 100644 --- a/onboarding-api/src/controllers/emails/signAndSendDocuments.ts +++ b/onboarding-api/src/controllers/emails/signAndSendDocuments.ts @@ -47,7 +47,8 @@ export const signAndSendDocumentsController = async ( throw new HttpError(400, 'Country not supported by issuer') } - const remark = `Signed subscription agreement for pool: ${poolId} tranche: ${trancheId}` + const remark = `I hereby sign the subscription agreement of pool ${poolId} and tranche ${trancheId}: ${metadata + .onboarding.tranches[trancheId].agreement?.uri!}` await new NetworkSwitch(wallet.network).validateRemark(wallet, transactionInfo, remark) diff --git a/onboarding-api/src/index.ts b/onboarding-api/src/index.ts index 86ee172898..a3287bbdca 100644 --- a/onboarding-api/src/index.ts +++ b/onboarding-api/src/index.ts @@ -61,7 +61,7 @@ onboarding.post('/setVerifiedIdentity', verifyAuth, setVerifiedIdentityControlle onboarding.post('/uploadTaxInfo', verifyAuth, fileUpload, uploadTaxInfoController) // pool steps -onboarding.post('/signAndSendDocuments', canOnboardToTinlakeTranche, verifyAuth, signAndSendDocumentsController) +onboarding.post('/signAndSendDocuments', verifyAuth, canOnboardToTinlakeTranche, signAndSendDocumentsController) onboarding.post('/updateInvestorStatus', updateInvestorStatusController) // getters From 1d1e0bacc12d9c860495020723301c1035bc7fde Mon Sep 17 00:00:00 2001 From: Sophia <littlejohn.sophia@gmail.com> Date: Mon, 21 Aug 2023 14:21:27 -0400 Subject: [PATCH 04/39] OnboardingAPI: Add debug email address (#1543) * Add debug email address * Remove migrations --- .../emails/signAndSendDocuments.ts | 5 +- .../controllers/migrations/migrateWallets.ts | 68 ------------------- .../src/emails/sendDocumentsMessage.ts | 5 +- onboarding-api/src/index.ts | 4 -- .../annotateAgreementAndSignAsInvestor.ts | 2 +- 5 files changed, 7 insertions(+), 77 deletions(-) delete mode 100644 onboarding-api/src/controllers/migrations/migrateWallets.ts diff --git a/onboarding-api/src/controllers/emails/signAndSendDocuments.ts b/onboarding-api/src/controllers/emails/signAndSendDocuments.ts index 4e5984223f..d140c351c7 100644 --- a/onboarding-api/src/controllers/emails/signAndSendDocuments.ts +++ b/onboarding-api/src/controllers/emails/signAndSendDocuments.ts @@ -19,6 +19,7 @@ export const signAndSendDocumentsInput = object({ poolId: string().required(), trancheId: string().required(), transactionInfo: transactionInfoSchema.required(), + debugEmail: string().optional(), // sends email to specified address instead of issuer }) export const signAndSendDocumentsController = async ( @@ -28,7 +29,7 @@ export const signAndSendDocumentsController = async ( try { await validateInput(req.body, signAndSendDocumentsInput) - const { poolId, trancheId, transactionInfo } = req.body + const { poolId, trancheId, transactionInfo, debugEmail } = req.body const { wallet } = req const { poolSteps, globalSteps, investorType, name, email, ...user } = await fetchUser(wallet) @@ -74,7 +75,7 @@ export const signAndSendDocumentsController = async ( ) if ((investorType === 'entity' && globalSteps.verifyBusiness.completed) || investorType === 'individual') { - await sendDocumentsMessage(wallet, poolId, trancheId, signedAgreementPDF) + await sendDocumentsMessage(wallet, poolId, trancheId, signedAgreementPDF, debugEmail) } const updatedUser: Subset<OnboardingUser> = { diff --git a/onboarding-api/src/controllers/migrations/migrateWallets.ts b/onboarding-api/src/controllers/migrations/migrateWallets.ts deleted file mode 100644 index a430f8c371..0000000000 --- a/onboarding-api/src/controllers/migrations/migrateWallets.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { userCollection } from '../../database' -import { reportHttpError } from '../../utils/httpError' - -/** - * - * Jul 26th 2023 - * - * Migrate wallets to new schema - * - * Old: {wallet : [{address: string, network: string}]} - * New: {wallets: {evm: string[], substrate: string[], evmOnSubstrate: string[]} - * - * Purpose: we need to be able to query the users just by wallet address and not necessarily by network - * so that evm wallets can be used both for cent-chain and for tinlake pools. - * - * With the old implementation we would have had to query all users before filtering for just the address - * which is a limitation by firestore (query arrays of objects for just one property isn't supported) - * - * This migration needs to be run in the prod database when the next release goes out. Then this method can be deleted. - */ -export const migrateWalletsController = async (req, res) => { - let failedMigrations = 0 - try { - const userSanpshot = await userCollection.get() - const users = userSanpshot?.docs.map((doc) => { - return doc.data() - }) - - for (const user of users) { - const { wallet, ...rest } = user - if (wallet?.length > 0) { - const [newWallets] = wallet.map((wal) => { - if (wal.network === 'evm') { - return { - evm: [wal.address], - substrate: [], - evmOnSubstrate: [], - } - } else if (wal.network === 'substrate') { - return { - evm: [], - substrate: [wal.address], - evmOnSubstrate: [], - } - } - return { - evm: [], - substrate: [], - evmOnSubstrate: [], - } - }) - const theUser = { - ...rest, - wallets: newWallets, - } - await userCollection.doc(wallet[0].address).set(theUser) - } else { - failedMigrations = failedMigrations + 1 - console.log(`user wallet not found or already migrated: ${JSON.stringify(user)}`) - } - } - - return res.json({ complete: true, failedMigrations, totalUsers: users.length }) - } catch (e) { - const error = reportHttpError(e) - return res.status(error.code).send({ error: error.message }) - } -} diff --git a/onboarding-api/src/emails/sendDocumentsMessage.ts b/onboarding-api/src/emails/sendDocumentsMessage.ts index b308d77cf3..e75e502ab4 100644 --- a/onboarding-api/src/emails/sendDocumentsMessage.ts +++ b/onboarding-api/src/emails/sendDocumentsMessage.ts @@ -15,7 +15,8 @@ export const sendDocumentsMessage = async ( wallet: Request['wallet'], poolId: string, trancheId: string, - signedAgreement: Uint8Array + signedAgreement: Uint8Array, + debugEmail?: string ) => { const { metadata, pool } = await new NetworkSwitch(wallet.network).getPoolById(poolId) const payload: UpdateInvestorStatusPayload = { wallet, poolId, trancheId } @@ -36,7 +37,7 @@ export const sendDocumentsMessage = async ( { to: [ { - email: metadata?.pool?.issuer?.email, + email: debugEmail ?? metadata?.pool?.issuer?.email, }, ], dynamic_template_data: { diff --git a/onboarding-api/src/index.ts b/onboarding-api/src/index.ts index a3287bbdca..598a324e10 100644 --- a/onboarding-api/src/index.ts +++ b/onboarding-api/src/index.ts @@ -13,7 +13,6 @@ import { initProxiesController } from './controllers/init/initProxies' import { confirmOwnersController } from './controllers/kyb/confirmOwners' import { manualKybCallbackController } from './controllers/kyb/manualKybCallback' import { verifyBusinessController } from './controllers/kyb/verifyBusiness' -import { migrateWalletsController } from './controllers/migrations/migrateWallets' import { getGlobalOnboardingStatusController } from './controllers/user/getGlobalOnboardingStatus' import { getTaxInfoController } from './controllers/user/getTaxInfo' import { getUserController } from './controllers/user/getUser' @@ -74,7 +73,4 @@ onboarding.get('/getTaxInfo', verifyAuth, getTaxInfoController) // init onboarding.get('/initProxies', initProxiesController) -// migrations -onboarding.get('/migrateWallets', migrateWalletsController) - exports.onboarding = onboarding diff --git a/onboarding-api/src/utils/annotateAgreementAndSignAsInvestor.ts b/onboarding-api/src/utils/annotateAgreementAndSignAsInvestor.ts index cc31a538ea..6ab92f7793 100644 --- a/onboarding-api/src/utils/annotateAgreementAndSignAsInvestor.ts +++ b/onboarding-api/src/utils/annotateAgreementAndSignAsInvestor.ts @@ -8,7 +8,7 @@ import { HttpError } from './httpError' import { getCentrifuge } from './networks/centrifuge' import { NetworkSwitch } from './networks/networkSwitch' -interface SignatureInfo extends InferType<typeof signAndSendDocumentsInput> { +interface SignatureInfo extends Omit<InferType<typeof signAndSendDocumentsInput>, 'debugEmail'> { name: string wallet: Request['wallet'] email: string From 076d5ccac8a96b30441ceb17fee476c773910f33 Mon Sep 17 00:00:00 2001 From: Onno Visser <visser.onno@gmail.com> Date: Tue, 22 Aug 2023 11:38:38 +0100 Subject: [PATCH 05/39] Centrifuge App: Remove loan portfolio runtime call (#1528) --- centrifuge-js/src/CentrifugeBase.ts | 35 ------------ centrifuge-js/src/modules/pools.ts | 85 +++++++++++++---------------- 2 files changed, 39 insertions(+), 81 deletions(-) diff --git a/centrifuge-js/src/CentrifugeBase.ts b/centrifuge-js/src/CentrifugeBase.ts index 460ddc2218..439e7cd242 100644 --- a/centrifuge-js/src/CentrifugeBase.ts +++ b/centrifuge-js/src/CentrifugeBase.ts @@ -113,10 +113,6 @@ const parachainTypes = { Staking: 'StakingCurrency', }, }, - ActiveLoanInfo: { - interest_accrued: 'u128', - present_value: 'u128', - }, } const parachainRpcMethods: Record<string, Record<string, DefinitionRpc>> = { @@ -191,37 +187,6 @@ const parachainRuntimeApi: DefinitionsCall = { version: 1, }, ], - LoansApi: [ - { - methods: { - portfolio: { - description: 'Get active pool loan', - params: [ - { - name: 'pool_id', - type: 'u64', - }, - ], - type: 'Vec<(u64, ActiveLoanInfo)>', - }, - portfolio_loan: { - description: 'Get active pool loan', - params: [ - { - name: 'pool_id', - type: 'u64', - }, - { - name: 'loan_id', - type: 'u64', - }, - ], - type: 'Option<ActiveLoanInfo>', - }, - }, - version: 1, - }, - ], } type Events = ISubmittableResult['events'] diff --git a/centrifuge-js/src/modules/pools.ts b/centrifuge-js/src/modules/pools.ts index 03e2aac18b..c009612d34 100644 --- a/centrifuge-js/src/modules/pools.ts +++ b/centrifuge-js/src/modules/pools.ts @@ -460,8 +460,8 @@ export type ActiveLoan = { originationDate: string normalizedDebt: CurrencyBalance outstandingDebt: CurrencyBalance - presentValue: CurrencyBalance - interestAccrued: CurrencyBalance + outstandingPrincipal: CurrencyBalance + outstandingInterest: CurrencyBalance } // transformed type for UI @@ -2238,7 +2238,6 @@ export function getPoolsModule(inst: Centrifuge) { api.query.interestAccrual.rates(), api.query.interestAccrual.lastUpdated(), api.query.ormlAssetRegistry.metadata((poolValue.toHuman() as any).currency), - api.call.loansApi.portfolio(poolId), ]).pipe(take(1)) }), map( @@ -2250,26 +2249,10 @@ export function getPoolsModule(inst: Centrifuge) { rateValues, interestLastUpdated, rawCurrency, - rawPortfolio, ]) => { const currency = rawCurrency.toHuman() as AssetCurrencyData const rates = rateValues.toPrimitive() as InterestAccrual[] - const activeLoansPortfolio: Record< - string, - { - interestAccrued: CurrencyBalance - presentValue: CurrencyBalance - } - > = {} - ;(rawPortfolio as any).forEach(([key, value]: [Codec, Codec]) => { - const data = value.toPrimitive() as any - activeLoansPortfolio[String(key.toPrimitive())] = { - interestAccrued: new CurrencyBalance(data.interest_accrued, 27), // not sure - presentValue: new CurrencyBalance(data.present_value, 27), - } - }) - const oraclePrices: Record< string, { @@ -2382,7 +2365,6 @@ export function getPoolsModule(inst: Centrifuge) { new Rate(rate.interestRatePerSec).toApr().toDecimalPlaces(4).toString() === sharedInfo.pricing.interestRate.toDecimal().toString() ) - const portfolio = activeLoansPortfolio[loanId.toString()] const penaltyRate = 'external' in loan.pricing ? loan.pricing.external.interest.penalty @@ -2397,29 +2379,42 @@ export function getPoolsModule(inst: Centrifuge) { percentage: new Rate(loan.writeOffPercentage), } - const outstandingDebt = - 'internal' in loan.pricing - ? getOutstandingDebt( - loan, - currency.decimals, - interestLastUpdated.toPrimitive() as number, - interestData - ) - : CurrencyBalance.fromFloat( - new CurrencyBalance(loan.pricing.external.outstandingQuantity, 27) - .toDecimal() - .mul( - new CurrencyBalance( - sharedInfo.pricing.oracle?.value ?? new BN(0), - currency.decimals - ).toDecimal() - ), - currency.decimals - ) - 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(), @@ -2440,8 +2435,8 @@ export function getPoolsModule(inst: Centrifuge) { originationDate: new Date(loan.originationDate * 1000).toISOString(), outstandingDebt, normalizedDebt: new CurrencyBalance(normalizedDebt, currency.decimals), - interestAccrued: portfolio.interestAccrued, - presentValue: portfolio.presentValue, + outstandingPrincipal, + outstandingInterest, } } ) @@ -2788,14 +2783,12 @@ function getOutstandingDebt( accrual?: InterestAccrual ) { if (!accrual) return new CurrencyBalance(0, currencyDecimals) - if (!('internal' in loan.pricing)) 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.normalizedDebt + 'internal' in loan.pricing ? loan.pricing.internal.interest.normalizedAcc - : loan.normalizedDebt + : loan.pricing.external.interest.normalizedAcc const normalizedDebt = new CurrencyBalance(balance, currencyDecimals).toDecimal() const secondsSinceUpdated = Date.now() / 1000 - lastUpdated From 6916d01612ea21cb0776ee91e65c10dce723ad2a Mon Sep 17 00:00:00 2001 From: Jeroen Offerijns <Offerijns@users.noreply.github.com> Date: Tue, 22 Aug 2023 13:04:02 +0200 Subject: [PATCH 06/39] Add extension period (#1527) * Add extension period * Start updating runtime API * Fix merge conflicts --- centrifuge-app/src/components/Tooltips.tsx | 4 ++++ .../src/pages/IssuerCreatePool/validate.ts | 1 + .../src/pages/IssuerPool/Assets/CreateLoan.tsx | 4 ++++ .../src/pages/IssuerPool/Assets/PricingInput.tsx | 8 ++++++++ centrifuge-app/src/pages/Loan/PricingValues.tsx | 3 +++ centrifuge-js/src/modules/pools.ts | 15 ++++++++++++++- 6 files changed, 34 insertions(+), 1 deletion(-) diff --git a/centrifuge-app/src/components/Tooltips.tsx b/centrifuge-app/src/components/Tooltips.tsx index 29160f63fc..3d5b519353 100644 --- a/centrifuge-app/src/components/Tooltips.tsx +++ b/centrifuge-app/src/components/Tooltips.tsx @@ -238,6 +238,10 @@ const tooltipText = { label: 'Applied write-off', body: 'The applied write-off is the amount of the outstanding financing that has been written off by the issuer.', }, + maturityExtensionDays: { + label: 'Extension period', + body: 'Number of days the maturity can be extended without restrictions.', + }, } export type TooltipsProps = { diff --git a/centrifuge-app/src/pages/IssuerCreatePool/validate.ts b/centrifuge-app/src/pages/IssuerCreatePool/validate.ts index 08032ab362..75824febb5 100644 --- a/centrifuge-app/src/pages/IssuerCreatePool/validate.ts +++ b/centrifuge-app/src/pages/IssuerCreatePool/validate.ts @@ -59,6 +59,7 @@ export const validate = { lossGivenDefault: combine(required(), nonNegativeNumber(), max(100)), maxBorrowQuantity: combine(required(), nonNegativeNumber(), max(Number.MAX_SAFE_INTEGER)), Isin: combine(required(), minLength(12), maxLength(12), isin()), + maturityExtensionDays: combine(required(), positiveNumber(), max(365 * 2 /* 2 years */)), // write-off groups days: combine(required(), integer(), nonNegativeNumber(), max(Number.MAX_SAFE_INTEGER)), diff --git a/centrifuge-app/src/pages/IssuerPool/Assets/CreateLoan.tsx b/centrifuge-app/src/pages/IssuerPool/Assets/CreateLoan.tsx index 796c16bc7b..58b4f511c3 100644 --- a/centrifuge-app/src/pages/IssuerPool/Assets/CreateLoan.tsx +++ b/centrifuge-app/src/pages/IssuerPool/Assets/CreateLoan.tsx @@ -61,6 +61,7 @@ export type CreateLoanFormValues = { maxBorrowAmount: 'upToTotalBorrowed' | 'upToOutstandingDebt' value: number | '' maturityDate: string + maturityExtensionDays: number advanceRate: number | '' interestRate: number | '' probabilityOfDefault: number | '' @@ -214,6 +215,7 @@ function IssuerCreateLoan() { maxBorrowAmount: 'upToTotalBorrowed', value: '', maturityDate: '', + maturityExtensionDays: 0, advanceRate: '', interestRate: '', probabilityOfDefault: '', @@ -236,6 +238,7 @@ function IssuerCreateLoan() { : 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), } @@ -244,6 +247,7 @@ function IssuerCreateLoan() { maxBorrowAmount: values.pricing.maxBorrowAmount, value: CurrencyBalance.fromFloat(values.pricing.value, decimals), maturityDate: new Date(values.pricing.maturityDate), + maturityExtensionDays: values.pricing.maturityExtensionDays, advanceRate: Rate.fromPercent(values.pricing.advanceRate), interestRate: Rate.fromPercent(values.pricing.interestRate), probabilityOfDefault: Rate.fromPercent(values.pricing.probabilityOfDefault || 0), diff --git a/centrifuge-app/src/pages/IssuerPool/Assets/PricingInput.tsx b/centrifuge-app/src/pages/IssuerPool/Assets/PricingInput.tsx index f5796eeae0..23666b29b9 100644 --- a/centrifuge-app/src/pages/IssuerPool/Assets/PricingInput.tsx +++ b/centrifuge-app/src/pages/IssuerPool/Assets/PricingInput.tsx @@ -105,6 +105,14 @@ export function PricingInput({ poolId }: { poolId: string }) { // Max 5 years from now max={new Date(Date.now() + 5 * 365 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10)} /> + <FieldWithErrorMessage + as={NumberInput} + label={<Tooltips type="maturityExtensionDays" variant="secondary" label="Extension period*" />} + placeholder={0} + rightElement="days" + name="pricing.maturityExtensionDays" + validate={validate.maturityExtensionDays} + /> <FieldWithErrorMessage as={NumberInput} diff --git a/centrifuge-app/src/pages/Loan/PricingValues.tsx b/centrifuge-app/src/pages/Loan/PricingValues.tsx index 04299c4279..7ccdddb626 100644 --- a/centrifuge-app/src/pages/Loan/PricingValues.tsx +++ b/centrifuge-app/src/pages/Loan/PricingValues.tsx @@ -39,6 +39,9 @@ export function PricingValues({ loan: { pricing }, pool }: Props) { return ( <> {pricing.maturityDate && <LabelValueStack label="Maturity date" value={formatDate(pricing.maturityDate)} />} + {pricing.maturityExtensionDays && ( + <LabelValueStack label="Extension period" value={`${pricing.maturityExtensionDays} days`} /> + )} {isOutstandingDebtOrDiscountedCashFlow && ( <LabelValueStack label="Advance rate" diff --git a/centrifuge-js/src/modules/pools.ts b/centrifuge-js/src/modules/pools.ts index c009612d34..a95365a55e 100644 --- a/centrifuge-js/src/modules/pools.ts +++ b/centrifuge-js/src/modules/pools.ts @@ -28,6 +28,7 @@ import { Dec } from '../utils/Decimal' const PerquintillBN = new BN(10).pow(new BN(18)) const PriceBN = new BN(10).pow(new BN(27)) const MaxU128 = '340282366920938463463374607431768211455' +const SEC_PER_DAY = 24 * 60 * 60 type AdminRole = | 'PoolAdmin' @@ -78,6 +79,7 @@ type LoanInfoInput = maxBorrowAmount: 'upToTotalBorrowed' | 'upToOutstandingDebt' value: BN maturityDate: Date + maturityExtensionDays: number advanceRate: BN interestRate: BN } @@ -86,6 +88,7 @@ type LoanInfoInput = maxBorrowAmount: BN | null Isin: string maturityDate: Date + maturityExtensionDays: number interestRate: BN notional: BN } @@ -97,6 +100,7 @@ type LoanInfoInput = maxBorrowAmount: 'upToTotalBorrowed' | 'upToOutstandingDebt' value: BN maturityDate: Date + maturityExtensionDays: number advanceRate: BN interestRate: BN } @@ -374,6 +378,7 @@ export type InternalPricingInfo = { maxBorrowAmount: 'upToTotalBorrowed' | 'upToOutstandingDebt' value: CurrencyBalance maturityDate: string + maturityExtensionDays: number advanceRate: Rate interestRate: Rate probabilityOfDefault?: Rate @@ -387,6 +392,7 @@ export type ExternalPricingInfo = { outstandingQuantity: CurrencyBalance Isin: string maturityDate: string + maturityExtensionDays: number oracle: { value: CurrencyBalance timestamp: number @@ -1267,7 +1273,12 @@ export function getPoolsModule(inst: Centrifuge) { const info: LoanInfoData = { /// Specify the repayments schedule of the loan schedule: { - maturity: { fixed: { date: Math.round(infoInput.maturityDate.getTime() / 1000), extension: 0 } }, + maturity: { + fixed: { + date: Math.round(infoInput.maturityDate.getTime() / 1000), + extension: infoInput.maturityExtensionDays * SEC_PER_DAY, + }, + }, interestPayments: 'None', payDownSchedule: 'None', }, @@ -2310,6 +2321,7 @@ export function getPoolsModule(inst: Centrifuge) { : 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, @@ -2337,6 +2349,7 @@ export function getPoolsModule(inst: Centrifuge) { : undefined, interestRate: new Rate(interestRate), maturityDate: new Date(info.schedule.maturity.fixed.date * 1000).toISOString(), + maturityExtensionDays: info.schedule.maturity.fixed.extension / SEC_PER_DAY, }, } } From 4777512f670b18d208ac2c086130366cc1e89370 Mon Sep 17 00:00:00 2001 From: Sophia <littlejohn.sophia@gmail.com> Date: Tue, 22 Aug 2023 11:40:18 -0400 Subject: [PATCH 07/39] OnboardingAPI: liquidity pool authentication and remark signing (#1539) * Add button to onboard to junior * Add chain id to auth token * Add chain id types * Add base network behind debug flag * Fix error with liquidity stakes * Implement remark signing for base networks * Fix types * Hide onboarding button for not yet supported networks * Add metadata to extended chain info for centrfuge chains * Localize countdown hook to prevent rerenders * Remove extra chars * Remove avalanche and check for evm on cent chain in api * Fix error from throwing in invest redeem component --- .../src/components/DebugFlags/config.ts | 4 +- .../components/InvestRedeem/InvestRedeem.tsx | 12 ++-- .../InvestRedeemCentrifugeProvider.tsx | 6 +- .../InvestRedeem/InvestRedeemProvider.tsx | 6 ++ .../LiquidityRewardsClaimer.tsx | 8 ++- .../LiquidityRewardsProvider.tsx | 4 +- .../src/components/LiquidityRewards/types.ts | 1 - .../src/components/OnboardingAuthProvider.tsx | 16 +++-- centrifuge-app/src/components/Root.tsx | 24 +++---- centrifuge-app/src/config.ts | 3 +- .../queries/useSignAndSendDocuments.ts | 4 +- .../pages/Onboarding/queries/useSignRemark.ts | 11 ++- .../src/pages/Pool/Overview/index.tsx | 8 ++- centrifuge-js/src/modules/rewards.ts | 9 ++- .../WalletProvider/ConnectionGuard.tsx | 5 +- .../WalletProvider/WalletDialog.tsx | 18 +++-- .../WalletProvider/WalletProvider.tsx | 6 +- .../components/WalletProvider/evm/chains.ts | 18 ++--- .../controllers/auth/authenticateWallet.ts | 61 +---------------- onboarding-api/src/database/index.ts | 1 + onboarding-api/src/middleware/verifyAuth.ts | 6 +- .../annotateAgreementAndSignAsInvestor.ts | 7 +- .../src/utils/networks/centrifuge.ts | 38 ++++++++++- onboarding-api/src/utils/networks/evm.ts | 68 +++++++++++++++++++ .../src/utils/networks/networkSwitch.ts | 27 ++++---- onboarding-api/src/utils/networks/tinlake.ts | 48 ++++--------- onboarding-api/src/utils/types.d.ts | 1 + 27 files changed, 232 insertions(+), 188 deletions(-) create mode 100644 onboarding-api/src/utils/networks/evm.ts diff --git a/centrifuge-app/src/components/DebugFlags/config.ts b/centrifuge-app/src/components/DebugFlags/config.ts index 99d41dc24f..866e55c54c 100644 --- a/centrifuge-app/src/components/DebugFlags/config.ts +++ b/centrifuge-app/src/components/DebugFlags/config.ts @@ -34,7 +34,7 @@ export type Key = | 'evmAddress' | 'batchMintNFTs' | 'persistDebugFlags' - | 'showAvalanche' + | 'showBase' | 'showUnusedFlags' | 'allowInvestBelowMin' | 'alternativeTheme' @@ -68,7 +68,7 @@ export const flagsConfig: Record<Key, DebugFlagConfig> = { default: false, alwaysShow: true, }, - showAvalanche: { + showBase: { type: 'checkbox', default: false, alwaysShow: true, diff --git a/centrifuge-app/src/components/InvestRedeem/InvestRedeem.tsx b/centrifuge-app/src/components/InvestRedeem/InvestRedeem.tsx index ebfbc91ad3..caf1a0e0ac 100644 --- a/centrifuge-app/src/components/InvestRedeem/InvestRedeem.tsx +++ b/centrifuge-app/src/components/InvestRedeem/InvestRedeem.tsx @@ -26,7 +26,7 @@ import css from '@styled-system/css' import Decimal from 'decimal.js-light' import { Field, FieldProps, Form, FormikErrors, FormikProvider, useFormik } from 'formik' import * as React from 'react' -import { useHistory } from 'react-router-dom' +import { useHistory, useParams } from 'react-router-dom' import styled from 'styled-components' import { Dec } from '../../utils/Decimal' import { formatBalance, roundDown } from '../../utils/formatting' @@ -310,19 +310,19 @@ function InvestRedeemInner({ view, setView, setTrancheId, networks }: InnerProps return null } -const OnboardingButton = ({ networks }: { networks: Network[] | undefined }) => { +const OnboardingButton = ({ networks }: { networks: Network[] | undefined; trancheId?: string }) => { const { showWallets, showNetworks, connectedType } = useWallet() const { state } = useInvestRedeem() - const pool = usePool(state.poolId) + const { pid: poolId } = useParams<{ pid: string }>() + const pool = usePool(poolId) const { data: metadata } = usePoolMetadata(pool) const isTinlakePool = pool.id.startsWith('0x') + const history = useHistory() const trancheName = state.trancheId.split('-')[1] === '0' ? 'junior' : 'senior' - const centPoolInvestStatus = metadata?.onboarding?.tranches?.[state.trancheId].openForOnboarding ? 'open' : 'closed' + const centPoolInvestStatus = metadata?.onboarding?.tranches?.[state?.trancheId]?.openForOnboarding ? 'open' : 'closed' const investStatus = isTinlakePool ? metadata?.pool?.newInvestmentsStatus?.[trancheName] : centPoolInvestStatus - const history = useHistory() - const getOnboardingButtonText = () => { if (connectedType) { if (investStatus === 'request') { diff --git a/centrifuge-app/src/components/InvestRedeem/InvestRedeemCentrifugeProvider.tsx b/centrifuge-app/src/components/InvestRedeem/InvestRedeemCentrifugeProvider.tsx index 3fa9789489..449cf540ee 100644 --- a/centrifuge-app/src/components/InvestRedeem/InvestRedeemCentrifugeProvider.tsx +++ b/centrifuge-app/src/components/InvestRedeem/InvestRedeemCentrifugeProvider.tsx @@ -23,9 +23,7 @@ export function InvestRedeemCentrifugeProvider({ poolId, trancheId, children }: const tranche = pool.tranches.find((t) => t.id === trancheId) const { data: metadata, isLoading: isMetadataLoading } = usePoolMetadata(pool) const trancheMeta = metadata?.tranches?.[trancheId] - const { - state: { combinedStakes }, - } = useLiquidityRewards() + const { state: liquidityState } = useLiquidityRewards() if (!tranche) throw new Error(`Token not found. Pool id: ${poolId}, token id: ${trancheId}`) @@ -35,7 +33,7 @@ export function InvestRedeemCentrifugeProvider({ poolId, trancheId, children }: const price = tranche.tokenPrice?.toDecimal() ?? Dec(1) const investToCollect = order?.payoutTokenAmount.toDecimal() ?? Dec(0) const pendingRedeem = order?.remainingRedeemToken.toDecimal() ?? Dec(0) - const stakedAmount = combinedStakes ?? Dec(0) + const stakedAmount = liquidityState?.combinedStakes ?? Dec(0) const combinedBalance = trancheBalance.add(investToCollect).add(pendingRedeem).add(stakedAmount) const investmentValue = combinedBalance.mul(price) const poolCurBalance = diff --git a/centrifuge-app/src/components/InvestRedeem/InvestRedeemProvider.tsx b/centrifuge-app/src/components/InvestRedeem/InvestRedeemProvider.tsx index 0d3f293db0..beac16627d 100644 --- a/centrifuge-app/src/components/InvestRedeem/InvestRedeemProvider.tsx +++ b/centrifuge-app/src/components/InvestRedeem/InvestRedeemProvider.tsx @@ -1,3 +1,4 @@ +import { useWallet } from '@centrifuge/centrifuge-react' import * as React from 'react' import { InvestRedeemCentrifugeProvider } from './InvestRedeemCentrifugeProvider' import { InvestRedeemTinlakeProvider } from './InvestRedeemTinlakeProvider' @@ -13,6 +14,11 @@ export function useInvestRedeem() { export function InvestRedeemProvider(props: Props) { const isTinlakePool = props.poolId.startsWith('0x') + const { connectedNetwork } = useWallet() + if (connectedNetwork && [1, 5, 8453, 84531].includes(connectedNetwork as any)) { + return null + } + const Comp = isTinlakePool ? InvestRedeemTinlakeProvider : InvestRedeemCentrifugeProvider return <Comp {...props} /> diff --git a/centrifuge-app/src/components/LiquidityRewards/LiquidityRewardsClaimer.tsx b/centrifuge-app/src/components/LiquidityRewards/LiquidityRewardsClaimer.tsx index d8d50248b0..2cd6a2f5bd 100644 --- a/centrifuge-app/src/components/LiquidityRewards/LiquidityRewardsClaimer.tsx +++ b/centrifuge-app/src/components/LiquidityRewards/LiquidityRewardsClaimer.tsx @@ -2,13 +2,15 @@ import { Box, Button, Card, Shelf, Stack, Text } from '@centrifuge/fabric' import * as React from 'react' import { Dec } from '../../utils/Decimal' import { formatBalance, formatBalanceAbbreviated } from '../../utils/formatting' +import { useClaimCountdown } from './hooks' import { useLiquidityRewards } from './LiquidityRewardsContext' export function LiquidityRewardsClaimer() { const { - state: { countdown, rewards, canClaim, isLoading, nativeCurrency }, + state: { rewards, canClaim, isLoading, nativeCurrency }, actions: { claim }, } = useLiquidityRewards() + const claimCountdown = useClaimCountdown() const rewardsAmount = rewards && !rewards?.isZero() @@ -36,9 +38,9 @@ export function LiquidityRewardsClaimer() { </Button> </Shelf> - {!!countdown && ( + {!!claimCountdown && ( <Text as="span" variant="body3"> - New rounds of rewards will be available in {countdown} + New rounds of rewards will be available in {claimCountdown} </Text> )} </Stack> diff --git a/centrifuge-app/src/components/LiquidityRewards/LiquidityRewardsProvider.tsx b/centrifuge-app/src/components/LiquidityRewards/LiquidityRewardsProvider.tsx index 74ed6db15d..909001841f 100644 --- a/centrifuge-app/src/components/LiquidityRewards/LiquidityRewardsProvider.tsx +++ b/centrifuge-app/src/components/LiquidityRewards/LiquidityRewardsProvider.tsx @@ -5,7 +5,7 @@ import * as React from 'react' import { Dec } from '../../utils/Decimal' import { useAddress } from '../../utils/useAddress' import { usePendingCollect, usePool } from '../../utils/usePools' -import { useAccountStakes, useClaimCountdown, useComputeLiquidityRewards, useRewardCurrencyGroup } from './hooks' +import { useAccountStakes, useComputeLiquidityRewards, useRewardCurrencyGroup } from './hooks' import { LiquidityRewardsContext } from './LiquidityRewardsContext' import { LiquidityRewardsActions, LiquidityRewardsProviderProps, LiquidityRewardsState } from './types' @@ -22,7 +22,6 @@ function Provider({ poolId, trancheId, children }: LiquidityRewardsProviderProps const stakes = useAccountStakes(address, poolId, trancheId) const rewards = useComputeLiquidityRewards(address, poolId, trancheId) const balances = useBalances(address) - const countdown = useClaimCountdown() const rewardCurrencyGroup = useRewardCurrencyGroup(poolId, trancheId) const trancheBalance = @@ -52,7 +51,6 @@ function Provider({ poolId, trancheId, children }: LiquidityRewardsProviderProps const state: LiquidityRewardsState = { tranche, - countdown, rewards, stakeableAmount, combinedStakes, diff --git a/centrifuge-app/src/components/LiquidityRewards/types.ts b/centrifuge-app/src/components/LiquidityRewards/types.ts index d0f951b1d1..d3e7eeb4d2 100644 --- a/centrifuge-app/src/components/LiquidityRewards/types.ts +++ b/centrifuge-app/src/components/LiquidityRewards/types.ts @@ -9,7 +9,6 @@ export type LiquidityRewardsProviderProps = { export type LiquidityRewardsState = { tranche: Token | undefined - countdown: ClaimCountDown | null rewards: Decimal | null | undefined stakeableAmount: Decimal | null combinedStakes: Decimal | null diff --git a/centrifuge-app/src/components/OnboardingAuthProvider.tsx b/centrifuge-app/src/components/OnboardingAuthProvider.tsx index b06c6a15b1..53cfb23100 100644 --- a/centrifuge-app/src/components/OnboardingAuthProvider.tsx +++ b/centrifuge-app/src/components/OnboardingAuthProvider.tsx @@ -16,7 +16,7 @@ const AUTHORIZED_ONBOARDING_PROXY_TYPES = ['Any', 'Invest', 'NonTransfer', 'NonP export function OnboardingAuthProvider({ children }: { children: React.ReactNode }) { const { substrate: { selectedWallet, selectedProxies, selectedAccount, evmChainId }, - evm: { selectedAddress }, + evm: { selectedAddress, ...evm }, isEvmOnSubstrate, } = useWallet() const cent = useCentrifuge() @@ -51,9 +51,9 @@ export function OnboardingAuthProvider({ children }: { children: React.ReactNode if (selectedAccount?.address && selectedWallet?.signer) { await loginWithSubstrate(selectedAccount?.address, selectedWallet.signer, cent, proxy) } else if (isEvmOnSubstrate && selectedAddress && provider?.getSigner()) { - await loginWithEvm(selectedAddress, provider.getSigner(), evmChainId) + await loginWithEvm(selectedAddress, provider.getSigner(), evmChainId, isEvmOnSubstrate) } else if (selectedAddress && provider?.getSigner()) { - await loginWithEvm(selectedAddress, provider.getSigner()) + await loginWithEvm(selectedAddress, provider.getSigner(), evm.chainId) } throw new Error('network not supported') } catch { @@ -175,13 +175,14 @@ const loginWithSubstrate = async (hexAddress: string, signer: Wallet['signer'], const { token, payload } = await cent.auth.generateJw3t(address, signer) if (token) { + const centChainId = await cent.getChainId() const authTokenRes = await fetch(`${import.meta.env.REACT_APP_ONBOARDING_API_URL}/authenticateWallet`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, credentials: 'include', - body: JSON.stringify({ jw3t: token, nonce, network: 'substrate' }), + body: JSON.stringify({ jw3t: token, nonce, network: 'substrate', chainId: centChainId }), }) if (authTokenRes.status !== 200) { throw new Error('Failed to authenticate wallet') @@ -196,7 +197,7 @@ const loginWithSubstrate = async (hexAddress: string, signer: Wallet['signer'], } } -const loginWithEvm = async (address: string, signer: any, evmChainId?: number) => { +const loginWithEvm = async (address: string, signer: any, evmChainId?: number, isEvmOnSubstrate?: boolean) => { const nonceRes = await fetch(`${import.meta.env.REACT_APP_ONBOARDING_API_URL}/nonce`, { method: 'POST', headers: { @@ -216,7 +217,7 @@ Please sign to authenticate your wallet URI: ${origin} Version: 1 -Chain ID: ${evmChainId ? evmChainId : import.meta.env.REACT_APP_TINLAKE_NETWORK === 'mainnet' ? 1 : 5 /* goerli */} +Chain ID: ${evmChainId || 1} Nonce: ${nonce} Issued At: ${new Date().toISOString()}` @@ -232,7 +233,8 @@ Issued At: ${new Date().toISOString()}` signature: signedMessage, address, nonce, - network: evmChainId ? 'evmOnSubstrate' : 'evm', + network: isEvmOnSubstrate ? 'evmOnSubstrate' : 'evm', + chainId: evmChainId || 1, }), }) if (tokenRes.status !== 200) { diff --git a/centrifuge-app/src/components/Root.tsx b/centrifuge-app/src/components/Root.tsx index 4faae3a7ed..b13f54116b 100644 --- a/centrifuge-app/src/components/Root.tsx +++ b/centrifuge-app/src/components/Root.tsx @@ -83,9 +83,9 @@ const evmChains: EvmChains = urls: [`https://mainnet.infura.io/v3/${infuraKey}`], iconUrl: ethereumLogo, }, - 43114: { - urls: ['https://api.avax.network/ext/bc/C/rpc'], - iconUrl: 'https://cryptologos.cc/logos/avalanche-avax-logo.svg?v=013', + 8453: { + urls: ['https://mainnet.base.org'], + iconUrl: 'https://docs.base.org/img/logo_dark.svg', }, } : { @@ -97,20 +97,20 @@ const evmChains: EvmChains = urls: [`https://goerli.infura.io/v3/${infuraKey}`], iconUrl: goerliLogo, }, - 43114: { - urls: ['https://api.avax.network/ext/bc/C/rpc'], - iconUrl: 'https://cryptologos.cc/logos/avalanche-avax-logo.svg?v=013', + 8453: { + urls: ['https://mainnet.base.org'], + iconUrl: 'https://docs.base.org/img/logo.svg', }, - 43113: { - urls: ['https://api.avax-test.network/ext/bc/C/rpc'], - iconUrl: 'https://cryptologos.cc/logos/avalanche-avax-logo.svg?v=013', + 84531: { + urls: ['https://goerli.base.org'], + iconUrl: 'https://docs.base.org/img/logo.svg', }, } export function Root() { const [isThemeToggled, setIsThemeToggled] = React.useState(!!initialFlagsState.alternativeTheme) const [showAdvancedAccounts, setShowAdvancedAccounts] = React.useState(!!initialFlagsState.showAdvancedAccounts) - const [showAvalanche, setShowAvalanche] = React.useState(!!initialFlagsState.showAvalanche) + const [showBase, setShowBase] = React.useState(!!initialFlagsState.showBase) return ( <> @@ -137,7 +137,7 @@ export function Root() { subscanUrl={import.meta.env.REACT_APP_SUBSCAN_URL} walletConnectId={import.meta.env.REACT_APP_WALLETCONNECT_ID} showAdvancedAccounts={showAdvancedAccounts} - showAvalanche={showAvalanche} + showBase={showBase} > <OnboardingAuthProvider> <OnboardingProvider> @@ -145,7 +145,7 @@ export function Root() { onChange={(state) => { setIsThemeToggled(!!state.alternativeTheme) setShowAdvancedAccounts(!!state.showAdvancedAccounts) - setShowAvalanche(!!state.showAvalanche) + setShowBase(!!state.showBase) }} > <TransactionProvider> diff --git a/centrifuge-app/src/config.ts b/centrifuge-app/src/config.ts index ae6e22bee2..d5b9f2d954 100644 --- a/centrifuge-app/src/config.ts +++ b/centrifuge-app/src/config.ts @@ -125,7 +125,6 @@ const goerliConfig = { poolRegistryAddress: '0x5ba1e12693dc8f9c48aad8770482f4739beed696', tinlakeUrl: 'https://goerli.staging.tinlake.cntrfg.com/', poolsHash: 'QmQe9NTiVJnVcb4srw6sBpHefhYieubR7v3J8ZriULQ8vB', // TODO: add registry to config and fetch poolHash - remarkerAddress: '0x6E395641087a4938861d7ada05411e3146175F58', blockExplorerUrl: 'https://goerli.etherscan.io', } const mainnetConfig = { @@ -133,13 +132,13 @@ const mainnetConfig = { poolRegistryAddress: '0x5ba1e12693dc8f9c48aad8770482f4739beed696', tinlakeUrl: 'https://tinlake.centrifuge.io', poolsHash: 'QmNvauf8E6TkUiyF1ZgtYtntHz335tCswKp2uhBH1fiui1', // TODO: add registry to config and fetch poolHash - remarkerAddress: '0x075f37451e7a4877f083aa070dd47a6969af2ced', blockExplorerUrl: 'https://etherscan.io', } export const ethConfig = { network: ethNetwork, multicallContractAddress: '0x5ba1e12693dc8f9c48aad8770482f4739beed696', // Same for all networks + remarkerAddress: '0x3E39db43035981c2C31F7Ffa4392f25231bE4477', // Same for all networks ...(ethNetwork === 'goerli' ? goerliConfig : mainnetConfig), } diff --git a/centrifuge-app/src/pages/Onboarding/queries/useSignAndSendDocuments.ts b/centrifuge-app/src/pages/Onboarding/queries/useSignAndSendDocuments.ts index 31822fc48b..fd48989066 100644 --- a/centrifuge-app/src/pages/Onboarding/queries/useSignAndSendDocuments.ts +++ b/centrifuge-app/src/pages/Onboarding/queries/useSignAndSendDocuments.ts @@ -1,4 +1,4 @@ -import { useTransactions } from '@centrifuge/centrifuge-react' +import { Network, useTransactions } from '@centrifuge/centrifuge-react' import { useMutation } from 'react-query' import { useOnboardingAuth } from '../../../components/OnboardingAuthProvider' import { OnboardingPool, useOnboarding } from '../../../components/OnboardingProvider' @@ -14,7 +14,7 @@ export const useSignAndSendDocuments = () => { const trancheId = pool.trancheId const mutation = useMutation( - async (transactionInfo: { txHash: string; blockNumber: string }) => { + async (transactionInfo: { txHash: string; blockNumber: string; chainId: Network }) => { addOrUpdateTransaction({ id: txIdSendDocs, title: `Send documents to issuers`, diff --git a/centrifuge-app/src/pages/Onboarding/queries/useSignRemark.ts b/centrifuge-app/src/pages/Onboarding/queries/useSignRemark.ts index 0f450276cb..08f3f7b7d4 100644 --- a/centrifuge-app/src/pages/Onboarding/queries/useSignRemark.ts +++ b/centrifuge-app/src/pages/Onboarding/queries/useSignRemark.ts @@ -1,4 +1,5 @@ import { + Network, useBalances, useCentrifuge, useCentrifugeTransaction, @@ -24,6 +25,7 @@ export const useSignRemark = ( txHash: string blockNumber: string isEvmOnSubstrate?: boolean + chainId: Network }, unknown > @@ -37,6 +39,7 @@ export const useSignRemark = ( connectedType, isEvmOnSubstrate, substrate: { selectedAddress, selectedAccount }, + connectedNetwork, } = useWallet() const [expectedTxFee, setExpectedTxFee] = React.useState(Dec(0)) const balances = useBalances(selectedAddress || '') @@ -59,7 +62,12 @@ export const useSignRemark = ( // @ts-expect-error blockNumber = result.blockNumber.toString() } - await sendDocumentsToIssuer({ txHash, blockNumber, isEvmOnSubstrate }) + await sendDocumentsToIssuer({ + txHash, + blockNumber, + isEvmOnSubstrate, + chainId: connectedNetwork || 'centrifuge', + }) setIsSubstrateTxLoading(false) } catch (e) { setIsSubstrateTxLoading(false) @@ -157,6 +165,7 @@ export const useSignRemark = ( await sendDocumentsToIssuer({ txHash: result.hash, blockNumber: finalizedTx.blockNumber.toString(), + chainId: connectedNetwork || 'centrifuge', }) updateTransaction(txId, () => ({ status: 'succeeded', diff --git a/centrifuge-app/src/pages/Pool/Overview/index.tsx b/centrifuge-app/src/pages/Pool/Overview/index.tsx index 08d01cc010..90fa740d60 100644 --- a/centrifuge-app/src/pages/Pool/Overview/index.tsx +++ b/centrifuge-app/src/pages/Pool/Overview/index.tsx @@ -1,4 +1,4 @@ -import { useWallet } from '@centrifuge/centrifuge-react' +import { Network, useWallet } from '@centrifuge/centrifuge-react' import { Button, Shelf, Stack, Text, TextWithPlaceholder } from '@centrifuge/fabric' import * as React from 'react' import { useLocation, useParams } from 'react-router' @@ -63,13 +63,17 @@ export function PoolDetailSideBar({ investRef?: ActionsRef }) { const { pid: poolId } = useParams<{ pid: string }>() + const isTinlakePool = poolId.startsWith('0x') + const tinlakeNetworks = [ethConfig.network === 'goerli' ? 5 : 1] as Network[] + // TODO: fetch supported networks from centrifuge chain + const centrifugeNetworks = ['centrifuge', 84531] as Network[] return ( <InvestRedeem poolId={poolId} trancheId={selectedToken} onSetTrancheId={setSelectedToken} - networks={poolId.startsWith('0x') ? [ethConfig.network === 'goerli' ? 5 : 1] : ['centrifuge']} + networks={isTinlakePool ? tinlakeNetworks : centrifugeNetworks} actionsRef={investRef} /> ) diff --git a/centrifuge-js/src/modules/rewards.ts b/centrifuge-js/src/modules/rewards.ts index 90e6278fb7..c1b68ca663 100644 --- a/centrifuge-js/src/modules/rewards.ts +++ b/centrifuge-js/src/modules/rewards.ts @@ -126,11 +126,14 @@ export function getRewardsModule(inst: Centrifuge) { } function getAccountStakes(args: [address: Account, poolId: string, trancheId: string]) { - const [address, poolId, trancheId] = args + const [addressEvm, poolId, trancheId] = args const { getPoolCurrency } = inst.pools - return inst.getApi().pipe( - switchMap((api) => api.query.liquidityRewardsBase.stakeAccount(address, { Tranche: [poolId, trancheId] })), + combineLatestWith(inst.getChainId()), + switchMap(([api, chainId]) => { + const address = inst.utils.evmToSubstrateAddress(addressEvm.toString(), chainId) + return api.query.liquidityRewardsBase.stakeAccount(address, { Tranche: [poolId, trancheId] }) + }), combineLatestWith(getPoolCurrency([poolId])), map(([data, currency]) => { const { stake, pendingStake, rewardTally, lastCurrencyMovement } = data.toPrimitive() as { diff --git a/centrifuge-react/src/components/WalletProvider/ConnectionGuard.tsx b/centrifuge-react/src/components/WalletProvider/ConnectionGuard.tsx index fb42993c62..e5d00f68f1 100644 --- a/centrifuge-react/src/components/WalletProvider/ConnectionGuard.tsx +++ b/centrifuge-react/src/components/WalletProvider/ConnectionGuard.tsx @@ -15,12 +15,11 @@ export function ConnectionGuard({ networks, children, body = 'Unsupported networ isEvmOnSubstrate, connectedType, connectedNetwork, - evm: { chains, selectedWallet }, + evm: { selectedWallet }, substrate: { evmChainId }, showWallets, connect, } = useWallet() - const getName = useGetNetworkName() if (!connectedNetwork) { @@ -65,7 +64,7 @@ export function ConnectionGuard({ networks, children, body = 'Unsupported networ <MenuItemGroup> {networks.map((network) => ( <MenuItem - label={network === 'centrifuge' ? 'Centrifuge' : chains[network]?.name} + label={getName(network)} onClick={() => { state.close() switchNetwork(network) diff --git a/centrifuge-react/src/components/WalletProvider/WalletDialog.tsx b/centrifuge-react/src/components/WalletProvider/WalletDialog.tsx index f03a107e09..2a2a4282b1 100644 --- a/centrifuge-react/src/components/WalletProvider/WalletDialog.tsx +++ b/centrifuge-react/src/components/WalletProvider/WalletDialog.tsx @@ -29,7 +29,7 @@ import { useCentEvmChainId, useWallet, wallets } from './WalletProvider' type Props = { evmChains: EvmChains showAdvancedAccounts?: boolean - showAvalanche?: boolean + showBase?: boolean } const title = { @@ -38,15 +38,13 @@ const title = { accounts: 'Choose account', } -export function WalletDialog({ evmChains: allEvmChains, showAdvancedAccounts, showAvalanche }: Props) { - const evmChains = showAvalanche - ? allEvmChains - : Object.keys(allEvmChains) - .filter((chain) => !['43114', '43113'].includes(chain)) - .reduce((obj, key) => { - obj[key] = allEvmChains[key] - return obj - }, {}) +export function WalletDialog({ evmChains: allEvmChains, showAdvancedAccounts, showBase }: Props) { + const evmChains = Object.keys(allEvmChains) + .filter((chain) => (!showBase ? !['8453', '84531'].includes(chain) : true)) + .reduce((obj, key) => { + obj[key] = allEvmChains[key] + return obj + }, {}) as EvmChains const ctx = useWallet() const centEvmChainId = useCentEvmChainId() const { diff --git a/centrifuge-react/src/components/WalletProvider/WalletProvider.tsx b/centrifuge-react/src/components/WalletProvider/WalletProvider.tsx index 9fb0b8dccc..b6c8376f51 100644 --- a/centrifuge-react/src/components/WalletProvider/WalletProvider.tsx +++ b/centrifuge-react/src/components/WalletProvider/WalletProvider.tsx @@ -122,7 +122,7 @@ type WalletProviderProps = { walletConnectId?: string subscanUrl?: string showAdvancedAccounts?: boolean - showAvalanche?: boolean + showBase?: boolean } let cachedEvmConnectors: EvmConnectorMeta[] | undefined = undefined @@ -138,7 +138,7 @@ export function WalletProvider({ walletConnectId, subscanUrl, showAdvancedAccounts, - showAvalanche, + showBase, }: WalletProviderProps) { if (!evmChainsProp[1]?.urls[0]) throw new Error('Mainnet should be defined in EVM Chains') @@ -468,7 +468,7 @@ export function WalletProvider({ return ( <WalletContext.Provider value={ctx}> {children} - <WalletDialog evmChains={evmChains} showAdvancedAccounts={showAdvancedAccounts} showAvalanche={showAvalanche} /> + <WalletDialog evmChains={evmChains} showAdvancedAccounts={showAdvancedAccounts} showBase={showBase} /> </WalletContext.Provider> ) } diff --git a/centrifuge-react/src/components/WalletProvider/evm/chains.ts b/centrifuge-react/src/components/WalletProvider/evm/chains.ts index d02b5558c0..4904303903 100644 --- a/centrifuge-react/src/components/WalletProvider/evm/chains.ts +++ b/centrifuge-react/src/components/WalletProvider/evm/chains.ts @@ -12,7 +12,7 @@ type ExtendedChainInformation = BasicChainInformation & { } export type EvmChains = - | { [chainId in 1 | 5 | 43113 | 43114]?: BasicChainInformation } + | { [chainId in 1 | 5 | 8453 | 84531]?: BasicChainInformation } | { [chainId: number]: ExtendedChainInformation } export function getAddChainParameters(chains: EvmChains, chainId: number): AddEthereumChainParameter | number { @@ -53,15 +53,15 @@ const chainExtendedInfo = { nativeCurrency: { name: 'Görli Ether', symbol: 'görETH', decimals: 18 }, blockExplorerUrl: 'https://goerli.etherscan.io/', }, - 43113: { - name: 'Fuji', - nativeCurrency: { name: 'Avalanche', symbol: 'AVAX', decimals: 18 }, - blockExplorerUrl: 'https://testnet.snowtrace.io/', + 8453: { + name: 'Base', + nativeCurrency: { name: 'Base', symbol: 'bETH', decimals: 18 }, + blockExplorerUrl: 'https://basescan.org/', }, - 43114: { - name: 'Avalanche', - nativeCurrency: { name: 'Avalanche', symbol: 'AVAX', decimals: 18 }, - blockExplorerUrl: 'https://snowtrace.io/', + 84531: { + name: 'Base Goerli', + nativeCurrency: { name: 'Base Goerli', symbol: 'gbETH', decimals: 18 }, + blockExplorerUrl: 'https://goerli.basescan.org/', }, } diff --git a/onboarding-api/src/controllers/auth/authenticateWallet.ts b/onboarding-api/src/controllers/auth/authenticateWallet.ts index 48e4e9d9e3..c0acabe4fa 100644 --- a/onboarding-api/src/controllers/auth/authenticateWallet.ts +++ b/onboarding-api/src/controllers/auth/authenticateWallet.ts @@ -1,11 +1,9 @@ import { isAddress } from '@polkadot/util-crypto' import { Request, Response } from 'express' import * as jwt from 'jsonwebtoken' -import { SiweMessage } from 'siwe' import { InferType, object, string, StringSchema } from 'yup' import { SupportedNetworks } from '../../database' -import { HttpError, reportHttpError } from '../../utils/httpError' -import { getCentrifuge } from '../../utils/networks/centrifuge' +import { reportHttpError } from '../../utils/httpError' import { NetworkSwitch } from '../../utils/networks/networkSwitch' import { validateInput } from '../../utils/validateInput' @@ -32,6 +30,7 @@ const verifyWalletInput = object({ }), nonce: string().required(), network: string().oneOf(['evm', 'substrate', 'evmOnSubstrate']) as StringSchema<SupportedNetworks>, + chainId: string().required(), }) export const authenticateWalletController = async ( @@ -52,59 +51,3 @@ export const authenticateWalletController = async ( return res.status(error.code).send({ error: error.message, e }) } } - -const AUTHORIZED_ONBOARDING_PROXY_TYPES = ['Any', 'Invest', 'NonTransfer', 'NonProxy'] -export async function verifySubstrateWallet(req: Request, res: Response): Promise<Request['wallet']> { - const { jw3t: token, nonce } = req.body - const { verified, payload } = await await getCentrifuge().auth.verify(token!) - - const onBehalfOf = payload?.on_behalf_of - const address = payload.address - - const cookieNonce = req.signedCookies[`onboarding-auth-${address.toLowerCase()}`] - if (!cookieNonce || cookieNonce !== nonce) { - throw new HttpError(400, 'Invalid nonce') - } - - res.clearCookie(`onboarding-auth-${address.toLowerCase()}`) - - if (verified && onBehalfOf) { - const isVerifiedProxy = await getCentrifuge().auth.verifyProxy( - address, - onBehalfOf, - AUTHORIZED_ONBOARDING_PROXY_TYPES - ) - if (isVerifiedProxy.verified) { - req.wallet.address = address - } else if (verified && !onBehalfOf) { - req.wallet.address = address - } else { - throw new Error() - } - } - return { - address, - network: payload.network || 'substrate', - } -} - -export async function verifyEthWallet(req: Request, res: Response): Promise<Request['wallet']> { - const { message, signature, address, nonce, network } = req.body - - if (!isAddress(address)) { - throw new HttpError(400, 'Invalid address') - } - - const cookieNonce = req.signedCookies[`onboarding-auth-${address.toLowerCase()}`] - - if (!cookieNonce || cookieNonce !== nonce) { - throw new HttpError(400, 'Invalid nonce') - } - - const decodedMessage = await new SiweMessage(message).verify({ signature }) - res.clearCookie(`onboarding-auth-${address.toLowerCase()}`) - return { - address: decodedMessage.data.address, - network, - } -} diff --git a/onboarding-api/src/database/index.ts b/onboarding-api/src/database/index.ts index ba0d7d64f7..ddd201f758 100644 --- a/onboarding-api/src/database/index.ts +++ b/onboarding-api/src/database/index.ts @@ -34,6 +34,7 @@ export const transactionInfoSchema = object({ txHash: string().required(), blockNumber: string().required(), isEvmOnSubstrate: bool().optional(), + chainId: string().required(), }) export type TransactionInfo = InferType<typeof transactionInfoSchema> diff --git a/onboarding-api/src/middleware/verifyAuth.ts b/onboarding-api/src/middleware/verifyAuth.ts index 735c85a844..b7bbf78922 100644 --- a/onboarding-api/src/middleware/verifyAuth.ts +++ b/onboarding-api/src/middleware/verifyAuth.ts @@ -10,18 +10,18 @@ export const verifyAuth = async (req: Request, _res: Response, next: NextFunctio throw new Error('Unauthorized') } const token = authorization.split(' ')[1] - const { address, network, aud } = (await jwt.verify(token, process.env.JWT_SECRET)) as Request['wallet'] & + const { address, network, chainId, aud } = (await jwt.verify(token, process.env.JWT_SECRET)) as Request['wallet'] & jwt.JwtPayload if (!address || aud !== req.get('origin')) { throw new Error('Unauthorized') } if ( (network.includes('evm') && !isAddress(address)) || - (network === 'substrate' && !(await getValidSubstrateAddress({ address, network }))) + (network === 'substrate' && !(await getValidSubstrateAddress({ address, network, chainId }))) ) { throw new Error('Unauthorized') } - req.wallet = { address, network } + req.wallet = { address, network, chainId } next() } catch (e) { throw new Error('Unauthorized') diff --git a/onboarding-api/src/utils/annotateAgreementAndSignAsInvestor.ts b/onboarding-api/src/utils/annotateAgreementAndSignAsInvestor.ts index 6ab92f7793..4a407ce9ea 100644 --- a/onboarding-api/src/utils/annotateAgreementAndSignAsInvestor.ts +++ b/onboarding-api/src/utils/annotateAgreementAndSignAsInvestor.ts @@ -36,8 +36,9 @@ export const annotateAgreementAndSignAsInvestor = async ({ const unsignedAgreementUrl = metadata?.onboarding?.tranches?.[trancheId]?.agreement?.uri ? centrifuge.metadata.parseMetadataUrl(metadata?.onboarding?.tranches?.[trancheId]?.agreement?.uri) - : wallet.network === 'substrate' || wallet.network === 'evmOnSubstrate' - ? centrifuge.metadata.parseMetadataUrl(GENERIC_SUBSCRIPTION_AGREEMENT) + : !pool.id.startsWith('0x') + ? // TODO: remove generic and don't allow onboarding if agreement is not uploaded + centrifuge.metadata.parseMetadataUrl(GENERIC_SUBSCRIPTION_AGREEMENT) : null // tinlake pools that are closed for onboarding don't have agreements in their metadata @@ -113,7 +114,7 @@ Agreement hash: ${unsignedAgreementUrl}`, }) // all tinlake agreements require the executive summary to be appended - if (wallet.network === 'evm') { + if (pool.id.startsWith('0x')) { const execSummaryRes = await fetch(metadata.pool.links.executiveSummary.uri) const execSummary = Buffer.from(await execSummaryRes.arrayBuffer()) const execSummaryPdf = await PDFDocument.load(execSummary) diff --git a/onboarding-api/src/utils/networks/centrifuge.ts b/onboarding-api/src/utils/networks/centrifuge.ts index 4780def035..ee3074b362 100644 --- a/onboarding-api/src/utils/networks/centrifuge.ts +++ b/onboarding-api/src/utils/networks/centrifuge.ts @@ -1,7 +1,7 @@ import Centrifuge, { CurrencyBalance, evmToSubstrateAddress } from '@centrifuge/centrifuge-js' import { Keyring } from '@polkadot/keyring' import { cryptoWaitReady, encodeAddress } from '@polkadot/util-crypto' -import { Request } from 'express' +import { Request, Response } from 'express' import { combineLatest, combineLatestWith, firstValueFrom, lastValueFrom, switchMap, take, takeWhile } from 'rxjs' import { InferType } from 'yup' import { signAndSendDocumentsInput } from '../../controllers/emails/signAndSendDocuments' @@ -188,3 +188,39 @@ export const getValidSubstrateAddress = async (wallet: Request['wallet']) => { throw new HttpError(400, 'Invalid substrate address') } } + +const AUTHORIZED_ONBOARDING_PROXY_TYPES = ['Any', 'Invest', 'NonTransfer', 'NonProxy'] +export async function verifySubstrateWallet(req: Request, res: Response): Promise<Request['wallet']> { + const { jw3t: token, nonce } = req.body + const { verified, payload } = await getCentrifuge().auth.verify(token!) + + const onBehalfOf = payload?.on_behalf_of + const address = payload.address + + const cookieNonce = req.signedCookies[`onboarding-auth-${address.toLowerCase()}`] + if (!cookieNonce || cookieNonce !== nonce) { + throw new HttpError(400, 'Invalid nonce') + } + + res.clearCookie(`onboarding-auth-${address.toLowerCase()}`) + + if (verified && onBehalfOf) { + const isVerifiedProxy = await getCentrifuge().auth.verifyProxy( + address, + onBehalfOf, + AUTHORIZED_ONBOARDING_PROXY_TYPES + ) + if (isVerifiedProxy.verified) { + req.wallet.address = address + } else if (verified && !onBehalfOf) { + req.wallet.address = address + } else { + throw new Error() + } + } + return { + address, + network: payload.network || 'substrate', + chainId: payload.chainId, + } +} diff --git a/onboarding-api/src/utils/networks/evm.ts b/onboarding-api/src/utils/networks/evm.ts new file mode 100644 index 0000000000..65e82c91fe --- /dev/null +++ b/onboarding-api/src/utils/networks/evm.ts @@ -0,0 +1,68 @@ +import { isAddress } from '@ethersproject/address' +import { Contract } from '@ethersproject/contracts' +import { InfuraProvider, JsonRpcProvider, Provider } from '@ethersproject/providers' +import { Request, Response } from 'express' +import { SiweMessage } from 'siwe' +import { InferType } from 'yup' +import { signAndSendDocumentsInput } from '../../controllers/emails/signAndSendDocuments' +import { HttpError } from '../httpError' +import RemarkerAbi from './abi/Remarker.abi.json' + +const getEvmProvider = (chainId: number | string, isEvmOnCentChain?: boolean): Provider => { + if (isEvmOnCentChain) { + return new InfuraProvider(chainId, process.env.INFURA_KEY) + } + switch (chainId.toString()) { + case '1': // eth mainnet + case '5': // goerli + return new InfuraProvider(chainId, process.env.INFURA_KEY) + case '8453': // base mainnet + return new JsonRpcProvider('https://mainnet.base.org') + case '84531': // base goerli + return new JsonRpcProvider('https://goerli.base.org') + default: + throw new HttpError(404, `Unsupported chainId ${chainId}`) + } +} + +export const validateEvmRemark = async ( + wallet: Request['wallet'], + transactionInfo: InferType<typeof signAndSendDocumentsInput>['transactionInfo'], + expectedRemark: string +) => { + const provider = getEvmProvider(transactionInfo.chainId, transactionInfo?.isEvmOnSubstrate) + const remarkerAddress = '0x3E39db43035981c2C31F7Ffa4392f25231bE4477' + const contract = new Contract(remarkerAddress, RemarkerAbi).connect(provider) + const filteredEvents = await contract.queryFilter( + 'Remarked', + Number(transactionInfo.blockNumber), + Number(transactionInfo.blockNumber) + ) + + const [sender, actualRemark] = filteredEvents.flatMap((ev) => ev.args?.map((arg) => arg.toString())) + if (actualRemark !== expectedRemark || sender !== wallet.address) { + throw new HttpError(400, 'Invalid remark') + } +} + +export async function verifyEvmWallet(req: Request, res: Response): Promise<Request['wallet']> { + const { message, signature, address, nonce, network, chainId } = req.body + + if (!isAddress(address)) { + throw new HttpError(400, 'Invalid address') + } + + const cookieNonce = req.signedCookies[`onboarding-auth-${address.toLowerCase()}`] + + if (!cookieNonce || cookieNonce !== nonce) { + throw new HttpError(400, 'Invalid nonce') + } + + const decodedMessage = await new SiweMessage(message).verify({ signature }) + res.clearCookie(`onboarding-auth-${address.toLowerCase()}`) + return { + address: decodedMessage.data.address, + network, + chainId, + } +} diff --git a/onboarding-api/src/utils/networks/networkSwitch.ts b/onboarding-api/src/utils/networks/networkSwitch.ts index 2065207398..73a9d14976 100644 --- a/onboarding-api/src/utils/networks/networkSwitch.ts +++ b/onboarding-api/src/utils/networks/networkSwitch.ts @@ -1,11 +1,16 @@ import { Request, Response } from 'express' import { InferType } from 'yup' -import { verifyEthWallet, verifySubstrateWallet } from '../../controllers/auth/authenticateWallet' import { signAndSendDocumentsInput } from '../../controllers/emails/signAndSendDocuments' import { SupportedNetworks } from '../../database' import { HttpError } from '../httpError' -import { addCentInvestorToMemberList, getCentPoolById, validateSubstrateRemark } from './centrifuge' -import { addTinlakeInvestorToMemberList, getTinlakePoolById, validateEvmRemark } from './tinlake' +import { + addCentInvestorToMemberList, + getCentPoolById, + validateSubstrateRemark, + verifySubstrateWallet, +} from './centrifuge' +import { validateEvmRemark, verifyEvmWallet } from './evm' +import { addTinlakeInvestorToMemberList, getTinlakePoolById } from './tinlake' export class NetworkSwitch { network: SupportedNetworks @@ -17,9 +22,9 @@ export class NetworkSwitch { if (this.network === 'substrate') { return verifySubstrateWallet(req, res) } else if (this.network === 'evm' || this.network === 'evmOnSubstrate') { - return verifyEthWallet(req, res) + return verifyEvmWallet(req, res) } - throw new Error('Unsupported network') + throw new HttpError(404, 'Unsupported network') } validateRemark = ( @@ -36,20 +41,16 @@ export class NetworkSwitch { } addInvestorToMemberList = async (wallet: Request['wallet'], poolId: string, trancheId: string) => { - if (this.network === 'evmOnSubstrate' || this.network === 'substrate') { - return addCentInvestorToMemberList(wallet, poolId, trancheId) - } else if (this.network === 'evm') { + if (this.network === 'evm' && poolId.startsWith('0x')) { return addTinlakeInvestorToMemberList(wallet, poolId, trancheId) } - throw new HttpError(404, 'Unsupported network') + return addCentInvestorToMemberList(wallet, poolId, trancheId) } getPoolById = async (poolId: string) => { - if (this.network === 'evmOnSubstrate' || this.network === 'substrate') { - return getCentPoolById(poolId) - } else if (this.network === 'evm') { + if (this.network === 'evm' && poolId.startsWith('0x')) { return getTinlakePoolById(poolId) } - throw new HttpError(404, 'Unsupported network') + return getCentPoolById(poolId) } } diff --git a/onboarding-api/src/utils/networks/tinlake.ts b/onboarding-api/src/utils/networks/tinlake.ts index 6f0c052492..dba446dc06 100644 --- a/onboarding-api/src/utils/networks/tinlake.ts +++ b/onboarding-api/src/utils/networks/tinlake.ts @@ -3,11 +3,8 @@ import { InfuraProvider } from '@ethersproject/providers' import { Wallet } from '@ethersproject/wallet' import { Request } from 'express' import { lastValueFrom } from 'rxjs' -import { InferType } from 'yup' -import { signAndSendDocumentsInput } from '../../controllers/emails/signAndSendDocuments' import { HttpError, reportHttpError } from '../httpError' import MemberListAdminAbi from './abi/MemberListAdmin.abi.json' -import RemarkerAbi from './abi/Remarker.abi.json' import { getCentrifuge } from './centrifuge' export interface LaunchingPool extends BasePool {} @@ -96,34 +93,32 @@ interface ActivePool extends BasePool { } } +function parsePoolsMetadata(poolsMetadata): { active: ActivePool[] } { + const launching = poolsMetadata.filter((p): p is LaunchingPool => !!p.metadata?.isLaunching) + const active = poolsMetadata.filter( + (p): p is ActivePool => !!('addresses' in p && p.addresses.ROOT_CONTRACT && !launching?.includes(p)) + ) + return { active } +} + const goerliConfig = { - remarkerAddress: '0x6E395641087a4938861d7ada05411e3146175F58', poolsHash: 'QmQe9NTiVJnVcb4srw6sBpHefhYieubR7v3J8ZriULQ8vB', // TODO: add registry to config and fetch poolHash memberListAddress: '0xaEcFA11fE9601c1B960661d7083A08A5df7c1947', } const mainnetConfig = { - remarkerAddress: '0x075f37451e7a4877f083aa070dd47a6969af2ced', poolsHash: 'QmNvauf8E6TkUiyF1ZgtYtntHz335tCswKp2uhBH1fiui1', // TODO: add registry to config and fetch poolHash memberListAddress: '0xB7e70B77f6386Ffa5F55DDCb53D87A0Fb5a2f53b', } -export const getEthConfig = () => ({ +export const getTinlakeConfig = () => ({ network: process.env.EVM_NETWORK, multicallContractAddress: '0x5ba1e12693dc8f9c48aad8770482f4739beed696', // Same for all networks signerPrivateKey: process.env.EVM_MEMBERLIST_ADMIN_PRIVATE_KEY, ...(process.env.EVM_NETWORK === 'goerli' ? goerliConfig : mainnetConfig), }) -function parsePoolsMetadata(poolsMetadata): { active: ActivePool[] } { - const launching = poolsMetadata.filter((p): p is LaunchingPool => !!p.metadata?.isLaunching) - const active = poolsMetadata.filter( - (p): p is ActivePool => !!('addresses' in p && p.addresses.ROOT_CONTRACT && !launching?.includes(p)) - ) - return { active } -} - export const getTinlakePoolById = async (poolId: string) => { - const uri = getEthConfig().poolsHash + const uri = getTinlakeConfig().poolsHash const data = (await lastValueFrom(getCentrifuge().metadata.getMetadata(uri))) as PoolMetadataDetails const pools = parsePoolsMetadata(Object.values(data)) const poolData = pools.active.find((p) => p.addresses.ROOT_CONTRACT === poolId) @@ -189,30 +184,11 @@ export const getTinlakePoolById = async (poolId: string) => { } } -export const validateEvmRemark = async ( - wallet: Request['wallet'], - transactionInfo: InferType<typeof signAndSendDocumentsInput>['transactionInfo'], - expectedRemark: string -) => { - const provider = new InfuraProvider(process.env.EVM_NETWORK, process.env.INFURA_KEY) - const contract = new Contract(getEthConfig().remarkerAddress, RemarkerAbi).connect(provider) - const filteredEvents = await contract.queryFilter( - 'Remarked', - Number(transactionInfo.blockNumber), - Number(transactionInfo.blockNumber) - ) - - const [sender, actualRemark] = filteredEvents.flatMap((ev) => ev.args?.map((arg) => arg.toString())) - if (actualRemark !== expectedRemark || sender !== wallet.address) { - throw new HttpError(400, 'Invalid remark') - } -} - export const addTinlakeInvestorToMemberList = async (wallet: Request['wallet'], poolId: string, trancheId: string) => { try { const pool = await getTinlakePoolById(poolId) - const provider = new InfuraProvider(process.env.EVM_NETWORK, process.env.INFURA_KEY) - const ethConfig = getEthConfig() + const provider = new InfuraProvider(wallet.chainId, process.env.INFURA_KEY) + const ethConfig = getTinlakeConfig() const signer = new Wallet(ethConfig.signerPrivateKey).connect(provider) const memberAdminContract = new Contract(ethConfig.memberListAddress, MemberListAdminAbi, signer) const memberlistAddress = trancheId.endsWith('1') diff --git a/onboarding-api/src/utils/types.d.ts b/onboarding-api/src/utils/types.d.ts index d5b2dc891d..0545496549 100644 --- a/onboarding-api/src/utils/types.d.ts +++ b/onboarding-api/src/utils/types.d.ts @@ -16,6 +16,7 @@ declare global { wallet: { address: string network: SupportedNetworks + chainId: string } } } From 6fce1a568c2e9c5dec95ad7ab03f800778dc4b5e Mon Sep 17 00:00:00 2001 From: Sophia <littlejohn.sophia@gmail.com> Date: Thu, 24 Aug 2023 09:52:38 -0400 Subject: [PATCH 08/39] OnboardingAPI: Implement faucet for remark signing evm on cent chain (#1547) * Implement remark faucet for evm on cent chain * Fix import * Clean up chain id types --- .../pages/Onboarding/queries/useSignRemark.ts | 29 +++++++++++++------ .../controllers/auth/authenticateWallet.ts | 4 +-- onboarding-api/src/database/index.ts | 4 +-- .../src/utils/networks/centrifuge.ts | 11 +++---- onboarding-api/src/utils/networks/evm.ts | 12 ++++---- onboarding-api/src/utils/types.d.ts | 2 +- 6 files changed, 37 insertions(+), 25 deletions(-) diff --git a/centrifuge-app/src/pages/Onboarding/queries/useSignRemark.ts b/centrifuge-app/src/pages/Onboarding/queries/useSignRemark.ts index 08f3f7b7d4..092d143cd4 100644 --- a/centrifuge-app/src/pages/Onboarding/queries/useSignRemark.ts +++ b/centrifuge-app/src/pages/Onboarding/queries/useSignRemark.ts @@ -7,6 +7,7 @@ import { useTransactions, useWallet, } from '@centrifuge/centrifuge-react' +import { useNativeBalance } from '@centrifuge/centrifuge-react/dist/components/WalletProvider/evm/utils' import { Contract } from '@ethersproject/contracts' import React, { useEffect } from 'react' import { UseMutateFunction } from 'react-query' @@ -39,10 +40,12 @@ export const useSignRemark = ( connectedType, isEvmOnSubstrate, substrate: { selectedAddress, selectedAccount }, + evm: { selectedAddress: evmSelectedAddress, chainId: evmChainId }, connectedNetwork, } = useWallet() const [expectedTxFee, setExpectedTxFee] = React.useState(Dec(0)) - const balances = useBalances(selectedAddress || '') + const balances = useBalances(selectedAddress || undefined) + const { data: evmBalance } = useNativeBalance() const { authToken } = useOnboardingAuth() const [account] = useSuitableAccounts({ actingAddress: [selectedAddress || ''] }) @@ -79,7 +82,7 @@ export const useSignRemark = ( const txIdSignRemark = Math.random().toString(36).substr(2) addOrUpdateTransaction({ id: txIdSignRemark, - title: `Get ${balances?.native.currency.symbol}`, + title: `Get ${balances?.native.currency.symbol || 'CFG'}`, status: 'pending', args: [], }) @@ -95,16 +98,16 @@ export const useSignRemark = ( if (response.status !== 201) { addOrUpdateTransaction({ id: txIdSignRemark, - title: `Get ${balances?.native.currency.symbol}`, + title: `Get ${balances?.native.currency.symbol || 'CFG'}`, status: 'failed', args: [], }) setIsSubstrateTxLoading(false) - throw new Error('Insufficient funds') + throw new Error('Unable to get balance for signing') } else { addOrUpdateTransaction({ id: txIdSignRemark, - title: `Get ${balances?.native.currency.symbol}`, + title: `Get ${balances?.native.currency.symbol || 'CFG'}`, status: 'succeeded', args: [], }) @@ -113,7 +116,10 @@ export const useSignRemark = ( const signSubstrateRemark = async (args: [message: string]) => { setIsSubstrateTxLoading(true) - if (!isEvmOnSubstrate && balances?.native.balance?.toDecimal().lt(expectedTxFee.mul(1.1))) { + if (balances?.native.balance?.toDecimal().lt(expectedTxFee.mul(1.1))) { + await getBalanceForSigning() + } + if (isEvmOnSubstrate && evmBalance?.toDecimal().lt(expectedTxFee.mul(1.1))) { await getBalanceForSigning() } substrateMutation.execute(args, { account }) @@ -121,15 +127,20 @@ export const useSignRemark = ( useEffect(() => { const executePaymentInfo = async () => { - if (selectedAccount && selectedAccount.signer) { - const api = await centrifuge.connect(selectedAccount.address, selectedAccount.signer as any) + if ((selectedAccount && selectedAccount.signer) || (isEvmOnSubstrate && evmSelectedAddress)) { + const address = + isEvmOnSubstrate && evmSelectedAddress + ? centrifuge.utils.evmToSubstrateAddress(evmSelectedAddress, evmChainId!) + : selectedAccount?.address + const signer = selectedAccount?.signer || (await evmProvider?.getSigner()) + const api = await centrifuge.connect(address!, signer as any) const paymentInfo = await lastValueFrom( api.remark.signRemark( [ `I hereby sign the subscription agreement of pool [POOL_ID] and tranche [TRANCHE_ID]: [IPFS_HASH_OF_TEMPLATE]`, ], { - paymentInfo: selectedAccount.address, + paymentInfo: address!, } ) ) diff --git a/onboarding-api/src/controllers/auth/authenticateWallet.ts b/onboarding-api/src/controllers/auth/authenticateWallet.ts index c0acabe4fa..34ec8c1169 100644 --- a/onboarding-api/src/controllers/auth/authenticateWallet.ts +++ b/onboarding-api/src/controllers/auth/authenticateWallet.ts @@ -1,7 +1,7 @@ import { isAddress } from '@polkadot/util-crypto' import { Request, Response } from 'express' import * as jwt from 'jsonwebtoken' -import { InferType, object, string, StringSchema } from 'yup' +import { InferType, number, object, string, StringSchema } from 'yup' import { SupportedNetworks } from '../../database' import { reportHttpError } from '../../utils/httpError' import { NetworkSwitch } from '../../utils/networks/networkSwitch' @@ -30,7 +30,7 @@ const verifyWalletInput = object({ }), nonce: string().required(), network: string().oneOf(['evm', 'substrate', 'evmOnSubstrate']) as StringSchema<SupportedNetworks>, - chainId: string().required(), + chainId: number().required(), }) export const authenticateWalletController = async ( diff --git a/onboarding-api/src/database/index.ts b/onboarding-api/src/database/index.ts index ddd201f758..adb63287f1 100644 --- a/onboarding-api/src/database/index.ts +++ b/onboarding-api/src/database/index.ts @@ -2,7 +2,7 @@ import { Firestore } from '@google-cloud/firestore' import { Storage } from '@google-cloud/storage' import * as dotenv from 'dotenv' import { Request } from 'express' -import { array, bool, date, InferType, lazy, mixed, object, string, StringSchema } from 'yup' +import { array, bool, date, InferType, lazy, mixed, number, object, string, StringSchema } from 'yup' import { HttpError } from '../utils/httpError' import { Subset } from '../utils/types' @@ -34,7 +34,7 @@ export const transactionInfoSchema = object({ txHash: string().required(), blockNumber: string().required(), isEvmOnSubstrate: bool().optional(), - chainId: string().required(), + chainId: number().required(), }) export type TransactionInfo = InferType<typeof transactionInfoSchema> diff --git a/onboarding-api/src/utils/networks/centrifuge.ts b/onboarding-api/src/utils/networks/centrifuge.ts index ee3074b362..354bcb46e1 100644 --- a/onboarding-api/src/utils/networks/centrifuge.ts +++ b/onboarding-api/src/utils/networks/centrifuge.ts @@ -131,12 +131,13 @@ export const validateSubstrateRemark = async ( } export const checkBalanceBeforeSigningRemark = async (wallet: Request['wallet']) => { + const address = await getValidSubstrateAddress(wallet) const signer = await getSigner() const $api = getCentrifuge().getApi() const $paymentInfo = $api - .pipe(switchMap((api) => api.tx.system.remarkWithEvent('Signing for pool').paymentInfo(wallet.address))) + .pipe(switchMap((api) => api.tx.system.remarkWithEvent('Signing for pool').paymentInfo(address))) .pipe(take(1)) - const $nativeBalance = $api.pipe(switchMap((api) => api.query.system.account(wallet.address))).pipe(take(1)) + const $nativeBalance = $api.pipe(switchMap((api) => api.query.system.account(address))).pipe(take(1)) const tx = await lastValueFrom( combineLatest([$api, $paymentInfo, $nativeBalance]).pipe( switchMap(([api, paymentInfo, nativeBalance]) => { @@ -151,7 +152,7 @@ export const checkBalanceBeforeSigningRemark = async (wallet: Request['wallet']) } // add 10% buffer to the transaction fee - const submittable = api.tx.tokens.transfer({ Id: wallet.address }, 'Native', txFee.add(txFee.muln(1.1))) + const submittable = api.tx.tokens.transfer({ Id: address }, 'Native', txFee.add(txFee.muln(1.1))) return submittable.signAndSend(signer) }), takeWhile(({ events, isFinalized }) => { @@ -159,10 +160,10 @@ export const checkBalanceBeforeSigningRemark = async (wallet: Request['wallet']) events.forEach(({ event }) => { const result = event.data[0]?.toHuman() if (event.method === 'ProxyExecuted' && result === 'Ok') { - console.log(`Executed proxy for transfer`, { walletAddress: wallet.address, result }) + console.log(`Executed proxy for transfer`, { walletAddress: address, result }) } if (event.method === 'ExtrinsicFailed') { - console.log(`Extrinsic failed`, { walletAddress: wallet.address, result }) + console.log(`Extrinsic failed`, { walletAddress: address, result }) throw new HttpError(400, 'Extrinsic failed') } }) diff --git a/onboarding-api/src/utils/networks/evm.ts b/onboarding-api/src/utils/networks/evm.ts index 65e82c91fe..7463fc7b60 100644 --- a/onboarding-api/src/utils/networks/evm.ts +++ b/onboarding-api/src/utils/networks/evm.ts @@ -8,17 +8,17 @@ import { signAndSendDocumentsInput } from '../../controllers/emails/signAndSendD import { HttpError } from '../httpError' import RemarkerAbi from './abi/Remarker.abi.json' -const getEvmProvider = (chainId: number | string, isEvmOnCentChain?: boolean): Provider => { +const getEvmProvider = (chainId: number, isEvmOnCentChain?: boolean): Provider => { if (isEvmOnCentChain) { return new InfuraProvider(chainId, process.env.INFURA_KEY) } - switch (chainId.toString()) { - case '1': // eth mainnet - case '5': // goerli + switch (chainId) { + case 1: // eth mainnet + case 5: // goerli return new InfuraProvider(chainId, process.env.INFURA_KEY) - case '8453': // base mainnet + case 8453: // base mainnet return new JsonRpcProvider('https://mainnet.base.org') - case '84531': // base goerli + case 84531: // base goerli return new JsonRpcProvider('https://goerli.base.org') default: throw new HttpError(404, `Unsupported chainId ${chainId}`) diff --git a/onboarding-api/src/utils/types.d.ts b/onboarding-api/src/utils/types.d.ts index 0545496549..6d6ad4b3e9 100644 --- a/onboarding-api/src/utils/types.d.ts +++ b/onboarding-api/src/utils/types.d.ts @@ -16,7 +16,7 @@ declare global { wallet: { address: string network: SupportedNetworks - chainId: string + chainId: number } } } From e5beac5a96e37fc649d00b3ac09f681e6199fddb Mon Sep 17 00:00:00 2001 From: Sophia <littlejohn.sophia@gmail.com> Date: Thu, 24 Aug 2023 11:49:18 -0400 Subject: [PATCH 09/39] OnboardingAPI: updateMember for liquidity pools (#1549) * Add exrtinsic to update member in liquidity pools * Use hex address for extrinsics * Remove proxy --- .../src/utils/networks/centrifuge.ts | 27 ++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/onboarding-api/src/utils/networks/centrifuge.ts b/onboarding-api/src/utils/networks/centrifuge.ts index 354bcb46e1..7c7d31ff49 100644 --- a/onboarding-api/src/utils/networks/centrifuge.ts +++ b/onboarding-api/src/utils/networks/centrifuge.ts @@ -61,6 +61,9 @@ export const addCentInvestorToMemberList = async (wallet: Request['wallet'], poo { Pool: poolId }, { PoolRole: { TrancheInvestor: [trancheId, OneHundredYearsFromNow] } } ) + const proxiedSubmittable = api.tx.proxy.proxy(pureProxyAddress, undefined, submittable) + const batchSubmittable = [proxiedSubmittable] + // give the investor PODReadAccess if they issuer enabled it if (!hasPodReadAccess && metadata?.onboarding?.podReadAccess) { const podSubmittable = api.tx.permissions.add( { PoolRole: 'InvestorAdmin' }, @@ -68,13 +71,22 @@ export const addCentInvestorToMemberList = async (wallet: Request['wallet'], poo { Pool: poolId }, { PoolRole: 'PODReadAccess' } ) - const proxiedSubmittable = api.tx.proxy.proxy(pureProxyAddress, undefined, submittable) const proxiedPodSubmittable = api.tx.proxy.proxy(pureProxyAddress, undefined, podSubmittable) - const batchSubmittable = api.tx.utility.batchAll([proxiedPodSubmittable, proxiedSubmittable]) - return batchSubmittable.signAndSend(signer) + batchSubmittable.push(proxiedPodSubmittable) } - const proxiedSubmittable = api.tx.proxy.proxy(pureProxyAddress, undefined, submittable) - return proxiedSubmittable.signAndSend(signer) + // add investor to liquidity pools if they are investing on any domain other than centrifuge + if (wallet.network === 'evm') { + const updateMemberSubmittable = api.tx.connectors.updateMember( + poolId, + trancheId, + { + EVM: [wallet.chainId, wallet.address], + }, + OneHundredYearsFromNow + ) + batchSubmittable.push(updateMemberSubmittable) + } + return api.tx.utility.batchAll(batchSubmittable).signAndSend(signer) }), combineLatestWith(api), takeWhile(([{ events, isFinalized }, api]) => { @@ -181,7 +193,10 @@ export const getValidSubstrateAddress = async (wallet: Request['wallet']) => { const centChainId = await cent.getChainId() if (wallet.network === 'evmOnSubstrate') { const chainId = await firstValueFrom(cent.getApi().pipe(switchMap((api) => api.query.evmChainId.chainId()))) - return encodeAddress(evmToSubstrateAddress(wallet.address, Number(chainId.toString())), centChainId) + return evmToSubstrateAddress(wallet.address, Number(chainId.toString())) + } + if (wallet.network === 'evm') { + return evmToSubstrateAddress(wallet.address, wallet.chainId) } const validAddress = encodeAddress(wallet.address, centChainId) return validAddress From eb46ec3994c1cb2ba635a46ff8f1c0f382b6894f Mon Sep 17 00:00:00 2001 From: Sophia <littlejohn.sophia@gmail.com> Date: Fri, 25 Aug 2023 13:55:01 -0400 Subject: [PATCH 10/39] Update centrifuge app docs (#1551) --- .github/README.md | 20 ++++------------ centrifuge-app/README.md | 49 ++++++++++++++++++++++++++-------------- 2 files changed, 37 insertions(+), 32 deletions(-) diff --git a/.github/README.md b/.github/README.md index 48f154588c..eb00f95835 100644 --- a/.github/README.md +++ b/.github/README.md @@ -1,15 +1,4 @@ -# Monorepo for the Centrifuge applications. - -## Setup - -Make sure you have installed Yarn and NVM. - -1. Use Node v14.15.1: `nvm use` -2. Install dependencies: `yarn install` -3. Install `husky`: `yarn postinstall` -4. Add `.env` files with the right environment variables to each project. - -It's also recommended to run Prettier automatically in your editor, e.g. using [this VS Code plugin](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode). +# Monorepo for the Centrifuge applications ## Preparing Envs (e.g when the dev chain data is reset) @@ -24,9 +13,10 @@ It's also recommended to run Prettier automatically in your editor, e.g. using [ Setup pure proxy to sign transactions (whitelisting & transfer tokens). -1. Run `/initProxies` to create the pure proxy, fund it, and give it sufficient permissions -2. Copy the resulting pure proxy address and add it to the env varibles: `MEMBERLIST_ADMIN_PURE_PROXY` (onboarding-api) and `REACT_APP_MEMBERLIST_ADMIN_PURE_PROXY` (centrifuge-app) -3. Enable onboarding for each new pool under /issuer/<poolId>/investors +1. Use sudo in polkadot UI to give Alice enough currency to distribute (tokens.setBalance()). For currencyId select ForeignAsset and submit the transacton once with ForeignAsset 1 and once with ForeignAsset 2 +2. Run `/initProxies` to create the pure proxy, fund it, and give it sufficient permissions +3. Copy the resulting pure proxy address and add it to the env varibles: `MEMBERLIST_ADMIN_PURE_PROXY` (onboarding-api) and `REACT_APP_MEMBERLIST_ADMIN_PURE_PROXY` (centrifuge-app) +4. Enable onboarding for each new pool under /issuer/<poolId>/investors ### Asset Originator POD Access diff --git a/centrifuge-app/README.md b/centrifuge-app/README.md index bfd4431dab..a69c22e00a 100644 --- a/centrifuge-app/README.md +++ b/centrifuge-app/README.md @@ -2,35 +2,50 @@ ## Data and UI Architecture -- `centrifuge-js`: fetch data from the chain or subquery. -- `fabric`: all design system elements (run storybook in fabric to see everything available). +UI -## Commands +- `centrifuge-js`: library to interact with the Centrifuge chain and subquery +- `fabric`: design system elements and components +- `centrifuge-react`: reusable React component and hooks (wallets, queries, transactions) -#### `yarn start` +Cloud functions -Running `yarn start` will start the following processes: -Start a development server that watches the different workspace modules and the react app (using Vite) +- `onboarding-api`: KYC/KYB and investor whitelisting +- `faucet-api`: dev chain faucet +- `pinning-api`: pin documents to Pinata (IPFS) -#### `yarn start:deps` -It will start a development mode on the dependencies (`fabric` & `centrifuge-js`), to allow HMR to work when making changes +Indexing -#### `yarn build` or `yarn build --mode $ENV` or `yarn build immutable` +- [pools-subql](https://github.com/centrifuge/pools-subql): subquery to index pools and assets -Build all dependencies, functions, and app with libraries. +## Development -## Other useful information +### Prerequisites + +- node v16 +- yarn -This app uses [`vite`](https://vitejs.dev/guide/) but serve, build and bundle. +### Setup -To reference env variables in code please use the vite standard `import.meta.env.ENV_VARIABLE`. +1. copy [.env.development](./.env-config/env.development) to `.env.development.local` +2. Install modules: + ```bash + $ yarn + ``` +3. Start the development server: + ```bash + $ yarn start + ``` +4. Open [http://localhost:3000](http://localhost:3000) in your browser + +## Other useful information -Check the Vite configuration file to find where we keep env file. Vite automatically grabs the right file when building with the `--mode` flag. [More info here](https://vitejs.dev/guide/env-and-mode.html) +This app uses [`vite`](https://vitejs.dev/guide/) to serve, build and bundle. -> in Netlify functions you still need to reference env variables with `process.env` +To reference env variables in code please use the viste standard `import.meta.env.ENV_VARIABLE`. ## Deployments -Up-to-date info in k-f's Knowledge Base: +Up-to-date info in k-f's Knowledge Base: -https://centrifuge.hackmd.io/MFsnRldyQSa4cadx11OtVg?view#Environments-amp-Deployments \ No newline at end of file +https://centrifuge.hackmd.io/MFsnRldyQSa4cadx11OtVg?view#Environments-amp-Deployments From 41088a19fca7acfe8a9fd9cb0cfb9d9d2f3b9c45 Mon Sep 17 00:00:00 2001 From: Hornebom <you@hornebom.com> Date: Tue, 29 Aug 2023 14:30:16 +0200 Subject: [PATCH 11/39] Pools Overview redesign (#1526) --- centrifuge-app/.env-config/.env.altair | 3 +- centrifuge-app/.env-config/.env.catalyst | 1 + centrifuge-app/.env-config/.env.demo | 1 + centrifuge-app/.env-config/.env.development | 1 + centrifuge-app/.env-config/.env.example | 1 + centrifuge-app/.env-config/.env.production | 1 + .../src/components/CardTotalValueLocked.tsx | 92 +++++++++ .../components/Charts/TotalValueLocked.tsx | 104 ++++++++++ centrifuge-app/src/components/GlobalStyle.tsx | 10 + .../src/components/LayoutBase/BaseSection.tsx | 15 ++ .../src/components/LayoutBase/config.ts | 9 + .../src/components/LayoutBase/index.tsx | 61 ++++++ .../src/components/LayoutBase/styles.tsx | 157 +++++++++++++++ .../LiquidityRewardsProvider.tsx | 2 +- .../src/components/LiquidityRewards/hooks.ts | 11 +- .../src/components/LogoLink-deprecated.tsx | 19 ++ centrifuge-app/src/components/LogoLink.tsx | 10 +- .../Menu-deprecated/GovernanceMenu.tsx | 138 +++++++++++++ .../components/Menu-deprecated/IssuerMenu.tsx | 69 +++++++ .../components/Menu-deprecated/PageLink.tsx | 26 +++ .../components/Menu-deprecated/PoolLink.tsx | 36 ++++ .../src/components/Menu-deprecated/Toggle.tsx | 11 ++ .../src/components/Menu-deprecated/index.tsx | 85 ++++++++ .../src/components/Menu-deprecated/styles.ts | 44 +++++ .../src/components/Menu/GovernanceMenu.tsx | 20 +- .../src/components/Menu/IssuerMenu.tsx | 14 +- centrifuge-app/src/components/Menu/index.tsx | 10 +- centrifuge-app/src/components/Menu/styles.ts | 4 +- centrifuge-app/src/components/MenuSwitch.tsx | 46 ++--- .../src/components/PageWithSideBar.tsx | 4 +- centrifuge-app/src/components/PoolCard.tsx | 128 ------------ .../src/components/PoolCard/PoolStatus.tsx | 14 ++ .../src/components/PoolCard/index.tsx | 95 +++++++++ .../src/components/PoolCard/styles.tsx | 36 ++++ .../src/components/PoolFilter/FilterMenu.tsx | 119 ++++++++++++ .../src/components/PoolFilter/SortButton.tsx | 108 +++++++++++ .../src/components/PoolFilter/config.ts | 30 +++ .../src/components/PoolFilter/index.tsx | 50 +++++ .../src/components/PoolFilter/styles.ts | 26 +++ .../src/components/PoolFilter/types.ts | 6 + .../src/components/PoolFilter/utils.ts | 54 ++++++ centrifuge-app/src/components/PoolList.tsx | 33 ++-- .../src/components/PoolsTokensShared.tsx | 43 +++++ .../src/components/PortfolioCta/Cubes.tsx | 70 +++++++ .../src/components/PortfolioCta/index.tsx | 106 ++++++++++ centrifuge-app/src/components/SideDrawer.tsx | 63 ++++++ centrifuge-app/src/components/Tooltips.tsx | 2 +- centrifuge-app/src/pages/Pools.tsx | 155 +++++++++------ centrifuge-app/src/pages/Tokens.tsx | 182 +++++++----------- centrifuge-app/src/utils/formatting.ts | 7 +- .../utils/tinlake/fetchFromTinlakeSubgraph.ts | 20 ++ .../utils/tinlake/getTinlakeSubgraphTVL.ts | 40 ++++ .../src/utils/tinlake/useTinlakePools.ts | 2 +- centrifuge-app/src/utils/useMetadata.ts | 2 +- centrifuge-app/src/utils/usePools.ts | 8 + centrifuge-js/src/modules/pools.ts | 81 +++++++- centrifuge-js/src/modules/rewards.ts | 22 ++- fabric/src/components/Checkbox/index.tsx | 76 +++++--- fabric/src/components/Popover/index.tsx | 6 +- .../src/components/SideNavigation/index.tsx | 45 +++++ fabric/src/components/StatusChip/index.tsx | 2 + fabric/src/icon-svg/icon-filter.svg | 5 + fabric/src/index.ts | 1 + fabric/src/theme/tokens/baseTheme.ts | 1 + fabric/src/theme/types.ts | 2 +- 65 files changed, 2226 insertions(+), 419 deletions(-) create mode 100644 centrifuge-app/src/components/CardTotalValueLocked.tsx create mode 100644 centrifuge-app/src/components/Charts/TotalValueLocked.tsx create mode 100644 centrifuge-app/src/components/LayoutBase/BaseSection.tsx create mode 100644 centrifuge-app/src/components/LayoutBase/config.ts create mode 100644 centrifuge-app/src/components/LayoutBase/index.tsx create mode 100644 centrifuge-app/src/components/LayoutBase/styles.tsx create mode 100644 centrifuge-app/src/components/LogoLink-deprecated.tsx create mode 100644 centrifuge-app/src/components/Menu-deprecated/GovernanceMenu.tsx create mode 100644 centrifuge-app/src/components/Menu-deprecated/IssuerMenu.tsx create mode 100644 centrifuge-app/src/components/Menu-deprecated/PageLink.tsx create mode 100644 centrifuge-app/src/components/Menu-deprecated/PoolLink.tsx create mode 100644 centrifuge-app/src/components/Menu-deprecated/Toggle.tsx create mode 100644 centrifuge-app/src/components/Menu-deprecated/index.tsx create mode 100644 centrifuge-app/src/components/Menu-deprecated/styles.ts delete mode 100644 centrifuge-app/src/components/PoolCard.tsx create mode 100644 centrifuge-app/src/components/PoolCard/PoolStatus.tsx create mode 100644 centrifuge-app/src/components/PoolCard/index.tsx create mode 100644 centrifuge-app/src/components/PoolCard/styles.tsx create mode 100644 centrifuge-app/src/components/PoolFilter/FilterMenu.tsx create mode 100644 centrifuge-app/src/components/PoolFilter/SortButton.tsx create mode 100644 centrifuge-app/src/components/PoolFilter/config.ts create mode 100644 centrifuge-app/src/components/PoolFilter/index.tsx create mode 100644 centrifuge-app/src/components/PoolFilter/styles.ts create mode 100644 centrifuge-app/src/components/PoolFilter/types.ts create mode 100644 centrifuge-app/src/components/PoolFilter/utils.ts create mode 100644 centrifuge-app/src/components/PoolsTokensShared.tsx create mode 100644 centrifuge-app/src/components/PortfolioCta/Cubes.tsx create mode 100644 centrifuge-app/src/components/PortfolioCta/index.tsx create mode 100644 centrifuge-app/src/components/SideDrawer.tsx create mode 100644 centrifuge-app/src/utils/tinlake/fetchFromTinlakeSubgraph.ts create mode 100644 centrifuge-app/src/utils/tinlake/getTinlakeSubgraphTVL.ts create mode 100644 fabric/src/components/SideNavigation/index.tsx create mode 100644 fabric/src/icon-svg/icon-filter.svg diff --git a/centrifuge-app/.env-config/.env.altair b/centrifuge-app/.env-config/.env.altair index a45415d108..af289426e9 100644 --- a/centrifuge-app/.env-config/.env.altair +++ b/centrifuge-app/.env-config/.env.altair @@ -15,5 +15,6 @@ REACT_APP_TINLAKE_NETWORK=goerli REACT_APP_INFURA_KEY=bf808e7d3d924fbeb74672d9341d0550 REACT_APP_WHITELISTED_ACCOUNTS= REACT_APP_REWARDS_TREE_URL=https://storage.googleapis.com/rad-rewards-trees-kovan-staging/latest.json -REACT_APP_WALLETCONNECT_ID=c32fa79350803519804a67fcab0b742a REACT_APP_MEMBERLIST_ADMIN_PURE_PROXY=kALJqPUHFzDR2VkoQYWefPQyzjGzKznNny2smXGQpSf3aMw19 +REACT_APP_WALLETCONNECT_ID=c32fa79350803519804a67fcab0b742a +REACT_APP_TINLAKE_SUBGRAPH_URL=https://graph.centrifuge.io/tinlake diff --git a/centrifuge-app/.env-config/.env.catalyst b/centrifuge-app/.env-config/.env.catalyst index 904670ff09..aa45a5272c 100644 --- a/centrifuge-app/.env-config/.env.catalyst +++ b/centrifuge-app/.env-config/.env.catalyst @@ -17,3 +17,4 @@ REACT_APP_WHITELISTED_ACCOUNTS= REACT_APP_REWARDS_TREE_URL=https://storage.googleapis.com/rad-rewards-trees-kovan-staging/latest.json REACT_APP_MEMBERLIST_ADMIN_PURE_PROXY=4bo2vNkwZtr2PuqppWwqya6dPC8MzxqZ4kgnAoTZyKo9Kxq8 REACT_APP_WALLETCONNECT_ID=c32fa79350803519804a67fcab0b742a +REACT_APP_TINLAKE_SUBGRAPH_URL=https://graph.centrifuge.io/tinlake diff --git a/centrifuge-app/.env-config/.env.demo b/centrifuge-app/.env-config/.env.demo index 2df090c581..db85519d73 100644 --- a/centrifuge-app/.env-config/.env.demo +++ b/centrifuge-app/.env-config/.env.demo @@ -17,3 +17,4 @@ REACT_APP_NETWORK=centrifuge REACT_APP_REWARDS_TREE_URL=https://storage.googleapis.com/rad-rewards-trees-kovan-staging/latest.json REACT_APP_MEMBERLIST_ADMIN_PURE_PROXY=kALwmJutBq95s41U9fWnoApCUgvPqPGTh1GSmFnQh5f9fWo93 REACT_APP_WALLETCONNECT_ID=c32fa79350803519804a67fcab0b742a +REACT_APP_TINLAKE_SUBGRAPH_URL=https://graph.centrifuge.io/tinlake diff --git a/centrifuge-app/.env-config/.env.development b/centrifuge-app/.env-config/.env.development index 83a78f8a75..a1a9a0e836 100644 --- a/centrifuge-app/.env-config/.env.development +++ b/centrifuge-app/.env-config/.env.development @@ -14,6 +14,7 @@ REACT_APP_SUBSCAN_URL= REACT_APP_TINLAKE_NETWORK=goerli REACT_APP_INFURA_KEY=bf808e7d3d924fbeb74672d9341d0550 REACT_APP_WHITELISTED_ACCOUNTS= +REACT_APP_TINLAKE_SUBGRAPH_URL=https://graph.centrifuge.io/tinlake REACT_APP_REWARDS_TREE_URL=https://storage.googleapis.com/rad-rewards-trees-kovan-staging/latest.json REACT_APP_WALLETCONNECT_ID=c32fa79350803519804a67fcab0b742a REACT_APP_MEMBERLIST_ADMIN_PURE_PROXY=kAKfp33p1SHRq6d1BMtGndP7Cek6pH6oZKKUoA7wJXRUqf6FY diff --git a/centrifuge-app/.env-config/.env.example b/centrifuge-app/.env-config/.env.example index 2b26c1d0e5..a6caf14422 100644 --- a/centrifuge-app/.env-config/.env.example +++ b/centrifuge-app/.env-config/.env.example @@ -17,3 +17,4 @@ REACT_APP_WHITELISTED_ACCOUNTS='' REACT_APP_REWARDS_TREE_URL=https://storage.googleapis.com/rad-rewards-trees-kovan-staging/latest.json REACT_APP_MEMBERLIST_ADMIN_PURE_PROXY=kALJqPUHFzDR2VkoQYWefPQyzjGzKznNny2smXGQpSf3aMw19 REACT_APP_WALLETCONNECT_ID=c32fa79350803519804a67fcab0b742a +REACT_APP_TINLAKE_SUBGRAPH_URL=https://graph.centrifuge.io/tinlake diff --git a/centrifuge-app/.env-config/.env.production b/centrifuge-app/.env-config/.env.production index b127592e39..a9c9ccc116 100644 --- a/centrifuge-app/.env-config/.env.production +++ b/centrifuge-app/.env-config/.env.production @@ -17,3 +17,4 @@ REACT_APP_WHITELISTED_ACCOUNTS='' REACT_APP_REWARDS_TREE_URL=https://storage.googleapis.com/rad-rewards-trees-mainnet-production/latest.json REACT_APP_MEMBERLIST_ADMIN_PURE_PROXY=kALJqPUHFzDR2VkoQYWefPQyzjGzKznNny2smXGQpSf3aMw19 REACT_APP_WALLETCONNECT_ID=c32fa79350803519804a67fcab0b742a +REACT_APP_TINLAKE_SUBGRAPH_URL=https://graph.centrifuge.io/tinlake diff --git a/centrifuge-app/src/components/CardTotalValueLocked.tsx b/centrifuge-app/src/components/CardTotalValueLocked.tsx new file mode 100644 index 0000000000..c794c62fba --- /dev/null +++ b/centrifuge-app/src/components/CardTotalValueLocked.tsx @@ -0,0 +1,92 @@ +import { Box, Stack, Text, TextWithPlaceholder, Tooltip } from '@centrifuge/fabric' +import * as React from 'react' +import { useTheme } from 'styled-components' +import { config } from '../config' +import { formatDate } from '../utils/date' +import { Dec } from '../utils/Decimal' +import { formatBalance } from '../utils/formatting' +import { useListedPools } from '../utils/useListedPools' +import { DataPoint, TotalValueLocked } from './Charts/TotalValueLocked' +import { tooltipText } from './Tooltips' + +export function CardTotalValueLocked() { + const { colors } = useTheme() + const [hovered, setHovered] = React.useState<DataPoint | undefined>(undefined) + const [, listedTokens] = useListedPools() + + const chartHeight = 100 + const balanceProps = { + as: 'strong', + fontSize: [28, 32], + } + const headingProps = { + as: 'h2', + variant: 'heading3', + } + + const totalValueLocked = React.useMemo(() => { + return ( + listedTokens + ?.map((tranche) => ({ + valueLocked: tranche.totalIssuance + .toDecimal() + .mul(tranche.tokenPrice?.toDecimal() ?? Dec(0)) + .toNumber(), + })) + .reduce((prev, curr) => prev.add(curr.valueLocked), Dec(0)) ?? Dec(0) + ) + }, [listedTokens]) + + return ( + <Box + role="article" + borderRadius="card" + borderStyle="solid" + borderWidth={1} + borderColor="borderSecondary" + p={3} + pb={chartHeight * 0.6} + position="relative" + style={{ + boxShadow: `0px 3px 2px -2px ${colors.borderPrimary}`, + }} + > + <Stack style={{ pointerEvents: 'none' }}> + {hovered ? ( + <> + <Text {...headingProps}> + TVL on{' '} + <time dateTime={new Date(hovered.dateInMilliseconds).toISOString()}> + {formatDate(hovered.dateInMilliseconds)} + </time> + </Text> + <Text {...balanceProps}>{formatBalance(Dec(hovered?.tvl || 0), config.baseCurrency)}</Text> + </> + ) : ( + <> + <Tooltip body={tooltipText.tvl.body} style={{ pointerEvents: 'auto' }}> + <Text {...headingProps}>{tooltipText.tvl.label}</Text> + </Tooltip> + <TextWithPlaceholder {...balanceProps} isLoading={!totalValueLocked}> + {formatBalance(Dec(totalValueLocked || 0), config.baseCurrency)} + </TextWithPlaceholder> + </> + )} + </Stack> + + <Box + as="figure" + position="absolute" + right={0} + bottom={0} + width="100%" + height={chartHeight} + overflow="hidden" + borderBottomRightRadius="card" + borderBottomLeftRadius="card" + > + <TotalValueLocked setHovered={setHovered} chainTVL={totalValueLocked} /> + </Box> + </Box> + ) +} diff --git a/centrifuge-app/src/components/Charts/TotalValueLocked.tsx b/centrifuge-app/src/components/Charts/TotalValueLocked.tsx new file mode 100644 index 0000000000..dabe29c187 --- /dev/null +++ b/centrifuge-app/src/components/Charts/TotalValueLocked.tsx @@ -0,0 +1,104 @@ +import Decimal from 'decimal.js-light' +import * as React from 'react' +import { useQuery } from 'react-query' +import { Area, AreaChart, ResponsiveContainer, Tooltip } from 'recharts' +import { getTinlakeSubgraphTVL } from '../../utils/tinlake/getTinlakeSubgraphTVL' +import { useDailyTVL } from '../../utils/usePools' + +export type DataPoint = { + dateInMilliseconds: number + tvl: Decimal +} + +type TotalValueLockedProps = { + chainTVL: Decimal + setHovered: (entry: DataPoint | undefined) => void +} + +export function TotalValueLocked({ chainTVL, setHovered }: TotalValueLockedProps) { + const centrifugeTVL = useDailyTVL() + const tinlakeTVL = useDailyTinlakeTVL() + const chartColor = '#ff8c00' + + const chartData = React.useMemo(() => { + if (!tinlakeTVL || !centrifugeTVL) { + return [] + } + + const currentTVL = chainTVL + ? { + dateInMilliseconds: new Date().setHours(0, 0, 0, 0), + tvl: chainTVL, + } + : undefined + + return getMergedData([...tinlakeTVL, ...centrifugeTVL], currentTVL) + }, [tinlakeTVL, centrifugeTVL, chainTVL]) + + return ( + <ResponsiveContainer> + <AreaChart + data={chartData} + margin={{ top: 0, left: 0, right: 0, bottom: 0 }} + onMouseMove={(val: any) => { + if (val?.activePayload && val?.activePayload.length > 0) { + setHovered(val.activePayload[0].payload) + } + }} + onMouseLeave={() => { + setHovered(undefined) + }} + > + <defs> + <linearGradient id="colorPoolValue" x1="0" y1="0" x2="0" y2="1"> + <stop offset="5%" stopColor={chartColor} stopOpacity={0.3} /> + <stop offset="95%" stopColor={chartColor} stopOpacity={0} /> + </linearGradient> + </defs> + <Area + type="monotone" + dataKey="tvl" + strokeWidth={0} + fillOpacity={1} + fill="url(#colorPoolValue)" + name="Current Value Locked" + activeDot={{ fill: chartColor }} + /> + <Tooltip content={<></>} /> + </AreaChart> + </ResponsiveContainer> + ) +} + +function useDailyTinlakeTVL() { + const { data } = useQuery('use daily tinlake tvl', getTinlakeSubgraphTVL, { + staleTime: Infinity, + suspense: true, + }) + + return data +} + +function getMergedData(combined: DataPoint[], current?: DataPoint) { + const mergedMap = new Map() + + combined.forEach((entry) => { + const { dateInMilliseconds, tvl } = entry + + if (mergedMap.has(dateInMilliseconds)) { + mergedMap.set(dateInMilliseconds, mergedMap.get(dateInMilliseconds).add(tvl)) + } else { + mergedMap.set(dateInMilliseconds, tvl) + } + }) + + if (current) { + mergedMap.set(current.dateInMilliseconds, current.tvl) + } + + const merged = Array.from(mergedMap, ([dateInMilliseconds, tvl]) => ({ dateInMilliseconds, tvl })) + .sort((a, b) => a.dateInMilliseconds - b.dateInMilliseconds) + .map((entry) => ({ ...entry, tvl: entry.tvl.toNumber() })) + + return merged +} diff --git a/centrifuge-app/src/components/GlobalStyle.tsx b/centrifuge-app/src/components/GlobalStyle.tsx index ebf029e575..18f1aa1aa0 100644 --- a/centrifuge-app/src/components/GlobalStyle.tsx +++ b/centrifuge-app/src/components/GlobalStyle.tsx @@ -27,4 +27,14 @@ export const GlobalStyle = createGlobalStyle` ul { list-style: none; } + + .visually-hidden { + clip: rect(0 0 0 0); + clip-path: inset(50%); + height: 1px; + overflow: hidden; + position: absolute; + white-space: nowrap; + width: 1px; + } ` diff --git a/centrifuge-app/src/components/LayoutBase/BaseSection.tsx b/centrifuge-app/src/components/LayoutBase/BaseSection.tsx new file mode 100644 index 0000000000..ab523309f7 --- /dev/null +++ b/centrifuge-app/src/components/LayoutBase/BaseSection.tsx @@ -0,0 +1,15 @@ +import { Box, BoxProps } from '@centrifuge/fabric' +import * as React from 'react' +import { config } from './config' + +type BaseSectionProps = BoxProps & { + children: React.ReactNode +} + +export function BaseSection({ children, ...boxProps }: BaseSectionProps) { + return ( + <Box px={config.PADDING_MAIN} {...boxProps}> + <Box maxWidth={config.LAYOUT_MAX_WIDTH}>{children}</Box> + </Box> + ) +} diff --git a/centrifuge-app/src/components/LayoutBase/config.ts b/centrifuge-app/src/components/LayoutBase/config.ts new file mode 100644 index 0000000000..dcb172ee3c --- /dev/null +++ b/centrifuge-app/src/components/LayoutBase/config.ts @@ -0,0 +1,9 @@ +export const config = { + HEADER_HEIGHT: 60, + TOOLBAR_HEIGHT: 50, + LAYOUT_MAX_WIDTH: 1800, + SIDEBAR_WIDTH: 80, + SIDEBAR_WIDTH_EXTENDED: 220, + PADDING_MAIN: [2, 2, 3, 3, 5], + WALLET_WIDTH: [200, 264], +} diff --git a/centrifuge-app/src/components/LayoutBase/index.tsx b/centrifuge-app/src/components/LayoutBase/index.tsx new file mode 100644 index 0000000000..03ca4abf07 --- /dev/null +++ b/centrifuge-app/src/components/LayoutBase/index.tsx @@ -0,0 +1,61 @@ +import { WalletMenu } from '@centrifuge/centrifuge-react' +import * as React from 'react' +import { Footer } from '../Footer' +import { LoadBoundary } from '../LoadBoundary' +import { LogoLink } from '../LogoLink' +import { Menu } from '../Menu' +import { OnboardingStatus } from '../OnboardingStatus' +import { SideDrawerProps } from '../SideDrawer' +import { config } from './config' +import { + FooterContainer, + HeaderBackground, + Inner, + LogoContainer, + MainContainer, + Root, + ToolbarContainer, + WalletContainer, + WalletInner, + WalletPositioner, +} from './styles' + +type LayoutBaseProps = { + children?: React.ReactNode + sideDrawer?: React.ReactElement<SideDrawerProps> +} + +export function LayoutBase({ children, sideDrawer }: LayoutBaseProps) { + return ( + <Root> + <Inner> + <HeaderBackground /> + + <LogoContainer> + <LogoLink /> + </LogoContainer> + + <WalletContainer px={config.PADDING_MAIN}> + <WalletPositioner> + <WalletInner minWidth={config.WALLET_WIDTH}> + <WalletMenu menuItems={[<OnboardingStatus />]} /> + </WalletInner> + </WalletPositioner> + </WalletContainer> + + <ToolbarContainer as="aside"> + <Menu /> + </ToolbarContainer> + + <LoadBoundary> + <MainContainer as="main">{children}</MainContainer> + </LoadBoundary> + + <FooterContainer> + <Footer /> + </FooterContainer> + </Inner> + <LoadBoundary>{sideDrawer}</LoadBoundary> + </Root> + ) +} diff --git a/centrifuge-app/src/components/LayoutBase/styles.tsx b/centrifuge-app/src/components/LayoutBase/styles.tsx new file mode 100644 index 0000000000..bf6e22e4fe --- /dev/null +++ b/centrifuge-app/src/components/LayoutBase/styles.tsx @@ -0,0 +1,157 @@ +import { Box, Grid, Shelf, Stack } from '@centrifuge/fabric' +import styled from 'styled-components' +import { config } from './config' + +// the main breakpoint to switch from stacked to columns layout +const BREAK_POINT_COLUMNS = 'M' +// breakpoint from minimal to extended left sidebar width +const BREAK_POINT_SIDEBAR_EXTENDED = 'L' + +export const Root = styled(Box)` + position: relative; + min-height: 100vh; + + @supports (min-height: 100dvh) { + min-height: 100dvh; + } + + @media (min-width: ${({ theme }) => theme.breakpoints[BREAK_POINT_COLUMNS]}) { + height: 100vh; + overflow: auto; + + @supports (height: 100dvh) { + height: 100dvh; + } + } +` + +export const Inner = styled(Grid)` + min-height: 100%; + + align-items: start; + grid-template-rows: 0px ${config.HEADER_HEIGHT}px 1fr auto ${config.TOOLBAR_HEIGHT}px; + grid-template-columns: auto 1fr; + grid-template-areas: + 'header-background header-background' + 'logo wallet' + 'main main' + 'footer footer' + 'toolbar toolbar'; + + @media (min-width: ${({ theme }) => theme.breakpoints[BREAK_POINT_COLUMNS]}) { + grid-template-rows: ${config.HEADER_HEIGHT}px minmax(max-content, 1fr) auto; + grid-template-columns: ${config.SIDEBAR_WIDTH}px 1fr; + grid-template-areas: + 'logo wallet' + 'toolbar main' + 'footer main'; + } + + @media (min-width: ${({ theme }) => theme.breakpoints[BREAK_POINT_SIDEBAR_EXTENDED]}) { + grid-template-columns: ${config.SIDEBAR_WIDTH_EXTENDED}px 1fr; + } +` + +export const HeaderBackground = styled(Box)` + z-index: ${({ theme }) => theme.zIndices.header}; + position: sticky; + top: 0; + left: 0; + + grid-area: header-background; + width: 100%; + height: ${config.HEADER_HEIGHT}px; + + background-color: ${({ theme }) => theme.colors.backgroundPrimary}; + border-bottom: ${({ theme }) => `1px solid ${theme.colors.borderSecondary}`}; + + @media (min-width: ${({ theme }) => theme.breakpoints[BREAK_POINT_COLUMNS]}) { + display: none; + } +` + +export const ToolbarContainer = styled(Box)` + z-index: ${({ theme }) => theme.zIndices.header}; + grid-area: toolbar; + position: sticky; + left: 0; + bottom: 0; + height: ${config.TOOLBAR_HEIGHT}px; + + border-top: ${({ theme }) => `1px solid ${theme.colors.borderSecondary}`}; + background-color: ${({ theme }) => theme.colors.backgroundPrimary}; + + @media (min-width: ${({ theme }) => theme.breakpoints[BREAK_POINT_COLUMNS]}) { + top: ${({ theme }) => theme.space[4] + config.HEADER_HEIGHT}px; + bottom: auto; + height: auto; + + border-top: 0; + background-color: transparent; + } + + @media (min-width: ${({ theme }) => theme.breakpoints[BREAK_POINT_SIDEBAR_EXTENDED]}) { + padding: ${({ theme }) => theme.space[2]}px; + } +` + +export const LogoContainer = styled(Stack)` + z-index: ${({ theme }) => theme.zIndices.header}; + position: sticky; + top: 0; + + grid-area: logo; + height: ${config.HEADER_HEIGHT}px; + padding-left: ${({ theme }) => theme.space[2]}px; + justify-content: center; + + @media (min-width: ${({ theme }) => theme.breakpoints[BREAK_POINT_COLUMNS]}) { + justify-content: start; + padding-top: ${({ theme }) => theme.space[2]}px; + } +` + +export const WalletContainer = styled(Stack)` + z-index: ${({ theme }) => theme.zIndices.header}; + position: sticky; + top: 0; + grid-area: wallet; + // WalletContainer & WalletPositioner are positioned above the main content and would block user interaction (click). + // disabling pointer-events here and enable again on WalletInner + pointer-events: none; +` + +export const WalletPositioner = styled(Shelf)` + max-width: ${config.LAYOUT_MAX_WIDTH}px; + justify-content: flex-end; + align-items: flex-start; +` + +export const WalletInner = styled(Stack)` + height: ${config.HEADER_HEIGHT}px; + justify-content: center; + pointer-events: auto; // resetting pointer events + + @media (min-width: ${({ theme }) => theme.breakpoints[BREAK_POINT_COLUMNS]}) { + justify-content: flex-end; + } +` + +export const MainContainer = styled(Box)` + grid-area: main; + align-self: stretch; + + @media (min-width: ${({ theme }) => theme.breakpoints[BREAK_POINT_COLUMNS]}) { + margin-top: calc(${config.HEADER_HEIGHT}px * -1); + border-left: ${({ theme }) => `1px solid ${theme.colors.borderSecondary}`}; + } +` + +export const FooterContainer = styled(Box)` + grid-area: footer; + + @media (min-width: ${({ theme }) => theme.breakpoints[BREAK_POINT_COLUMNS]}) { + position: sticky; + bottom: 0; + } +` diff --git a/centrifuge-app/src/components/LiquidityRewards/LiquidityRewardsProvider.tsx b/centrifuge-app/src/components/LiquidityRewards/LiquidityRewardsProvider.tsx index 909001841f..3358d55443 100644 --- a/centrifuge-app/src/components/LiquidityRewards/LiquidityRewardsProvider.tsx +++ b/centrifuge-app/src/components/LiquidityRewards/LiquidityRewardsProvider.tsx @@ -20,7 +20,7 @@ function Provider({ poolId, trancheId, children }: LiquidityRewardsProviderProps const address = useAddress() const order = usePendingCollect(poolId, trancheId, address) const stakes = useAccountStakes(address, poolId, trancheId) - const rewards = useComputeLiquidityRewards(address, poolId, trancheId) + const rewards = useComputeLiquidityRewards(address, [{ poolId, trancheId }]) const balances = useBalances(address) const rewardCurrencyGroup = useRewardCurrencyGroup(poolId, trancheId) diff --git a/centrifuge-app/src/components/LiquidityRewards/hooks.ts b/centrifuge-app/src/components/LiquidityRewards/hooks.ts index 9b59234125..44d422a30c 100644 --- a/centrifuge-app/src/components/LiquidityRewards/hooks.ts +++ b/centrifuge-app/src/components/LiquidityRewards/hooks.ts @@ -1,5 +1,6 @@ import { useCentrifugeQuery } from '@centrifuge/centrifuge-react' import * as React from 'react' +import { Dec } from '../../utils/Decimal' export function useAccountStakes(address?: string, poolId?: string, trancheId?: string) { const [result] = useCentrifugeQuery( @@ -46,16 +47,16 @@ export function useRewardCurrencyGroup(poolId?: string, trancheId?: string) { return result } -export function useComputeLiquidityRewards(address?: string, poolId?: string, trancheId?: string) { +export function useComputeLiquidityRewards(address?: string, tranches?: { poolId: string; trancheId: string }[]) { const [result] = useCentrifugeQuery( - ['compute liquidity rewards', address, poolId, trancheId], - (cent) => cent.rewards.computeReward([address!, poolId!, trancheId!, 'Liquidity']), + ['compute liquidity rewards', address, tranches?.map(({ trancheId }) => trancheId).join('')], + (cent) => cent.rewards.computeReward([address!, tranches!, 'Liquidity']), { - enabled: !!address && !!poolId && !!trancheId, + enabled: !!address && !!tranches && tranches.length > 0, } ) - return result + return result ?? Dec(0) } // list of all staked currencyIds diff --git a/centrifuge-app/src/components/LogoLink-deprecated.tsx b/centrifuge-app/src/components/LogoLink-deprecated.tsx new file mode 100644 index 0000000000..c95b180e90 --- /dev/null +++ b/centrifuge-app/src/components/LogoLink-deprecated.tsx @@ -0,0 +1,19 @@ +import { Box } from '@centrifuge/fabric' +import { Link } from 'react-router-dom' +import { config } from '../config' +import { useIsAboveBreakpoint } from '../utils/useIsAboveBreakpoint' + +const [LogoMark, WordMark] = config.logo + +export function LogoLink() { + const isMedium = useIsAboveBreakpoint('M') + const isXLarge = useIsAboveBreakpoint('XL') + + return ( + <Link to="/"> + <Box color="textPrimary" width={[80, 80, 36, 36, 120]}> + {isMedium && !isXLarge ? <LogoMark /> : <WordMark />} + </Box> + </Link> + ) +} diff --git a/centrifuge-app/src/components/LogoLink.tsx b/centrifuge-app/src/components/LogoLink.tsx index c95b180e90..18b912ce61 100644 --- a/centrifuge-app/src/components/LogoLink.tsx +++ b/centrifuge-app/src/components/LogoLink.tsx @@ -7,13 +7,11 @@ const [LogoMark, WordMark] = config.logo export function LogoLink() { const isMedium = useIsAboveBreakpoint('M') - const isXLarge = useIsAboveBreakpoint('XL') + const isLarge = useIsAboveBreakpoint('L') return ( - <Link to="/"> - <Box color="textPrimary" width={[80, 80, 36, 36, 120]}> - {isMedium && !isXLarge ? <LogoMark /> : <WordMark />} - </Box> - </Link> + <Box as={Link} to="/" display="block" width={[80, 80, 36, 108]}> + {isMedium && !isLarge ? <LogoMark /> : <WordMark />} + </Box> ) } diff --git a/centrifuge-app/src/components/Menu-deprecated/GovernanceMenu.tsx b/centrifuge-app/src/components/Menu-deprecated/GovernanceMenu.tsx new file mode 100644 index 0000000000..2b00cb50a1 --- /dev/null +++ b/centrifuge-app/src/components/Menu-deprecated/GovernanceMenu.tsx @@ -0,0 +1,138 @@ +import { + Box, + IconChevronDown, + IconChevronRight, + IconExternalLink, + IconGovernance, + Menu as Panel, + MenuItemGroup, + Stack, + Text, +} from '@centrifuge/fabric' +import * as React from 'react' +import styled, { useTheme } from 'styled-components' +import { useIsAboveBreakpoint } from '../../utils/useIsAboveBreakpoint' +import { baseButton } from './styles' +import { Toggle } from './Toggle' + +const ExternalLink = styled(Text)` + ${baseButton} + display: flex; + justify-content: space-between; + gap: ${({ theme }) => theme.space[1]}px; + white-space: nowrap; + + svg { + display: block; + width: ${({ theme }) => theme.sizes.iconSmall}px; + height: ${({ theme }) => theme.sizes.iconSmall}px; + object-fit: contain; + } +` + +export function GovernanceMenu() { + const [open, setOpen] = React.useState(false) + const { space } = useTheme() + const fullWidth = `calc(100vw - 2 * ${space[1]}px)` + const offset = `calc(100% + 2 * ${space[1]}px)` + const id = React.useId() + const isXLarge = useIsAboveBreakpoint('XL') + + return ( + <Box position={['static', 'static', 'relative', 'relative', 'static']}> + {open && ( + <Box + display={['block', 'block', 'block', 'block', 'none']} + position="fixed" + top="0" + left="0" + width="100%" + height="100%" + onClick={() => setOpen(false)} + /> + )} + + <Toggle + forwardedAs="button" + variant="interactive1" + id={`${id}-button`} + aria-controls={`${id}-menu`} + aria-label={open ? 'Hide menu' : 'Show menu'} + onClick={() => setOpen(!open)} + stacked={!isXLarge} + > + <IconGovernance /> + Governance + {isXLarge && (open ? <IconChevronDown /> : <IconChevronRight />)} + </Toggle> + + <Box + as="section" + hidden={!open} + id={`${id}-menu`} + aria-labelledby={`${id}-button`} + aria-expanded={!!open} + position={['absolute', 'absolute', 'absolute', 'absolute', 'static']} + top={['auto', 'auto', 0, 0, 'auto']} + bottom={[offset, offset, 'auto']} + left={[1, 1, offset, offset, 'auto']} + width={[fullWidth, fullWidth, 200, 200, '100%']} + mt={[0, 0, 0, 0, 1]} + > + {isXLarge ? ( + <Stack as="ul" gap={1}> + {links.map(({ href, label }) => ( + <Box as="li" pl={4} pr={1} key={href}> + <Link href={href} stacked={!isXLarge}> + {label} + </Link> + </Box> + ))} + </Stack> + ) : ( + <Panel> + {links.map(({ href, label }) => ( + <MenuItemGroup key={href}> + <Box px={2} py={1}> + <Link href={href} stacked={!isXLarge}> + {label} + </Link> + </Box> + </MenuItemGroup> + ))} + </Panel> + )} + </Box> + </Box> + ) +} + +const links = [ + { + href: 'https://centrifuge.subsquare.io/democracy/referenda', + label: 'On-chain voting', + }, + { + href: 'https://voting.opensquare.io/space/centrifuge', + label: 'Off-chain voting', + }, + { + href: 'https://gov.centrifuge.io/', + label: 'Governance forum', + }, +] + +function Link({ href, stacked, children }: { href: string; stacked: boolean; children: React.ReactNode }) { + return ( + <ExternalLink + variant="interactive1" + forwardedAs="a" + target="_blank" + rel="noopener noreferrer" + href={href} + stacked={stacked} + > + {children} <IconExternalLink /> + </ExternalLink> + ) +} diff --git a/centrifuge-app/src/components/Menu-deprecated/IssuerMenu.tsx b/centrifuge-app/src/components/Menu-deprecated/IssuerMenu.tsx new file mode 100644 index 0000000000..031ad80a8d --- /dev/null +++ b/centrifuge-app/src/components/Menu-deprecated/IssuerMenu.tsx @@ -0,0 +1,69 @@ +import { Box, IconChevronDown, IconChevronRight, IconUser } from '@centrifuge/fabric' +import * as React from 'react' +import { useRouteMatch } from 'react-router' +import { useTheme } from 'styled-components' +import { Toggle } from './Toggle' + +type IssuerMenuProps = { + defaultOpen?: boolean + poolIds?: string[] + stacked?: boolean + children?: React.ReactNode +} + +export function IssuerMenu({ defaultOpen = false, poolIds = [], stacked, children }: IssuerMenuProps) { + const match = useRouteMatch<{ pid: string }>('/issuer/:pid') + const isActive = match && poolIds.includes(match.params.pid) + const [open, setOpen] = React.useState(defaultOpen) + const { space } = useTheme() + const fullWidth = `calc(100vw - 2 * ${space[1]}px)` + const offset = `calc(100% + 2 * ${space[1]}px)` + const id = React.useId() + + return ( + <Box position={['static', 'static', 'relative', 'relative', 'static']}> + {open && ( + <Box + display={['block', 'block', 'block', 'block', 'none']} + position="fixed" + top="0" + left="0" + width="100%" + height="100%" + onClick={() => setOpen(false)} + /> + )} + + <Toggle + forwardedAs="button" + variant="interactive1" + id={`${id}-button`} + aria-controls={`${id}-menu`} + aria-label={open ? 'Hide menu' : 'Show menu'} + onClick={() => setOpen(!open)} + isActive={isActive} + stacked={stacked} + > + <IconUser /> + Issuer + {!stacked && (open ? <IconChevronDown /> : <IconChevronRight />)} + </Toggle> + + <Box + as="section" + hidden={!open} + id={`${id}-menu`} + aria-labelledby={`${id}-button`} + aria-expanded={!!open} + position={['absolute', 'absolute', 'absolute', 'absolute', 'static']} + top={['auto', 'auto', 0, 0, 'auto']} + bottom={[offset, offset, 'auto']} + left={[1, 1, offset, offset, 'auto']} + width={[fullWidth, fullWidth, 150, 150, '100%']} + mt={[0, 0, 0, 0, 1]} + > + {children} + </Box> + </Box> + ) +} diff --git a/centrifuge-app/src/components/Menu-deprecated/PageLink.tsx b/centrifuge-app/src/components/Menu-deprecated/PageLink.tsx new file mode 100644 index 0000000000..e79ff77046 --- /dev/null +++ b/centrifuge-app/src/components/Menu-deprecated/PageLink.tsx @@ -0,0 +1,26 @@ +import { Text } from '@centrifuge/fabric' +import * as React from 'react' +import { useRouteMatch } from 'react-router' +import { Link, LinkProps } from 'react-router-dom' +import styled from 'styled-components' +import { baseButton, primaryButton } from './styles' + +const Root = styled(Text)<{ isActive?: boolean; stacked?: boolean }>` + ${baseButton} + ${primaryButton} + grid-template-columns: ${({ stacked, theme }) => (stacked ? '1fr' : `${theme.sizes.iconSmall}px 1fr`)}; +` + +type PageLinkProps = LinkProps & { + stacked?: boolean +} + +export function PageLink({ stacked = false, to, children }: PageLinkProps) { + const match = useRouteMatch(to as string) + + return ( + <Root forwardedAs={Link} to={to} variant="interactive1" isActive={Boolean(match)} stacked={stacked}> + {children} + </Root> + ) +} diff --git a/centrifuge-app/src/components/Menu-deprecated/PoolLink.tsx b/centrifuge-app/src/components/Menu-deprecated/PoolLink.tsx new file mode 100644 index 0000000000..e5a47decb9 --- /dev/null +++ b/centrifuge-app/src/components/Menu-deprecated/PoolLink.tsx @@ -0,0 +1,36 @@ +import type { Pool } from '@centrifuge/centrifuge-js' +import { Text } from '@centrifuge/fabric' +import { useRouteMatch } from 'react-router' +import { Link } from 'react-router-dom' +import styled from 'styled-components' +import { usePoolMetadata } from '../../utils/usePools' +import { baseButton } from './styles' + +const Root = styled(Text)` + ${baseButton} + display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + border-radius: ${({ theme }) => theme.radii.input}px; +` + +type PoolLinkProps = { + pool: Pool +} + +export function PoolLink({ pool }: PoolLinkProps) { + const match = useRouteMatch<{ pid: string }>('/issuer/:pid') + const { data: metadata } = usePoolMetadata(pool) + + return ( + <Root + forwardedAs={Link} + to={`/issuer/${pool.id}`} + variant="interactive1" + isActive={match && pool.id === match.params.pid} + > + {metadata?.pool?.name ?? pool.id} + </Root> + ) +} diff --git a/centrifuge-app/src/components/Menu-deprecated/Toggle.tsx b/centrifuge-app/src/components/Menu-deprecated/Toggle.tsx new file mode 100644 index 0000000000..1da3cd9199 --- /dev/null +++ b/centrifuge-app/src/components/Menu-deprecated/Toggle.tsx @@ -0,0 +1,11 @@ +import { Text } from '@centrifuge/fabric' +import styled from 'styled-components' +import { baseButton, primaryButton } from './styles' + +export const Toggle = styled(Text)<{ isActive?: boolean; stacked?: boolean }>` + ${baseButton} + ${primaryButton} + width: 100%; + grid-template-columns: ${({ stacked, theme }) => + stacked ? '1fr' : `${theme.sizes.iconSmall}px 1fr ${theme.sizes.iconSmall}px`}; +` diff --git a/centrifuge-app/src/components/Menu-deprecated/index.tsx b/centrifuge-app/src/components/Menu-deprecated/index.tsx new file mode 100644 index 0000000000..83e5d3d7e1 --- /dev/null +++ b/centrifuge-app/src/components/Menu-deprecated/index.tsx @@ -0,0 +1,85 @@ +import { Box, IconInvestments, IconNft, Menu as Panel, MenuItemGroup, Shelf, Stack } from '@centrifuge/fabric' +import { config } from '../../config' +import { useAddress } from '../../utils/useAddress' +import { useIsAboveBreakpoint } from '../../utils/useIsAboveBreakpoint' +import { usePools } from '../../utils/usePools' +import { RouterLinkButton } from '../RouterLinkButton' +import { GovernanceMenu } from './GovernanceMenu' +import { IssuerMenu } from './IssuerMenu' +import { PageLink } from './PageLink' +import { PoolLink } from './PoolLink' + +export function Menu() { + // const pools = usePoolsThatAnyConnectedAddressHasPermissionsFor() || [] + const pools = usePools() || [] + const isXLarge = useIsAboveBreakpoint('XL') + const address = useAddress('substrate') + + return ( + <Shelf + width="100%" + position="relative" + gap={1} + flexDirection={['row', 'row', 'column']} + alignItems={['center', 'center', 'stretch']} + > + <PageLink to="/pools" stacked={!isXLarge}> + <IconInvestments /> + Pools + </PageLink> + + {config.network !== 'centrifuge' && ( + <PageLink to="/nfts" stacked={!isXLarge}> + <IconNft /> + NFTs + </PageLink> + )} + + <GovernanceMenu /> + + {(pools.length > 0 || config.poolCreationType === 'immediate') && ( + <IssuerMenu defaultOpen={isXLarge} stacked={!isXLarge} poolIds={pools.map(({ id }) => id)}> + {isXLarge ? ( + <Stack as="ul" gap={1}> + {!!pools.length && + pools.map((pool) => ( + <Box key={pool.id} as="li" pl={4}> + <PoolLink pool={pool} /> + </Box> + ))} + {address && config.poolCreationType === 'immediate' && ( + <Shelf justifyContent="center" as="li" mt={1}> + <CreatePool /> + </Shelf> + )} + </Stack> + ) : ( + <Panel> + {!!pools.length && + pools.map((pool) => ( + <MenuItemGroup key={pool.id}> + <Box px={2} py={1}> + <PoolLink pool={pool} /> + </Box> + </MenuItemGroup> + ))} + {address && config.poolCreationType === 'immediate' && ( + <Box px={2} py={1}> + <CreatePool /> + </Box> + )} + </Panel> + )} + </IssuerMenu> + )} + </Shelf> + ) +} + +function CreatePool() { + return ( + <RouterLinkButton to="/issuer/create-pool" variant="secondary" small> + Create pool + </RouterLinkButton> + ) +} diff --git a/centrifuge-app/src/components/Menu-deprecated/styles.ts b/centrifuge-app/src/components/Menu-deprecated/styles.ts new file mode 100644 index 0000000000..5c72585550 --- /dev/null +++ b/centrifuge-app/src/components/Menu-deprecated/styles.ts @@ -0,0 +1,44 @@ +import { css } from 'styled-components' + +export const baseButton = css<{ isActive?: boolean }>` + cursor: pointer; + border: none; + color: ${({ isActive, theme }) => (isActive ? theme.colors.textSelected : theme.colors.textPrimary)}; + + :hover { + color: ${({ theme }) => theme.colors.textSelected}; + } + + :focus-visible { + color: ${({ theme }) => theme.colors.textSelected}; + outline: solid ${({ theme }) => theme.colors.textSelected}; + outline-offset: -1px; + } +` + +export const primaryButton = css<{ isActive?: boolean; stacked?: boolean }>` + display: grid; + gap: ${({ stacked, theme }) => (stacked ? 0 : `${theme.space[1]}px`)}; + grid-template-rows: ${({ stacked }) => (stacked ? '20px 1fr' : '1fr')}; + grid-auto-flow: ${({ stacked }) => (stacked ? 'column' : 'row')}; + justify-items: ${({ stacked }) => (stacked ? 'center' : 'start')}; + align-items: center; + padding: ${({ theme }) => theme.space[1]}px; + + background-color: ${({ isActive, theme }) => (isActive ? theme.colors.secondarySelectedBackground : 'transparent')}; + + @media (max-width: ${({ theme }) => theme.breakpoints['XL']}) { + font-size: 10px; + } + + @media (min-width: ${({ theme }) => theme.breakpoints['XL']}) { + border-radius: 16px; + } + + svg { + display: block; + width: ${({ theme }) => theme.sizes.iconSmall}px; + height: ${({ theme }) => theme.sizes.iconSmall}px; + object-fit: contain; + } +` diff --git a/centrifuge-app/src/components/Menu/GovernanceMenu.tsx b/centrifuge-app/src/components/Menu/GovernanceMenu.tsx index 2b00cb50a1..1152ada756 100644 --- a/centrifuge-app/src/components/Menu/GovernanceMenu.tsx +++ b/centrifuge-app/src/components/Menu/GovernanceMenu.tsx @@ -36,7 +36,7 @@ export function GovernanceMenu() { const fullWidth = `calc(100vw - 2 * ${space[1]}px)` const offset = `calc(100% + 2 * ${space[1]}px)` const id = React.useId() - const isXLarge = useIsAboveBreakpoint('XL') + const isLarge = useIsAboveBreakpoint('L') return ( <Box position={['static', 'static', 'relative', 'relative', 'static']}> @@ -59,11 +59,11 @@ export function GovernanceMenu() { aria-controls={`${id}-menu`} aria-label={open ? 'Hide menu' : 'Show menu'} onClick={() => setOpen(!open)} - stacked={!isXLarge} + stacked={!isLarge} > <IconGovernance /> Governance - {isXLarge && (open ? <IconChevronDown /> : <IconChevronRight />)} + {isLarge && (open ? <IconChevronDown /> : <IconChevronRight />)} </Toggle> <Box @@ -72,18 +72,18 @@ export function GovernanceMenu() { id={`${id}-menu`} aria-labelledby={`${id}-button`} aria-expanded={!!open} - position={['absolute', 'absolute', 'absolute', 'absolute', 'static']} - top={['auto', 'auto', 0, 0, 'auto']} + position={['absolute', 'absolute', 'absolute', 'static']} + top={['auto', 'auto', 0, 'auto']} bottom={[offset, offset, 'auto']} left={[1, 1, offset, offset, 'auto']} - width={[fullWidth, fullWidth, 200, 200, '100%']} - mt={[0, 0, 0, 0, 1]} + width={[fullWidth, fullWidth, 200, '100%']} + mt={[0, 0, 0, 1]} > - {isXLarge ? ( + {isLarge ? ( <Stack as="ul" gap={1}> {links.map(({ href, label }) => ( <Box as="li" pl={4} pr={1} key={href}> - <Link href={href} stacked={!isXLarge}> + <Link href={href} stacked={!isLarge}> {label} </Link> </Box> @@ -94,7 +94,7 @@ export function GovernanceMenu() { {links.map(({ href, label }) => ( <MenuItemGroup key={href}> <Box px={2} py={1}> - <Link href={href} stacked={!isXLarge}> + <Link href={href} stacked={!isLarge}> {label} </Link> </Box> diff --git a/centrifuge-app/src/components/Menu/IssuerMenu.tsx b/centrifuge-app/src/components/Menu/IssuerMenu.tsx index 031ad80a8d..58ed994907 100644 --- a/centrifuge-app/src/components/Menu/IssuerMenu.tsx +++ b/centrifuge-app/src/components/Menu/IssuerMenu.tsx @@ -20,11 +20,15 @@ export function IssuerMenu({ defaultOpen = false, poolIds = [], stacked, childre const offset = `calc(100% + 2 * ${space[1]}px)` const id = React.useId() + React.useEffect(() => { + setOpen(defaultOpen) + }, [defaultOpen]) + return ( - <Box position={['static', 'static', 'relative', 'relative', 'static']}> + <Box position={['static', 'static', 'relative', 'static']}> {open && ( <Box - display={['block', 'block', 'block', 'block', 'none']} + display={['block', 'block', 'block', 'none']} position="fixed" top="0" left="0" @@ -55,12 +59,12 @@ export function IssuerMenu({ defaultOpen = false, poolIds = [], stacked, childre id={`${id}-menu`} aria-labelledby={`${id}-button`} aria-expanded={!!open} - position={['absolute', 'absolute', 'absolute', 'absolute', 'static']} + position={['absolute', 'absolute', 'absolute', 'static']} top={['auto', 'auto', 0, 0, 'auto']} bottom={[offset, offset, 'auto']} left={[1, 1, offset, offset, 'auto']} - width={[fullWidth, fullWidth, 150, 150, '100%']} - mt={[0, 0, 0, 0, 1]} + width={[fullWidth, fullWidth, 150, '100%']} + mt={[0, 0, 0, 1]} > {children} </Box> diff --git a/centrifuge-app/src/components/Menu/index.tsx b/centrifuge-app/src/components/Menu/index.tsx index 83e5d3d7e1..e8479b6928 100644 --- a/centrifuge-app/src/components/Menu/index.tsx +++ b/centrifuge-app/src/components/Menu/index.tsx @@ -12,7 +12,7 @@ import { PoolLink } from './PoolLink' export function Menu() { // const pools = usePoolsThatAnyConnectedAddressHasPermissionsFor() || [] const pools = usePools() || [] - const isXLarge = useIsAboveBreakpoint('XL') + const isLarge = useIsAboveBreakpoint('L') const address = useAddress('substrate') return ( @@ -23,13 +23,13 @@ export function Menu() { flexDirection={['row', 'row', 'column']} alignItems={['center', 'center', 'stretch']} > - <PageLink to="/pools" stacked={!isXLarge}> + <PageLink to="/pools" stacked={!isLarge}> <IconInvestments /> Pools </PageLink> {config.network !== 'centrifuge' && ( - <PageLink to="/nfts" stacked={!isXLarge}> + <PageLink to="/nfts" stacked={!isLarge}> <IconNft /> NFTs </PageLink> @@ -38,8 +38,8 @@ export function Menu() { <GovernanceMenu /> {(pools.length > 0 || config.poolCreationType === 'immediate') && ( - <IssuerMenu defaultOpen={isXLarge} stacked={!isXLarge} poolIds={pools.map(({ id }) => id)}> - {isXLarge ? ( + <IssuerMenu defaultOpen={isLarge} stacked={!isLarge} poolIds={pools.map(({ id }) => id)}> + {isLarge ? ( <Stack as="ul" gap={1}> {!!pools.length && pools.map((pool) => ( diff --git a/centrifuge-app/src/components/Menu/styles.ts b/centrifuge-app/src/components/Menu/styles.ts index 5c72585550..ea1cec5e87 100644 --- a/centrifuge-app/src/components/Menu/styles.ts +++ b/centrifuge-app/src/components/Menu/styles.ts @@ -27,11 +27,11 @@ export const primaryButton = css<{ isActive?: boolean; stacked?: boolean }>` background-color: ${({ isActive, theme }) => (isActive ? theme.colors.secondarySelectedBackground : 'transparent')}; - @media (max-width: ${({ theme }) => theme.breakpoints['XL']}) { + @media (max-width: ${({ theme }) => theme.breakpoints['L']}) { font-size: 10px; } - @media (min-width: ${({ theme }) => theme.breakpoints['XL']}) { + @media (min-width: ${({ theme }) => theme.breakpoints['L']}) { border-radius: 16px; } diff --git a/centrifuge-app/src/components/MenuSwitch.tsx b/centrifuge-app/src/components/MenuSwitch.tsx index 20e5d56639..3f6acdd1df 100644 --- a/centrifuge-app/src/components/MenuSwitch.tsx +++ b/centrifuge-app/src/components/MenuSwitch.tsx @@ -1,41 +1,29 @@ -import { Box, Shelf, Text } from '@centrifuge/fabric' +import { SideNavigationContainer, SideNavigationItem } from '@centrifuge/fabric' import * as React from 'react' -import { NavLink, useLocation, useRouteMatch } from 'react-router-dom' -import { useTheme } from 'styled-components' +import { Link, useLocation, useRouteMatch } from 'react-router-dom' -export const MenuSwitch: React.VFC = () => { - const theme = useTheme() +export function MenuSwitch() { const { pathname } = useLocation() const basePath = useRouteMatch(['/pools', '/issuer'])?.path || '' const links = [ - { to: `${basePath}`, label: 'Pools' }, - { to: `${basePath}/tokens`, label: 'Tokens' }, + { + to: `${basePath}`, + label: 'Pools', + }, + { + to: `${basePath}/tokens`, + label: 'Tokens', + }, ] - const activeStyle = { - boxShadow: theme.shadows.cardInteractive, - background: theme.colors.backgroundPage, - } - return ( - <Shelf as="nav" bg="backgroundSecondary" borderRadius="20px" p="5px"> - {links.map((link) => ( - <Box borderRadius="20px" key={`${link.to}-${link.label}`}> - <NavLink - to={link.to} - style={{ padding: '8px 16px', borderRadius: '20px', display: 'block' }} - activeStyle={pathname === link.to ? activeStyle : {}} - > - <Text - variant="interactive2" - color={pathname === link.to ? theme.colors.textInteractive : theme.colors.textPrimary} - > - {link.label} - </Text> - </NavLink> - </Box> + <SideNavigationContainer> + {links.map(({ to, label }) => ( + <SideNavigationItem key={to} as={Link} to={to} $isActive={pathname === to}> + {label} + </SideNavigationItem> ))} - </Shelf> + </SideNavigationContainer> ) } diff --git a/centrifuge-app/src/components/PageWithSideBar.tsx b/centrifuge-app/src/components/PageWithSideBar.tsx index 273cba445e..03104e3c64 100644 --- a/centrifuge-app/src/components/PageWithSideBar.tsx +++ b/centrifuge-app/src/components/PageWithSideBar.tsx @@ -6,8 +6,8 @@ import { config } from '../config' import { useIsAboveBreakpoint } from '../utils/useIsAboveBreakpoint' import { Footer } from './Footer' import { LoadBoundary } from './LoadBoundary' -import { LogoLink } from './LogoLink' -import { Menu } from './Menu' +import { LogoLink } from './LogoLink-deprecated' +import { Menu } from './Menu-deprecated' import { OnboardingStatus } from './OnboardingStatus' import { TinlakeRewards } from './TinlakeRewards' diff --git a/centrifuge-app/src/components/PoolCard.tsx b/centrifuge-app/src/components/PoolCard.tsx deleted file mode 100644 index d823a0b19b..0000000000 --- a/centrifuge-app/src/components/PoolCard.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import { Pool } from '@centrifuge/centrifuge-js' -import { useCentrifuge } from '@centrifuge/centrifuge-react' -import { Box, Card, Grid, IconChevronRight, Shelf, TextWithPlaceholder, Thumbnail } from '@centrifuge/fabric' -import * as React from 'react' -import { useRouteMatch } from 'react-router' -import { Link } from 'react-router-dom' -import styled from 'styled-components' -import { formatBalance, formatPercentage } from '../utils/formatting' -import { getPoolValueLocked } from '../utils/getPoolValueLocked' -import { TinlakePool } from '../utils/tinlake/useTinlakePools' -import { usePoolMetadata } from '../utils/usePools' -import { Eththumbnail } from './EthThumbnail' -import { LabelValueStack } from './LabelValueStack' -import { Tooltips } from './Tooltips' - -type PoolCardProps = { - // Not passing a pool shows a placeholder card - pool?: Pool | TinlakePool -} - -const Anchor = styled(Grid)` - place-items: center; - color: ${({ theme }) => theme.colors.textInteractive}; - - &:active { - color: ${({ theme }) => theme.colors.textInteractive}; - } - - &:focus-visible { - outline: ${({ theme }) => `2px solid ${theme.colors.textInteractive}`}; - } - - svg { - transition: transform 0.15s; - } - - &:hover svg { - transform: translateX(5px); - } - - &::after { - content: ''; - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - } -` - -export function PoolCard({ pool }: PoolCardProps) { - const cent = useCentrifuge() - const basePath = useRouteMatch(['/pools', '/issuer'])?.path || '' - const { data: metadata } = usePoolMetadata(pool) - const mostSeniorTranche = pool?.tranches?.slice(1).at(-1) - - return ( - <Card role="article" variant="interactive"> - <Grid as="header" gridTemplateColumns="40px 1fr" alignItems="start" gap={1} p={2}> - <Eththumbnail show={pool?.id.startsWith('0x')}> - {metadata?.pool?.icon?.uri ? ( - <img src={cent.metadata.parseMetadataUrl(metadata?.pool?.icon?.uri)} alt="" height="40" width="40" /> - ) : ( - <Thumbnail type="pool" label="LP" size="large" /> - )} - </Eththumbnail> - - <Grid position="relative" gridTemplateColumns="1fr 30px" alignItems="center" gap={1}> - <Box> - <TextWithPlaceholder as="h2" variant="heading3" color="textInteractive" isLoading={!metadata}> - {metadata?.pool?.name} - </TextWithPlaceholder> - - <TextWithPlaceholder as="span" variant="body2" isLoading={!metadata}> - {metadata?.pool?.asset.class} - </TextWithPlaceholder> - </Box> - - {pool && ( - <Anchor - forwardedAs={Link} - gridTemplateColumns="1fr" - width={30} - height={30} - borderRadius="tooltip" - to={`${basePath}/${pool.id}`} - aria-label="Go to pool details" - > - <IconChevronRight /> - </Anchor> - )} - </Grid> - </Grid> - - <Box as="hr" height="1px" backgroundColor="borderSecondary" border="none" /> - - <Shelf as="dl" gap="6" p={2} justifyContent="flex-start"> - <LabelValueStack - label={ - pool ? <Tooltips type="valueLocked" variant="secondary" props={{ poolId: pool.id }} /> : 'Value locked' - } - value={ - pool ? formatBalance(getPoolValueLocked(pool), pool.currency.symbol) : <TextWithPlaceholder isLoading /> - } - renderAs={{ label: 'dt', value: 'dd' }} - /> - <LabelValueStack - label="Capacity" - value={ - pool ? ( - formatBalance(pool.tranches.at(-1)!.capacity, pool.currency.symbol) - ) : ( - <TextWithPlaceholder isLoading /> - ) - } - renderAs={{ label: 'dt', value: 'dd' }} - /> - {mostSeniorTranche && mostSeniorTranche.interestRatePerSec && ( - <LabelValueStack - label="Senior APR" - value={formatPercentage(mostSeniorTranche.interestRatePerSec.toAprPercent())} - renderAs={{ label: 'dt', value: 'dd' }} - /> - )} - </Shelf> - </Card> - ) -} diff --git a/centrifuge-app/src/components/PoolCard/PoolStatus.tsx b/centrifuge-app/src/components/PoolCard/PoolStatus.tsx new file mode 100644 index 0000000000..60acdb11ac --- /dev/null +++ b/centrifuge-app/src/components/PoolCard/PoolStatus.tsx @@ -0,0 +1,14 @@ +import { StatusChip, StatusChipProps } from '@centrifuge/fabric' +import * as React from 'react' + +export type PoolStatusKey = 'Maker Pool' | 'Open for investments' | 'Closed' + +const statusColor: { [key in PoolStatusKey]: StatusChipProps['status'] } = { + 'Maker Pool': 'ok', + 'Open for investments': 'info', + Closed: 'default', +} + +export function PoolStatus({ status }: { status?: PoolStatusKey }) { + return status ? <StatusChip status={statusColor[status] ?? 'default'}>{status}</StatusChip> : null +} diff --git a/centrifuge-app/src/components/PoolCard/index.tsx b/centrifuge-app/src/components/PoolCard/index.tsx new file mode 100644 index 0000000000..9f984f3e47 --- /dev/null +++ b/centrifuge-app/src/components/PoolCard/index.tsx @@ -0,0 +1,95 @@ +import { Rate } from '@centrifuge/centrifuge-js' +import { Box, Grid, TextWithPlaceholder, Thumbnail } from '@centrifuge/fabric' +import Decimal from 'decimal.js-light' +import * as React from 'react' +import { useRouteMatch } from 'react-router' +import { useTheme } from 'styled-components' +import { formatBalance, formatPercentage } from '../../utils/formatting' +import { Eththumbnail } from '../EthThumbnail' +import { PoolStatus, PoolStatusKey } from './PoolStatus' +import { Anchor, Ellipsis, Root } from './styles' + +const columns_base = 'minmax(150px, 2fr) minmax(100px, 1fr) 140px 70px 150px' +const columns_extended = 'minmax(200px, 2fr) minmax(100px, 1fr) 140px 100px 150px' +export const COLUMNS = [columns_base, columns_base, columns_base, columns_extended] +export const COLUMN_GAPS = [3, 3, 6, 8] + +export type PoolCardProps = { + poolId?: string + name?: string + assetClass?: string + valueLocked?: Decimal + currencySymbol?: string + apr?: Rate | null | undefined + status?: PoolStatusKey + iconUri?: string + isLoading?: boolean +} + +export function PoolCard({ + poolId, + name, + assetClass, + valueLocked, + currencySymbol, + apr, + status, + iconUri, + isLoading, +}: PoolCardProps) { + const basePath = useRouteMatch(['/pools', '/issuer'])?.path || '' + const { sizes } = useTheme() + + return ( + <Root as="article"> + <Grid gridTemplateColumns={COLUMNS} gap={COLUMN_GAPS} p={2} alignItems="center"> + <Grid as="header" gridTemplateColumns={`${sizes.iconMedium}px 1fr`} alignItems="center" gap={2}> + <Eththumbnail show={poolId?.startsWith('0x')}> + {iconUri ? ( + <Box as="img" src={iconUri} alt="" height="iconMedium" width="iconMedium" /> + ) : ( + <Thumbnail type="pool" label="LP" size="small" /> + )} + </Eththumbnail> + + <TextWithPlaceholder as="h2" variant="body2" color="textPrimary" isLoading={isLoading}> + <Ellipsis>{name}</Ellipsis> + </TextWithPlaceholder> + </Grid> + + <TextWithPlaceholder as="span" variant="body2" color="textSecondary" isLoading={isLoading}> + <Ellipsis>{assetClass}</Ellipsis> + </TextWithPlaceholder> + + <TextWithPlaceholder as="span" variant="body1" color="textPrimary" textAlign="right" isLoading={isLoading}> + <Ellipsis>{valueLocked ? formatBalance(valueLocked, currencySymbol) : '-'}</Ellipsis> + </TextWithPlaceholder> + + <TextWithPlaceholder + as="span" + variant="body1" + color="textPrimary" + fontWeight={500} + textAlign="right" + isLoading={isLoading} + maxLines={1} + > + <Ellipsis> + {apr + ? formatPercentage(apr.toAprPercent(), true, { + minimumFractionDigits: 1, + maximumFractionDigits: 1, + }) + : '—'} + </Ellipsis> + </TextWithPlaceholder> + + <Box> + <PoolStatus status={status} /> + </Box> + </Grid> + + <Anchor to={`${basePath}/${poolId}`} aria-label="Go to pool details" /> + </Root> + ) +} diff --git a/centrifuge-app/src/components/PoolCard/styles.tsx b/centrifuge-app/src/components/PoolCard/styles.tsx new file mode 100644 index 0000000000..a8f9add34b --- /dev/null +++ b/centrifuge-app/src/components/PoolCard/styles.tsx @@ -0,0 +1,36 @@ +import { Box } from '@centrifuge/fabric' +import { Link } from 'react-router-dom' +import styled from 'styled-components' + +export const Root = styled(Box)` + position: relative; + border-radius: ${({ theme }) => theme.radii.card}px; + border: ${({ theme }) => `1px solid ${theme.colors.borderSecondary}`}; + box-shadow: ${({ theme }) => `0px 1px 0px ${theme.colors.borderPrimary}`}; + transition: box-shadow 0.15s linear; + + &:hover { + box-shadow: ${({ theme }) => theme.shadows.cardInteractive}; + } +` + +export const Anchor = styled(Link)` + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border-radius: ${({ theme }) => theme.radii.card}px; + background-color: transparent; + outline-offset: -2px; + + &:focus-visible { + outline: ${({ theme }) => `2px solid ${theme.colors.textInteractive}`}; + } +` + +export const Ellipsis = styled.span` + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +` diff --git a/centrifuge-app/src/components/PoolFilter/FilterMenu.tsx b/centrifuge-app/src/components/PoolFilter/FilterMenu.tsx new file mode 100644 index 0000000000..feadc2fc9a --- /dev/null +++ b/centrifuge-app/src/components/PoolFilter/FilterMenu.tsx @@ -0,0 +1,119 @@ +import { Box, Checkbox, Divider, IconFilter, Menu, Popover, Stack, Tooltip } from '@centrifuge/fabric' +import * as React from 'react' +import { useHistory, useLocation } from 'react-router-dom' +import { FilterButton, QuickAction } from './styles' +import { SearchKeys } from './types' +import { toKebabCase } from './utils' + +export type FilterMenuProps = { + label: string + options: string[] + searchKey: SearchKeys['ASSET_CLASS' | 'POOL_STATUS'] + tooltip: string +} + +export function FilterMenu({ label, options, searchKey, tooltip }: FilterMenuProps) { + const history = useHistory() + const { pathname, search } = useLocation() + + const form = React.useRef<HTMLFormElement>(null) + + const restSearchParams = React.useMemo(() => { + const searchParams = new URLSearchParams(search) + searchParams.delete(searchKey) + return searchParams + }, [search]) + + const selectedOptions = React.useMemo(() => { + const searchParams = new URLSearchParams(search) + return searchParams.getAll(searchKey) + }, [search]) + + function handleChange() { + const formData = new FormData(form.current ?? undefined) + const entries = formData.getAll(searchKey) as string[] + const searchParams = new URLSearchParams(entries.map((entry) => [searchKey, entry])) + + history.push({ + pathname, + search: `?${searchParams}${restSearchParams.size > 0 ? `&${restSearchParams}` : ''}`, + }) + } + + function deselectAll() { + history.push({ + pathname, + search: restSearchParams.size > 0 ? `?${restSearchParams}` : '', + }) + } + + function selectAll() { + const searchParams = new URLSearchParams(options.map((option) => [searchKey, toKebabCase(option)])) + + history.push({ + pathname, + search: `?${searchParams}${restSearchParams.size > 0 ? `&${restSearchParams}` : ''}`, + }) + } + + return ( + <Box position="relative"> + <Popover + placement="bottom left" + renderTrigger={(props, ref, state) => { + return ( + <Box ref={ref}> + <Tooltip body={tooltip} {...props} style={{ display: 'block' }}> + <FilterButton forwardedAs="span" variant="body3"> + {label} + <IconFilter color={selectedOptions.length ? 'textSelected' : 'textSecondary'} size="1em" /> + </FilterButton> + </Tooltip> + </Box> + ) + }} + renderContent={(props, ref) => ( + <Box {...props} ref={ref}> + <Menu width={300}> + <Stack as="form" ref={form} p={[2, 3]} gap={2}> + <Stack as="fieldset" borderWidth={0} gap={2}> + <Box as="legend" className="visually-hidden"> + Filter {label} by: + </Box> + {options.map((option, index) => { + const value = toKebabCase(option) + const checked = selectedOptions.includes(value) + + return ( + <Checkbox + key={`${value}${index}`} + name={searchKey} + value={value} + onChange={handleChange} + checked={checked} + label={option} + extendedClickArea + /> + ) + })} + </Stack> + + <Divider borderColor="textPrimary" /> + + {selectedOptions.length === options.length ? ( + <QuickAction variant="body1" forwardedAs="button" type="button" onClick={() => deselectAll()}> + Deselect all + </QuickAction> + ) : ( + <QuickAction variant="body1" forwardedAs="button" type="button" onClick={() => selectAll()}> + Select all + </QuickAction> + )} + </Stack> + </Menu> + </Box> + )} + /> + </Box> + ) +} diff --git a/centrifuge-app/src/components/PoolFilter/SortButton.tsx b/centrifuge-app/src/components/PoolFilter/SortButton.tsx new file mode 100644 index 0000000000..4354aee38a --- /dev/null +++ b/centrifuge-app/src/components/PoolFilter/SortButton.tsx @@ -0,0 +1,108 @@ +import { IconChevronDown, IconChevronUp, Stack, Tooltip } from '@centrifuge/fabric' +import * as React from 'react' +import { useHistory, useLocation } from 'react-router-dom' +import { SEARCH_KEYS } from './config' +import { FilterButton } from './styles' +import { SortBy } from './types' + +export type SortButtonProps = { + label: string + searchKey: SortBy + tooltip?: string +} + +type Sorting = { + isActive: boolean + direction: string | null +} + +export function SortButton({ label, searchKey, tooltip }: SortButtonProps) { + const history = useHistory() + const { pathname, search } = useLocation() + + const sorting: Sorting = React.useMemo(() => { + const searchParams = new URLSearchParams(search) + + return { + isActive: searchParams.get(SEARCH_KEYS.SORT_BY) === searchKey, + direction: searchParams.get(SEARCH_KEYS.SORT), + } + }, [search]) + + function handleClick() { + const restSearchParams = new URLSearchParams(search) + restSearchParams.delete(SEARCH_KEYS.SORT_BY) + restSearchParams.delete(SEARCH_KEYS.SORT) + + const searchParams = new URLSearchParams({ + [SEARCH_KEYS.SORT_BY]: searchKey, + [SEARCH_KEYS.SORT]: sorting.direction === 'asc' ? 'desc' : 'asc', + }) + + history.push({ + pathname, + search: `?${searchParams}${restSearchParams.size > 0 ? `&${restSearchParams}` : ''}`, + }) + } + + if (tooltip) { + return ( + <Tooltip + body={tooltip} + onClick={handleClick} + aria-label={ + !sorting.isActive + ? `Sort ${label}` + : sorting.direction === 'asc' + ? `Sort ${label} descending` + : `Sort ${label} ascending` + } + aria-live + style={{ justifySelf: 'end' }} + > + <FilterButton forwardedAs="span" variant="body3"> + {label} + + <Inner sorting={sorting} /> + </FilterButton> + </Tooltip> + ) + } + + return ( + <FilterButton + forwardedAs="button" + variant="body3" + onClick={handleClick} + aria-label={ + !sorting.isActive + ? `Sort ${label}` + : sorting.direction === 'asc' + ? `Sort ${label} descending` + : `Sort ${label} ascending` + } + aria-live + style={{ justifySelf: 'end' }} + > + {label} + + <Inner sorting={sorting} /> + </FilterButton> + ) +} + +function Inner({ sorting }: { sorting: Sorting }) { + return ( + <Stack as="span" width="1em"> + <IconChevronUp + size="1em" + color={sorting.isActive && sorting.direction === 'asc' ? 'textSelected' : 'textSecondary'} + /> + <IconChevronDown + size="1em" + color={sorting.isActive && sorting.direction === 'desc' ? 'textSelected' : 'textSecondary'} + style={{ marginTop: '-.4em' }} + /> + </Stack> + ) +} diff --git a/centrifuge-app/src/components/PoolFilter/config.ts b/centrifuge-app/src/components/PoolFilter/config.ts new file mode 100644 index 0000000000..cf607b7f72 --- /dev/null +++ b/centrifuge-app/src/components/PoolFilter/config.ts @@ -0,0 +1,30 @@ +import { FilterMenuProps } from './FilterMenu' +import { SortButtonProps } from './SortButton' + +export const SEARCH_KEYS = { + SORT_BY: 'sort-by', + SORT: 'sort', + ASSET_CLASS: 'asset-class', + POOL_STATUS: 'pool-status', + VALUE_LOCKED: 'value-locked', + APR: 'apr', +} as const + +export const poolFilterConfig = { + assetClass: { + label: 'Asset class', + searchKey: SEARCH_KEYS.ASSET_CLASS, + } as FilterMenuProps, + poolStatus: { + label: 'Pool status', + searchKey: SEARCH_KEYS.POOL_STATUS, + } as FilterMenuProps, + valueLocked: { + label: 'Value locked', + searchKey: SEARCH_KEYS.VALUE_LOCKED, + } as SortButtonProps, + apr: { + label: 'APR', + searchKey: SEARCH_KEYS.APR, + } as SortButtonProps, +} diff --git a/centrifuge-app/src/components/PoolFilter/index.tsx b/centrifuge-app/src/components/PoolFilter/index.tsx new file mode 100644 index 0000000000..87ead21872 --- /dev/null +++ b/centrifuge-app/src/components/PoolFilter/index.tsx @@ -0,0 +1,50 @@ +import { Grid, Text } from '@centrifuge/fabric' +import * as React from 'react' +import { COLUMNS, COLUMN_GAPS, PoolCardProps } from '../PoolCard' +import { poolFilterConfig } from './config' +import { FilterMenu } from './FilterMenu' +import { SortButton } from './SortButton' + +type PoolFilterProps = { + pools?: PoolCardProps[] +} + +export function PoolFilter({ pools }: PoolFilterProps) { + const [assetClasses, poolStatuses] = React.useMemo(() => { + if (!pools) { + return [[], []] + } + + return [ + [...new Set(pools.map(({ assetClass }) => assetClass))], + [...new Set(pools.map(({ status }) => status))], + ] as [string[], string[]] + }, [pools]) + + return ( + <Grid gridTemplateColumns={COLUMNS} gap={COLUMN_GAPS} alignItems="start" minWidth={970} px={2}> + <Text as="span" variant="body3"> + Pool name + </Text> + + <FilterMenu + {...poolFilterConfig.assetClass} + options={assetClasses} + tooltip="Different asset classes to group real-world assets with similar characteristics." + /> + + <SortButton + {...poolFilterConfig.valueLocked} + tooltip="Value locked represents the current total value of pool tokens." + /> + + <SortButton {...poolFilterConfig.apr} /> + + <FilterMenu + {...poolFilterConfig.poolStatus} + options={poolStatuses} + tooltip="Pool status displays the type of pool, if open or closed for investment and if senior tranche is funded by Maker." + /> + </Grid> + ) +} diff --git a/centrifuge-app/src/components/PoolFilter/styles.ts b/centrifuge-app/src/components/PoolFilter/styles.ts new file mode 100644 index 0000000000..f94d6c7d85 --- /dev/null +++ b/centrifuge-app/src/components/PoolFilter/styles.ts @@ -0,0 +1,26 @@ +import { Text } from '@centrifuge/fabric' +import styled, { css } from 'styled-components' + +const sharedStyles = css` + appearance: none; + border: none; + cursor: pointer; + background-color: transparent; + border-radius: ${({ theme }) => theme.radii.tooltip}px; + + &:focus-visible { + outline: ${({ theme }) => `2px solid ${theme.colors.textSelected}`}; + outline-offset: 4px; + } +` + +export const FilterButton = styled(Text)` + display: flex; + align-items: center; + gap: 0.3em; + + ${sharedStyles} +` +export const QuickAction = styled(Text)` + ${sharedStyles} +` diff --git a/centrifuge-app/src/components/PoolFilter/types.ts b/centrifuge-app/src/components/PoolFilter/types.ts new file mode 100644 index 0000000000..07db5d419f --- /dev/null +++ b/centrifuge-app/src/components/PoolFilter/types.ts @@ -0,0 +1,6 @@ +import { SEARCH_KEYS } from './config' + +export type SortDirection = 'asc' | 'desc' +export type SortBy = 'value-locked' | 'apr' +export type FilterBy = 'asset-class' | 'pool-status' +export type SearchKeys = typeof SEARCH_KEYS diff --git a/centrifuge-app/src/components/PoolFilter/utils.ts b/centrifuge-app/src/components/PoolFilter/utils.ts new file mode 100644 index 0000000000..c867fbfd90 --- /dev/null +++ b/centrifuge-app/src/components/PoolFilter/utils.ts @@ -0,0 +1,54 @@ +import { PoolCardProps } from '../PoolCard' +import { poolFilterConfig, SEARCH_KEYS } from './config' +import { SortBy, SortDirection } from './types' + +export function toKebabCase(string: string) { + return string.toLowerCase().split(' ').join('-') +} + +export function filterPools(pools: PoolCardProps[], searchParams: URLSearchParams) { + let filtered = pools + const assetClasses = new Set(searchParams.getAll(poolFilterConfig.assetClass.searchKey)) + const poolStatuses = new Set(searchParams.getAll(poolFilterConfig.poolStatus.searchKey)) + const sortDirection = searchParams.get('sort') as SortDirection + const sortBy = searchParams.get('sort-by') as SortBy + + filtered = filtered.filter( + (pool) => pool.status && (poolStatuses.size ? poolStatuses.has(toKebabCase(pool.status)) : pool.status !== 'Closed') + ) + console.log('filtered', filtered) + + if (assetClasses.size) { + filtered = filtered.filter((pool) => pool.assetClass && assetClasses.has(toKebabCase(pool.assetClass))) + } + + if (sortDirection && sortBy) { + filtered = sortData(filtered, sortBy, sortDirection) + } + + return filtered +} + +const sortMap = { + [SEARCH_KEYS.VALUE_LOCKED]: (item: PoolCardProps) => item.valueLocked?.toNumber() ?? 0, + [SEARCH_KEYS.APR]: (item: PoolCardProps) => (item.apr ? item.apr.toDecimal().toNumber() : 0), +} + +function sortData(data: PoolCardProps[], sortBy: SortBy, sortDirection: SortDirection) { + if (sortMap.hasOwnProperty(sortBy)) { + const sortFunction = sortMap[sortBy] + + data.sort((a, b) => { + const valueA = sortFunction(a) + const valueB = sortFunction(b) + + return sortDirection === 'asc' ? compareNumeric(valueA, valueB) : compareNumeric(valueB, valueA) + }) + } + + return data +} + +function compareNumeric(a: number, b: number) { + return a - b +} diff --git a/centrifuge-app/src/components/PoolList.tsx b/centrifuge-app/src/components/PoolList.tsx index 97d105ca19..017b5017b1 100644 --- a/centrifuge-app/src/components/PoolList.tsx +++ b/centrifuge-app/src/components/PoolList.tsx @@ -1,21 +1,28 @@ -import { Pool } from '@centrifuge/centrifuge-js' -import { Grid } from '@centrifuge/fabric' +import { Box, Stack } from '@centrifuge/fabric' import * as React from 'react' -import { TinlakePool } from '../utils/tinlake/useTinlakePools' -import { PoolCard } from './PoolCard' +import { PoolCard, PoolCardProps } from './PoolCard' -type Props = { - pools: (Pool | TinlakePool)[] +type PoolListProps = { + pools: PoolCardProps[] isLoading?: boolean } -export const PoolList: React.FC<Props> = ({ pools, isLoading }) => { +export function PoolList({ pools, isLoading }: PoolListProps) { return ( - <Grid columns={[1, 2]} gap={[3, 2]} m={[2, 3]} equalColumns> - {pools.map((pool) => { - return <PoolCard key={pool.id} pool={pool} /> - })} - {isLoading && <PoolCard />} - </Grid> + <Stack as="ul" role="list" gap={1} minWidth={970} py={1}> + {isLoading + ? Array(6) + .fill(true) + .map((_, index) => ( + <Box as="li" key={index}> + <PoolCard isLoading={true} /> + </Box> + )) + : pools.map((pool) => ( + <Box as="li" key={pool.poolId}> + <PoolCard {...pool} /> + </Box> + ))} + </Stack> ) } diff --git a/centrifuge-app/src/components/PoolsTokensShared.tsx b/centrifuge-app/src/components/PoolsTokensShared.tsx new file mode 100644 index 0000000000..896eb090ff --- /dev/null +++ b/centrifuge-app/src/components/PoolsTokensShared.tsx @@ -0,0 +1,43 @@ +import { Grid, Stack, Text } from '@centrifuge/fabric' +import * as React from 'react' +import { config } from '../config' +import { CardTotalValueLocked } from './CardTotalValueLocked' +import { BaseSection } from './LayoutBase/BaseSection' +import { LoadBoundary } from './LoadBoundary' +import { MenuSwitch } from './MenuSwitch' +import { PortfolioCta } from './PortfolioCta' + +type PoolsTokensSharedProps = { + title: string + children: React.ReactNode +} + +export function PoolsTokensShared({ title, children }: PoolsTokensSharedProps) { + return ( + <BaseSection pt={3} pb={4}> + <Stack gap={4}> + <Stack> + <Text as="h1" variant="heading1"> + {title} + </Text> + <Text as="p" variant="heading6"> + {`Pools and tokens ${config.network === 'centrifuge' ? 'of real-world assets' : ''}`} + </Text> + </Stack> + + <Grid gridTemplateColumns={['1fr', '1fr', '1fr', 'repeat(2, minmax(0, 1fr))']} gap={[2, 2, 2, 4]}> + <LoadBoundary> + <CardTotalValueLocked /> + </LoadBoundary> + <PortfolioCta /> + </Grid> + + <Stack alignItems="end"> + <MenuSwitch /> + </Stack> + + {children} + </Stack> + </BaseSection> + ) +} diff --git a/centrifuge-app/src/components/PortfolioCta/Cubes.tsx b/centrifuge-app/src/components/PortfolioCta/Cubes.tsx new file mode 100644 index 0000000000..eb560b78f8 --- /dev/null +++ b/centrifuge-app/src/components/PortfolioCta/Cubes.tsx @@ -0,0 +1,70 @@ +import * as React from 'react' + +export function Cubes() { + return ( + <svg + viewBox="0 0 137 154" + fill="none" + xmlns="http://www.w3.org/2000/svg" + style={{ + position: 'absolute', + top: 0, + right: 0, + width: 'auto', + height: '100%', + transform: 'translate(20%, -20%)', + }} + > + <g opacity="0.1"> + <path + d="M90.7471 87.5577L46.1669 65.9414L1.59082 87.5577V130.794L46.1669 152.414L90.7471 130.794V87.5577Z" + fill="white" + stroke="black" + strokeWidth="1.42637" + strokeMiterlimit="10" + /> + <path + d="M46.0785 109.006L1.59082 87.5594L46.1669 109.179L90.7472 87.5594L46.1669 65.943L90.4776 44.2383L135.054 65.8546V109.091L90.4776 130.711V87.4745L45.9016 65.8546" + fill="white" + /> + <path + d="M46.0785 109.006L1.59082 87.5594L46.1669 109.179L90.7472 87.5594L46.1669 65.943L90.4776 44.2383L135.054 65.8546V109.091L90.4776 130.711V87.4745L45.9016 65.8546" + stroke="black" + strokeWidth="1.42637" + strokeMiterlimit="10" + /> + <path d="M135.012 109.178V65.9414" stroke="black" strokeWidth="1.42637" strokeMiterlimit="10" /> + <path + d="M135.234 65.7679L90.6538 87.3842L90.6117 87.4727H90.4769L135.053 65.8528L90.4769 44.2365L90.4307 44.3214L135.011 22.7051" + fill="white" + /> + <path + d="M135.234 65.7679L90.6538 87.3842L90.6117 87.4727H90.4769L135.053 65.8528L90.4769 44.2365L90.4307 44.3214L135.011 22.7051" + stroke="black" + strokeWidth="1.42637" + strokeMiterlimit="10" + /> + <path d="M46.1689 109.305V152.109" stroke="black" strokeWidth="1.42637" strokeMiterlimit="10" /> + <path + d="M135.17 22.7048L90.5896 1.08496L46.0137 22.7048V65.941L90.5896 87.5573L135.17 65.941V22.7048Z" + fill="white" + stroke="black" + strokeWidth="1.42637" + strokeMiterlimit="10" + /> + <path d="M134.9 65.8525V22.6163L90.3242 1" fill="white" /> + <path d="M134.9 65.8525V22.6163L90.3242 1" stroke="black" strokeWidth="1.42637" strokeMiterlimit="10" /> + <path d="M90.5012 44.1478L46.0137 22.7048L90.5896 44.3211L135.17 22.7048L90.5896 1.08496" fill="white" /> + <path + d="M90.5012 44.1478L46.0137 22.7048L90.5896 44.3211L135.17 22.7048L90.5896 1.08496" + stroke="black" + strokeWidth="1.42637" + strokeMiterlimit="10" + /> + <path d="M135.081 22.5312L135.035 22.6123H134.899" fill="white" /> + <path d="M135.081 22.5312L135.035 22.6123H134.899" stroke="black" strokeWidth="1.42637" strokeMiterlimit="10" /> + <path d="M90.5889 44.4521V87.2568" stroke="black" strokeWidth="1.42637" strokeMiterlimit="10" /> + </g> + </svg> + ) +} diff --git a/centrifuge-app/src/components/PortfolioCta/index.tsx b/centrifuge-app/src/components/PortfolioCta/index.tsx new file mode 100644 index 0000000000..c520300ecc --- /dev/null +++ b/centrifuge-app/src/components/PortfolioCta/index.tsx @@ -0,0 +1,106 @@ +import { ActiveLoan } from '@centrifuge/centrifuge-js' +import { useBalances, useCentrifugeConsts, useWallet } from '@centrifuge/centrifuge-react' +import { Box, Button, Shelf, Stack, Text } from '@centrifuge/fabric' +import * as React from 'react' +import { useTheme } from 'styled-components' +import { config } from '../../config' +import { Dec } from '../../utils/Decimal' +import { formatBalance, formatBalanceAbbreviated } from '../../utils/formatting' +import { useAddress } from '../../utils/useAddress' +import { useListedPools } from '../../utils/useListedPools' +import { useLoansAcrossPools } from '../../utils/useLoans' +import { useComputeLiquidityRewards } from '../LiquidityRewards/hooks' +import { Cubes } from './Cubes' + +export function PortfolioCta() { + const { showNetworks } = useWallet() + const { colors } = useTheme() + const address = useAddress() + const balances = useBalances(address) + const consts = useCentrifugeConsts() + const [, listedTokens] = useListedPools() + + const stakes = balances?.tranches.map(({ poolId, trancheId }) => ({ poolId, trancheId })) ?? [] + const rewards = useComputeLiquidityRewards(address, stakes) + + const currencies = balances?.currencies.map(({ balance }) => balance.toDecimal()) ?? [] + const tranches = + balances?.tranches.map(({ balance, trancheId }) => { + const token = listedTokens.find(({ id }) => id === trancheId) + return balance.toDecimal().mul(token?.tokenPrice?.toDecimal() ?? Dec(0)) + }) ?? [] + const investedValue = [...currencies, ...tranches].reduce((a, b) => a.add(b), Dec(0)) + + const pools = balances?.tranches.map(({ poolId }) => poolId) ?? [] + const loans = useLoansAcrossPools(pools) ?? [] + const activeLoans = loans?.filter(({ status }) => status === 'Active') as ActiveLoan[] + const accruedInterest = activeLoans + .map(({ outstandingInterest }) => outstandingInterest.toDecimal()) + .reduce((a, b) => a.add(b), Dec(0)) + + const terms = [ + { + title: 'Portfolio value', + value: investedValue.gte(1000) + ? formatBalanceAbbreviated(investedValue, config.baseCurrency) + : formatBalance(investedValue, config.baseCurrency), + }, + { + title: 'Accrued interest', + value: formatBalance(accruedInterest, config.baseCurrency), + }, + { + title: 'CFG rewards', + value: formatBalance(rewards, consts.chainSymbol, 2), + }, + ] + + return ( + <Box + as="article" + position="relative" + p={3} + pb={5} + overflow="hidden" + borderRadius="card" + borderStyle="solid" + borderWidth={1} + borderColor="borderSecondary" + style={{ + boxShadow: `0px 3px 2px -2px ${colors.borderPrimary}`, + }} + > + {!address && <Cubes />} + + <Stack gap={2} alignItems="start"> + {address ? ( + <> + <Text as="h2" variant="heading2"> + Your portfolio + </Text> + + <Shelf as="dl" gap={6} flexWrap="wrap" rowGap={2}> + {terms.map(({ title, value }, index) => ( + <Stack key={`${title}${index}`} gap="4px"> + <Text as="dt" variant="body3" whiteSpace="nowrap"> + {title} + </Text> + <Text as="dd" variant="body2" whiteSpace="nowrap"> + {value} + </Text> + </Stack> + ))} + </Shelf> + </> + ) : ( + <> + <Text as="h2" variant="body1" style={{ maxWidth: '35ch' }}> + Pools on Centrifuge let investors earn yield from real-world assets. + </Text> + <Button onClick={() => showNetworks()}>Get started</Button> + </> + )} + </Stack> + </Box> + ) +} diff --git a/centrifuge-app/src/components/SideDrawer.tsx b/centrifuge-app/src/components/SideDrawer.tsx new file mode 100644 index 0000000000..cc1445f640 --- /dev/null +++ b/centrifuge-app/src/components/SideDrawer.tsx @@ -0,0 +1,63 @@ +import { Box } from '@centrifuge/fabric' +import { useDialog } from '@react-aria/dialog' +import { FocusScope } from '@react-aria/focus' +import { OverlayContainer, useModal, useOverlay, usePreventScroll } from '@react-aria/overlays' +import * as React from 'react' +import { useTheme } from 'styled-components' + +export type SideDrawerProps = { + isOpen: boolean + onClose: () => void + children?: React.ReactNode +} + +export function SideDrawer(props: SideDrawerProps) { + return props.isOpen ? ( + <OverlayContainer> + <SideDrawerInner {...props} /> + </OverlayContainer> + ) : null +} + +export function SideDrawerInner({ children, isOpen, onClose }: SideDrawerProps) { + const theme = useTheme() + const ref = React.useRef<HTMLDivElement>(null) + const underlayRef = React.useRef<HTMLDivElement>(null) + const { overlayProps, underlayProps } = useOverlay( + { isOpen, onClose, isDismissable: true, shouldCloseOnInteractOutside: (target) => target === underlayRef.current }, + ref + ) + const { space } = useTheme() + + usePreventScroll() + const { modalProps } = useModal() + const { dialogProps } = useDialog({}, ref) + + return ( + <Box position="fixed" zIndex="overlay" top={0} left={0} bottom={0} right={0} {...underlayProps} ref={underlayRef}> + <FocusScope contain restoreFocus autoFocus> + <Box + position="relative" + height="100%" + overflowY="auto" + ml="auto" + width={[`calc(100% - ${space[5]}px)`, 440]} + p={3} + borderStyle="solid" + borderColor="borderPrimary" + borderLeftWidth={1} + backgroundColor="backgroundPrimary" + style={{ + boxShadow: `0px 0px 20px ${theme.colors.borderSecondary}`, + }} + {...overlayProps} + {...dialogProps} + {...modalProps} + ref={ref} + > + {children} + </Box> + </FocusScope> + </Box> + ) +} diff --git a/centrifuge-app/src/components/Tooltips.tsx b/centrifuge-app/src/components/Tooltips.tsx index 3d5b519353..28940a0b6e 100644 --- a/centrifuge-app/src/components/Tooltips.tsx +++ b/centrifuge-app/src/components/Tooltips.tsx @@ -9,7 +9,7 @@ const ValueLockedTooltipBody: React.FC<{ poolId?: string }> = ({ poolId }) => { return <>Value locked represents the current total value of pool tokens in {pool?.currency.symbol}.</> } -const tooltipText = { +export const tooltipText = { assetType: { label: 'Asset type', body: 'This refers to the asset type used to finance the asset. This can e.g. be bullet loans or interest bearing loans. The asset type determines in particular the cash flow profile of the financing.', diff --git a/centrifuge-app/src/pages/Pools.tsx b/centrifuge-app/src/pages/Pools.tsx index 2b2f7493ff..81ce05b5ab 100644 --- a/centrifuge-app/src/pages/Pools.tsx +++ b/centrifuge-app/src/pages/Pools.tsx @@ -1,76 +1,113 @@ -import { Box, Shelf, Stack, Text } from '@centrifuge/fabric' +import Centrifuge, { Pool, PoolMetadata } from '@centrifuge/centrifuge-js' +import { useCentrifuge } from '@centrifuge/centrifuge-react' +import { Box, InlineFeedback, Shelf, Stack, Text } from '@centrifuge/fabric' import * as React from 'react' -import { MenuSwitch } from '../components/MenuSwitch' -import { PageHeader } from '../components/PageHeader' -import { PageSummary } from '../components/PageSummary' -import { PageWithSideBar } from '../components/PageWithSideBar' +import { useLocation } from 'react-router-dom' +import { LayoutBase } from '../components/LayoutBase' +import { PoolCardProps } from '../components/PoolCard' +import { PoolStatusKey } from '../components/PoolCard/PoolStatus' +import { PoolFilter } from '../components/PoolFilter' +import { filterPools } from '../components/PoolFilter/utils' import { PoolList } from '../components/PoolList' -import { PoolsSwitch } from '../components/PoolsSwitch' -import { Tooltips } from '../components/Tooltips' -import { config } from '../config' -import { Dec } from '../utils/Decimal' -import { formatBalance } from '../utils/formatting' +import { PoolsTokensShared } from '../components/PoolsTokensShared' +import { getPoolValueLocked } from '../utils/getPoolValueLocked' +import { TinlakePool } from '../utils/tinlake/useTinlakePools' import { useListedPools } from '../utils/useListedPools' +import { useMetadataMulti } from '../utils/useMetadata' -export const PoolsPage: React.FC = () => { +type PoolMetaDataPartial = Partial<PoolMetadata> | undefined +type MetaDataById = Record<string, PoolMetaDataPartial> + +export function PoolsPage() { return ( - <PageWithSideBar sidebar> - <Pools /> - </PageWithSideBar> + <LayoutBase> + <PoolsTokensShared title="Pools"> + <Pools /> + </PoolsTokensShared> + </LayoutBase> ) } -const Pools: React.FC = () => { - const [filtered, setFiltered] = React.useState(true) +function Pools() { + const cent = useCentrifuge() + const { search } = useLocation() const [listedPools, listedTokens, metadataIsLoading] = useListedPools() - const totalValueLocked = React.useMemo(() => { + console.log('listedPools', listedPools) + + const centPools = listedPools.filter(({ id }) => !id.startsWith('0x')) as Pool[] + const centPoolsMetaData: PoolMetaDataPartial[] = useMetadataMulti<PoolMetadata>( + centPools?.map((p) => p.metadata) ?? [] + ).map((q) => q.data) + const centPoolsMetaDataById = getMetasById(centPools, centPoolsMetaData) + + const pools = !!listedPools?.length ? poolsToPoolCardProps(listedPools, centPoolsMetaDataById, cent) : [] + console.log('pools', pools) + const filteredPools = !!pools?.length ? filterPools(pools, new URLSearchParams(search)) : [] + console.log('flfitleirlerpOPopolp', filteredPools) + + if (!listedPools.length) { return ( - listedTokens - ?.map((tranche) => ({ - valueLocked: tranche.totalIssuance - .toDecimal() - .mul(tranche.tokenPrice?.toDecimal() ?? Dec(0)) - .toNumber(), - })) - .reduce((prev, curr) => prev.add(curr.valueLocked), Dec(0)) ?? Dec(0) + <Shelf p={4} justifyContent="center" textAlign="center"> + <Text variant="heading2" color="textSecondary"> + There are no pools yet + </Text> + </Shelf> ) - }, [listedTokens]) - - const pageSummaryData = [ - { - label: <Tooltips type="tvl" />, - value: formatBalance(Dec(totalValueLocked || 0), config.baseCurrency), - }, - { label: 'Pools', value: listedPools?.length || 0 }, - { label: <Tooltips type="tokens" />, value: listedTokens?.length || 0 }, - ] + } return ( - <Stack gap={0} flex={1}> - <PageHeader - title="Pools" - subtitle={`Pools and tokens ${config.network === 'centrifuge' ? 'of real-world assets' : ''}`} - actions={<MenuSwitch />} - /> + <Stack gap={1}> + <Box overflow="auto"> + <PoolFilter pools={pools} /> - {listedPools?.length ? ( - <> - <PageSummary data={pageSummaryData} /> - <PoolList - pools={filtered ? listedPools.filter(({ reserve }) => reserve.max.toFloat() > 0) : listedPools} - isLoading={metadataIsLoading} - /> - <Box mx={2} mt={3} p={2} borderWidth={0} borderTopWidth={1} borderStyle="solid" borderColor="borderSecondary"> - <PoolsSwitch filtered={filtered} setFiltered={setFiltered} /> - </Box> - </> - ) : ( - <Shelf p={4} justifyContent="center" textAlign="center"> - <Text variant="heading2" color="textSecondary"> - There are no pools yet - </Text> - </Shelf> - )} + {!filteredPools.length ? ( + <Shelf px={2} mt={2} justifyContent="center"> + <Box px={2} py={1} borderRadius="input" backgroundColor="secondarySelectedBackground"> + <InlineFeedback status="info">No results found with these filters. Try different filters.</InlineFeedback> + </Box> + </Shelf> + ) : ( + <PoolList pools={filteredPools} isLoading={metadataIsLoading} /> + )} + </Box> </Stack> ) } + +function getMetasById(pools: Pool[], poolMetas: PoolMetaDataPartial[]) { + const result: MetaDataById = {} + + pools.forEach(({ id: poolId }, index) => { + result[poolId] = poolMetas[index] + }) + + return result +} + +function poolsToPoolCardProps( + pools: (Pool | TinlakePool)[], + metaDataById: MetaDataById, + cent: Centrifuge +): PoolCardProps[] { + return pools.map((pool) => { + const tinlakePool = pool.id?.startsWith('0x') && (pool as TinlakePool) + const mostSeniorTranche = pool?.tranches?.slice(1).at(-1) + const metaData = typeof pool.metadata === 'string' ? metaDataById[pool.id] : pool.metadata + + return { + poolId: pool.id, + name: metaData?.pool?.name, + assetClass: metaData?.pool?.asset.class, + valueLocked: getPoolValueLocked(pool), + currencySymbol: pool.currency.symbol, + apr: mostSeniorTranche?.interestRatePerSec, + status: + tinlakePool && tinlakePool.addresses.CLERK !== undefined && tinlakePool.tinlakeMetadata.maker?.ilk + ? 'Maker Pool' + : pool.tranches.at(-1)?.capacity.toFloat() + ? 'Open for investments' + : ('Closed' as PoolStatusKey), + iconUri: metaData?.pool?.icon?.uri ? cent.metadata.parseMetadataUrl(metaData?.pool?.icon?.uri) : undefined, + } + }) +} diff --git a/centrifuge-app/src/pages/Tokens.tsx b/centrifuge-app/src/pages/Tokens.tsx index 494e2996bd..27e9468ad9 100644 --- a/centrifuge-app/src/pages/Tokens.tsx +++ b/centrifuge-app/src/pages/Tokens.tsx @@ -1,30 +1,27 @@ -import { IconChevronRight, Shelf, Stack, Text, TextWithPlaceholder } from '@centrifuge/fabric' +import { IconChevronRight, Shelf, Text, TextWithPlaceholder } from '@centrifuge/fabric' import * as React from 'react' import { DataTable } from '../components/DataTable' -import { MenuSwitch } from '../components/MenuSwitch' -import { PageHeader } from '../components/PageHeader' -import { PageSummary } from '../components/PageSummary' -import { PageWithSideBar } from '../components/PageWithSideBar' +import { LayoutBase } from '../components/LayoutBase' +import { PoolsTokensShared } from '../components/PoolsTokensShared' import { TokenList, TokenTableData } from '../components/TokenList' -import { Tooltips } from '../components/Tooltips' -import { config } from '../config' import { Dec } from '../utils/Decimal' -import { formatBalance } from '../utils/formatting' import { useListedPools } from '../utils/useListedPools' import { usePools } from '../utils/usePools' -export const TokenOverviewPage: React.FC = () => { +export function TokenOverviewPage() { return ( - <PageWithSideBar sidebar> - <TokenOverview /> - </PageWithSideBar> + <LayoutBase> + <PoolsTokensShared title="Tokens"> + <TokenOverview /> + </PoolsTokensShared> + </LayoutBase> ) } -const TokenOverview: React.FC = () => { +function TokenOverview() { const pools = usePools() - const [listedPools, listedTokens] = useListedPools() + const [, listedTokens] = useListedPools() const tokens: TokenTableData[] | undefined = React.useMemo( () => @@ -55,103 +52,66 @@ const TokenOverview: React.FC = () => { [listedTokens] ) - const totalValueLocked = React.useMemo(() => { - return ( - listedTokens - ?.map((tranche) => ({ - valueLocked: tranche.totalIssuance - .toDecimal() - .mul(tranche.tokenPrice?.toDecimal() ?? Dec(0)) - .toNumber(), - })) - .reduce((prev, curr) => prev.add(curr.valueLocked), Dec(0)) ?? Dec(0) - ) - }, [listedTokens]) - - const pageSummaryData = [ - { - label: <Tooltips type="tvl" />, - value: formatBalance(Dec(totalValueLocked || 0), config.baseCurrency), - }, - { label: 'Pools', value: listedPools?.length || 0 }, - { label: <Tooltips type="tokens" />, value: tokens?.length || 0 }, - ] - - return ( - <Stack gap={0} flex={1} mb="6"> - <PageHeader - subtitle={`Pools and tokens${config.network === 'centrifuge' ? ' of real-world assets' : ''}`} - title="Tokens" - actions={<MenuSwitch />} - /> - {tokens?.length ? ( - <> - <PageSummary data={pageSummaryData} /> - <TokenList tokens={tokens} /> - </> - ) : pools?.length ? ( - <> - <PageSummary data={pageSummaryData} /> - <DataTable - rounded={false} - data={[{}]} - columns={[ - { - align: 'left', - header: 'Token', - cell: () => <TextWithPlaceholder isLoading />, - flex: '9', - }, - { - align: 'left', - header: 'Asset class', - cell: () => <TextWithPlaceholder isLoading />, - flex: '6', - }, - { - header: 'Yield', - cell: () => <TextWithPlaceholder isLoading width={4} />, - flex: '4', - sortKey: 'yield', - }, - { - header: 'Token Price', - cell: () => <TextWithPlaceholder isLoading width={4} />, - flex: '4', - sortKey: 'tokenPrice', - }, - { - header: 'Protection', - cell: () => <TextWithPlaceholder isLoading width={4} />, - flex: '4', - }, - { - header: 'Value locked', - cell: () => <TextWithPlaceholder isLoading width={6} />, - flex: '4', - sortKey: 'valueLocked', - }, - { - header: 'Capacity', - cell: () => <TextWithPlaceholder isLoading width={6} />, - flex: '4', - }, + return tokens?.length ? ( + <TokenList tokens={tokens} /> + ) : pools?.length ? ( + <DataTable + rounded={false} + data={[{}]} + columns={[ + { + align: 'left', + header: 'Token', + cell: () => <TextWithPlaceholder isLoading />, + flex: '9', + }, + { + align: 'left', + header: 'Asset class', + cell: () => <TextWithPlaceholder isLoading />, + flex: '6', + }, + { + header: 'Yield', + cell: () => <TextWithPlaceholder isLoading width={4} />, + flex: '4', + sortKey: 'yield', + }, + { + header: 'Token Price', + cell: () => <TextWithPlaceholder isLoading width={4} />, + flex: '4', + sortKey: 'tokenPrice', + }, + { + header: 'Protection', + cell: () => <TextWithPlaceholder isLoading width={4} />, + flex: '4', + }, + { + header: 'Value locked', + cell: () => <TextWithPlaceholder isLoading width={6} />, + flex: '4', + sortKey: 'valueLocked', + }, + { + header: 'Capacity', + cell: () => <TextWithPlaceholder isLoading width={6} />, + flex: '4', + }, - { - header: '', - cell: () => <IconChevronRight size={24} color="textPrimary" />, - flex: '0 1 52px', - }, - ]} - /> - </> - ) : ( - <Shelf p={4} justifyContent="center" textAlign="center"> - <Text variant="heading2" color="textSecondary"> - There are no tokens yet - </Text> - </Shelf> - )} - </Stack> + { + header: '', + cell: () => <IconChevronRight size={24} color="textPrimary" />, + flex: '0 1 52px', + }, + ]} + /> + ) : ( + <Shelf p={4} justifyContent="center" textAlign="center"> + <Text variant="heading2" color="textSecondary"> + There are no tokens yet + </Text> + </Shelf> ) } diff --git a/centrifuge-app/src/utils/formatting.ts b/centrifuge-app/src/utils/formatting.ts index 451ed28b09..f8221c0b3b 100644 --- a/centrifuge-app/src/utils/formatting.ts +++ b/centrifuge-app/src/utils/formatting.ts @@ -42,7 +42,11 @@ export function formatBalanceAbbreviated( return currency ? `${formattedAmount} ${currency}` : formattedAmount } -export function formatPercentage(amount: Perquintill | Decimal | number, includeSymbol = true) { +export function formatPercentage( + amount: Perquintill | Decimal | number, + includeSymbol = true, + options: Intl.NumberFormatOptions = {} +) { const formattedAmount = ( amount instanceof Perquintill ? amount.toPercent().toNumber() @@ -52,6 +56,7 @@ export function formatPercentage(amount: Perquintill | Decimal | number, include ).toLocaleString('en', { minimumFractionDigits: 2, maximumFractionDigits: 2, + ...options, }) return includeSymbol ? `${formattedAmount}%` : formattedAmount } diff --git a/centrifuge-app/src/utils/tinlake/fetchFromTinlakeSubgraph.ts b/centrifuge-app/src/utils/tinlake/fetchFromTinlakeSubgraph.ts new file mode 100644 index 0000000000..3378d4d225 --- /dev/null +++ b/centrifuge-app/src/utils/tinlake/fetchFromTinlakeSubgraph.ts @@ -0,0 +1,20 @@ +export async function fetchFromTinlakeSubgraph(query: string, variables?: unknown) { + const response = await fetch(import.meta.env.REACT_APP_TINLAKE_SUBGRAPH_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query, + variables: { ...(variables || {}) }, + }), + }) + + if (!response?.ok) { + throw new Error(`Issue fetching from Tinlake subgraph. Status: ${response?.status}`) + } else { + const { data, errors } = await response.json() + if (errors?.length) { + throw new Error(`Issue fetching from Subgraph. Errors: ${errors}`) + } + return data + } +} diff --git a/centrifuge-app/src/utils/tinlake/getTinlakeSubgraphTVL.ts b/centrifuge-app/src/utils/tinlake/getTinlakeSubgraphTVL.ts new file mode 100644 index 0000000000..5846c797a3 --- /dev/null +++ b/centrifuge-app/src/utils/tinlake/getTinlakeSubgraphTVL.ts @@ -0,0 +1,40 @@ +import { CurrencyBalance } from '@centrifuge/centrifuge-js' +import BN from 'bn.js' +import { fetchFromTinlakeSubgraph } from './fetchFromTinlakeSubgraph' + +export async function getTinlakeSubgraphTVL() { + const query = ` + query ($first: Int!, $skip: Int!) { + days(orderBy: id, orderDirection: asc, first: $first, skip: $skip) { + id + assetValue + reserve + } + } + ` + let skip = 0 + const limit = 1000 + + const data: { + id: string + assetValue: string + reserve: string + }[] = [] + + // subgraph only returns 1000 entries, fetch until no more entries are returned + while (true) { + const response: any = await fetchFromTinlakeSubgraph(query, { first: 1000, skip: skip }) + data.push(...response.days) + if (response.days.length < limit) { + break + } + skip += limit + } + + const poolsDailyData = data.map(({ id, assetValue, reserve }) => ({ + dateInMilliseconds: new Date(Number(id) * 1000).setHours(0, 0, 0, 0), + tvl: new CurrencyBalance(new BN(assetValue || '0').add(new BN(reserve || '0')), 18).toDecimal(), + })) + + return poolsDailyData +} diff --git a/centrifuge-app/src/utils/tinlake/useTinlakePools.ts b/centrifuge-app/src/utils/tinlake/useTinlakePools.ts index e0e994d3c4..86b68334e5 100644 --- a/centrifuge-app/src/utils/tinlake/useTinlakePools.ts +++ b/centrifuge-app/src/utils/tinlake/useTinlakePools.ts @@ -279,7 +279,7 @@ async function getTinlakeLoans(poolId: string) { loans: unknown[] }[] = [] - const response = await fetch('https://graph.centrifuge.io/tinlake', { + const response = await fetch(import.meta.env.REACT_APP_TINLAKE_SUBGRAPH_URL, { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/centrifuge-app/src/utils/useMetadata.ts b/centrifuge-app/src/utils/useMetadata.ts index 044fae6406..f3ec3726d1 100644 --- a/centrifuge-app/src/utils/useMetadata.ts +++ b/centrifuge-app/src/utils/useMetadata.ts @@ -21,7 +21,7 @@ type Result<T extends Schema> = { > } -async function metadataQueryFn<T extends Schema>(uri: string, cent: Centrifuge, schema?: T) { +export async function metadataQueryFn<T extends Schema>(uri: string, cent: Centrifuge, schema?: T) { try { const res = await lastValueFrom(cent.metadata.getMetadata(uri!)) diff --git a/centrifuge-app/src/utils/usePools.ts b/centrifuge-app/src/utils/usePools.ts index 145c32d53d..9e8fdf3539 100644 --- a/centrifuge-app/src/utils/usePools.ts +++ b/centrifuge-app/src/utils/usePools.ts @@ -98,6 +98,14 @@ export function useDailyTrancheStates(trancheId: string) { return result } +export function useDailyTVL() { + const [result] = useCentrifugeQuery(['daily TVL'], (cent) => cent.pools.getDailyTVL(), { + suspense: true, + }) + + return result +} + export function usePoolOrders(poolId: string) { const [result] = useCentrifugeQuery(['poolOrders', poolId], (cent) => cent.pools.getPoolOrders([poolId])) diff --git a/centrifuge-js/src/modules/pools.ts b/centrifuge-js/src/modules/pools.ts index a95365a55e..5b2bb11352 100644 --- a/centrifuge-js/src/modules/pools.ts +++ b/centrifuge-js/src/modules/pools.ts @@ -1839,6 +1839,67 @@ export function getPoolsModule(inst: Centrifuge) { ) } + function getDailyTVL() { + const $query = inst.getSubqueryObservable<{ + poolSnapshots: { + nodes: { + portfolioValuation: string + totalReserve: string + periodStart: string + pool: { + currency: { + decimals: number + } + } + }[] + } + }>( + `query { + poolSnapshots(first: 1000, orderBy: PERIOD_START_ASC) { + nodes { + portfolioValuation + totalReserve + periodStart + pool { + currency { + decimals + } + } + } + } + }` + ) + + return $query.pipe( + map((data) => { + if (!data) { + return [] + } + + const mergedMap = new Map() + const formatted = data.poolSnapshots.nodes.map(({ portfolioValuation, totalReserve, periodStart, pool }) => ({ + dateInMilliseconds: new Date(periodStart).getTime(), + tvl: new CurrencyBalance( + new BN(portfolioValuation || '0').add(new BN(totalReserve || '0')), + pool.currency.decimals + ).toDecimal(), + })) + + formatted.forEach((entry) => { + const { dateInMilliseconds, tvl } = entry + + if (mergedMap.has(dateInMilliseconds)) { + mergedMap.set(dateInMilliseconds, mergedMap.get(dateInMilliseconds).add(tvl)) + } else { + mergedMap.set(dateInMilliseconds, tvl) + } + }) + + return Array.from(mergedMap, ([dateInMilliseconds, tvl]) => ({ dateInMilliseconds, tvl })) + }) + ) + } + function getDailyTrancheStates(args: [trancheId: string]) { const [trancheId] = args const $query = inst.getSubqueryObservable<{ trancheSnapshots: { nodes: SubqueryTrancheSnapshot[] } }>( @@ -2083,7 +2144,12 @@ export function getPoolsModule(inst: Centrifuge) { rawBalances.forEach(([rawKey, rawValue]) => { const key = parseCurrencyKey((rawKey.toHuman() as any)[1] as CurrencyKey) - const value = rawValue.toJSON() as { free: string | number } + const value = rawValue.toJSON() as { + free: string | number + reserved: string | number + frozen: string | number + } + const currency = findCurrency(currencies, key) if (!currency) return @@ -2091,19 +2157,23 @@ export function getPoolsModule(inst: Centrifuge) { if (typeof key !== 'string' && 'Tranche' in key) { const [pid, trancheId] = key.Tranche const poolId = pid.replace(/\D/g, '') - if (value.free !== 0) { + if (value.free !== 0 || value.reserved !== 0) { + const balance = hexToBN(value.free).add(hexToBN(value.reserved)) + balances.tranches.push({ currency, poolId, trancheId, - balance: new TokenBalance(hexToBN(value.free), currency.decimals), + balance: new TokenBalance(balance, currency.decimals), }) } } else { - if (value.free !== 0) { + if (value.free !== 0 || value.reserved !== 0) { + const balance = hexToBN(value.free).add(hexToBN(value.reserved)) + balances.currencies.push({ currency, - balance: new CurrencyBalance(hexToBN(value.free), currency.decimals), + balance: new CurrencyBalance(balance, currency.decimals), }) } } @@ -2781,6 +2851,7 @@ export function getPoolsModule(inst: Centrifuge) { getNativeCurrency, getCurrencies, getDailyTrancheStates, + getDailyTVL, } } diff --git a/centrifuge-js/src/modules/rewards.ts b/centrifuge-js/src/modules/rewards.ts index c1b68ca663..fc57e86fed 100644 --- a/centrifuge-js/src/modules/rewards.ts +++ b/centrifuge-js/src/modules/rewards.ts @@ -1,4 +1,5 @@ import BN from 'bn.js' +import { forkJoin } from 'rxjs' import { combineLatestWith, filter, map, repeat, switchMap } from 'rxjs/operators' import { Centrifuge } from '../Centrifuge' import { RewardDomain } from '../CentrifugeBase' @@ -6,9 +7,10 @@ import { Account, TransactionOptions } from '../types' import { TokenBalance } from '../utils/BN' export function getRewardsModule(inst: Centrifuge) { - function computeReward(args: [address: Account, poolId: string, trancheId: string, rewardDomain: RewardDomain]) { - const [address, poolId, trancheId, rewardDomain] = args - const currencyId = { Tranche: [poolId, trancheId] } + function computeReward( + args: [address: Account, tranches: { poolId: string; trancheId: string }[], rewardDomain: RewardDomain] + ) { + const [address, tranches, rewardDomain] = args const $events = inst.getEvents().pipe( filter(({ api, events }) => { @@ -18,11 +20,19 @@ export function getRewardsModule(inst: Centrifuge) { ) return inst.getApi().pipe( - switchMap((api) => api.call.rewardsApi.computeReward(rewardDomain, currencyId, address)), + switchMap((api) => { + const computeRewardObservables = tranches.map(({ poolId, trancheId }) => + api.call.rewardsApi.computeReward(rewardDomain, { Tranche: [poolId, trancheId] }, address) + ) + return forkJoin(computeRewardObservables) + }), map((data) => { - const reward = data?.toPrimitive() as string + const rewards = data + ?.map((entry) => entry.toPrimitive() as string) + .map((entry) => new BN(entry)) + .reduce((a, b) => a.add(b), new BN(0)) - return reward ? new TokenBalance(reward, 18).toDecimal() : null + return data ? new TokenBalance(rewards, 18).toDecimal() : null }), repeat({ delay: () => $events }) ) diff --git a/fabric/src/components/Checkbox/index.tsx b/fabric/src/components/Checkbox/index.tsx index 17e9e094a6..803a36ad30 100644 --- a/fabric/src/components/Checkbox/index.tsx +++ b/fabric/src/components/Checkbox/index.tsx @@ -9,32 +9,35 @@ import { Text } from '../Text' type CheckboxProps = React.InputHTMLAttributes<HTMLInputElement> & { label?: string | React.ReactElement errorMessage?: string + extendedClickArea?: boolean } -export const Checkbox: React.VFC<CheckboxProps> = ({ label, errorMessage, ...checkboxProps }) => { +export const Checkbox: React.VFC<CheckboxProps> = ({ label, errorMessage, extendedClickArea, ...checkboxProps }) => { return ( - <label> - <Shelf as={Text} gap={1} alignItems="center"> - <StyledWrapper minWidth="18px" height="18px" flex="0 0 18px" $hasLabel={!!label}> - <StyledCheckbox type="checkbox" {...checkboxProps} /> - <StyledOutline /> - </StyledWrapper> - {label && ( - <Stack gap={1} flex={1}> - {typeof label === 'string' && ( - <Text variant="body1" color={checkboxProps.disabled ? 'textDisabled' : 'textPrimary'}> - {label} - </Text> - )} - {React.isValidElement(label) && label} - {errorMessage && ( - <Text variant="label2" color="statusCritical"> - {errorMessage} - </Text> - )} - </Stack> - )} - </Shelf> + <Box position="relative"> + <StyledLabel $extendedClickArea={!!extendedClickArea}> + <Shelf as={Text} gap={1} alignItems="center" position="relative"> + <StyledWrapper minWidth="18px" height="18px" flex="0 0 18px" $hasLabel={!!label}> + <StyledCheckbox type="checkbox" {...checkboxProps} /> + <StyledOutline /> + </StyledWrapper> + {label && ( + <Stack gap={1} flex={1}> + {typeof label === 'string' && ( + <Text variant="body1" color={checkboxProps.disabled ? 'textDisabled' : 'textPrimary'}> + {label} + </Text> + )} + {React.isValidElement(label) && label} + {errorMessage && ( + <Text variant="label2" color="statusCritical"> + {errorMessage} + </Text> + )} + </Stack> + )} + </Shelf> + </StyledLabel> {!label && errorMessage && ( <Box mt={1}> <Text variant="label2" color="statusCritical"> @@ -42,10 +45,35 @@ export const Checkbox: React.VFC<CheckboxProps> = ({ label, errorMessage, ...che </Text> </Box> )} - </label> + </Box> ) } +const StyledLabel = styled.label<{ $extendedClickArea: boolean }>` + cursor: pointer; + user-select: none; + + &:before { + --offset: 10px; + + content: ''; + display: ${({ $extendedClickArea }) => ($extendedClickArea ? 'block' : 'none')}; + position: absolute; + top: calc(var(--offset) * -1); + left: calc(var(--offset) * -1); + width: calc(100% + var(--offset) * 2); + height: calc(100% + var(--offset) * 2); + background-color: ${({ theme }) => theme.colors.borderSecondary}; + border-radius: ${({ theme }) => theme.radii.tooltip}px; + opacity: 0; + transition: opacity 0.1s linear; + } + + &:hover:before { + opacity: 1; + } +` + const StyledOutline = styled.span` display: none; pointer-events: none; diff --git a/fabric/src/components/Popover/index.tsx b/fabric/src/components/Popover/index.tsx index 7196abf970..5f6f819308 100644 --- a/fabric/src/components/Popover/index.tsx +++ b/fabric/src/components/Popover/index.tsx @@ -1,5 +1,5 @@ import { FocusScope } from '@react-aria/focus' -import { useOverlay, useOverlayTrigger } from '@react-aria/overlays' +import { AriaPositionProps, useOverlay, useOverlayTrigger } from '@react-aria/overlays' import { OverlayTriggerState, useOverlayTriggerState } from '@react-stately/overlays' import * as React from 'react' import { Positioner } from '../Positioner' @@ -15,9 +15,10 @@ type PopoverProps = { ref: React.RefObject<HTMLDivElement>, state: OverlayTriggerState ) => React.ReactElement + placement?: AriaPositionProps['placement'] } -export const Popover: React.FC<PopoverProps> = ({ renderTrigger, renderContent }) => { +export const Popover: React.FC<PopoverProps> = ({ renderTrigger, renderContent, placement }) => { const state = useOverlayTriggerState({}) const overlayRef = React.useRef<HTMLDivElement>(null) const triggerRef = React.useRef<HTMLDivElement>(null) @@ -46,6 +47,7 @@ export const Popover: React.FC<PopoverProps> = ({ renderTrigger, renderContent } {state.isOpen && ( <Positioner isShown + placement={placement} targetRef={triggerRef} overlayRef={overlayRef} render={(positionProps) => ( diff --git a/fabric/src/components/SideNavigation/index.tsx b/fabric/src/components/SideNavigation/index.tsx new file mode 100644 index 0000000000..3ab5668e5f --- /dev/null +++ b/fabric/src/components/SideNavigation/index.tsx @@ -0,0 +1,45 @@ +import * as React from 'react' +import styled from 'styled-components' +import { Shelf } from '../Shelf' + +export type SideNavigationProps = { + items: { + label: string + href: string + isActive: boolean + }[] +} + +export function SideNavigation({ items }: SideNavigationProps) { + return ( + <SideNavigationContainer> + {items.map(({ label, href, isActive }) => ( + <SideNavigationItem href={href} $isActive={isActive}> + {label} + </SideNavigationItem> + ))} + </SideNavigationContainer> + ) +} + +export function SideNavigationContainer({ children }: { children: React.ReactNode }) { + return ( + <Shelf as="nav" bg="backgroundSecondary" borderRadius="20px" p="5px"> + {children} + </Shelf> + ) +} + +export const SideNavigationItem = styled.a<{ $isActive: boolean }>` + display: block; + padding: 7px 16px 8px 16px; + border-radius: 20px; + + color: ${({ theme, $isActive }) => ($isActive ? theme.colors.textInverted : theme.colors.textPrimary)}; + font-size: ${({ theme }) => theme.typography.interactive2.fontSize}px; + line-height: ${({ theme }) => theme.typography.interactive2.lineHeight}; + font-weight: ${({ theme }) => theme.typography.interactive2.fontWeight}; + + box-shadow: ${({ theme, $isActive }) => ($isActive ? theme.shadows.cardInteractive : 'none')}; + background: ${({ theme, $isActive }) => ($isActive ? theme.colors.textSelected : 'transparent')}; +` diff --git a/fabric/src/components/StatusChip/index.tsx b/fabric/src/components/StatusChip/index.tsx index 4bad3b604b..50a5d1e332 100644 --- a/fabric/src/components/StatusChip/index.tsx +++ b/fabric/src/components/StatusChip/index.tsx @@ -17,11 +17,13 @@ const colors = { const Wrapper = styled.span<{ $color: string }>((props) => css({ + display: 'inline-block', padding: '0 8px', borderColor: props.$color, borderWidth: '1px', borderStyle: 'solid', borderRadius: '20px', + whiteSpace: 'nowrap', }) ) diff --git a/fabric/src/icon-svg/icon-filter.svg b/fabric/src/icon-svg/icon-filter.svg new file mode 100644 index 0000000000..543d2f321b --- /dev/null +++ b/fabric/src/icon-svg/icon-filter.svg @@ -0,0 +1,5 @@ +<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M2.47536 6.19355V2.56911H21.7128V6.19355H2.47536Z" stroke="black" stroke-width="2" stroke-linejoin="round"/> +<path d="M2.6196 6.3101L10.1381 16.3307H15.1541L21.5764 6.3101" stroke="black" stroke-width="2" stroke-linejoin="round"/> +<path d="M10.4982 16.9014V22.2515L15.0154 20.3225V16.3702" stroke="black" stroke-width="2" stroke-linejoin="round"/> +</svg> \ No newline at end of file diff --git a/fabric/src/index.ts b/fabric/src/index.ts index 384130c859..d8811ce946 100644 --- a/fabric/src/index.ts +++ b/fabric/src/index.ts @@ -30,6 +30,7 @@ export * from './components/RadioButton' export * from './components/RangeInput' export * from './components/Select' export * from './components/Shelf' +export { SideNavigation, SideNavigationContainer, SideNavigationItem } from './components/SideNavigation' export * from './components/Spinner' export * from './components/Stack' export * from './components/StatusChip' diff --git a/fabric/src/theme/tokens/baseTheme.ts b/fabric/src/theme/tokens/baseTheme.ts index 04490e2ceb..96bbb47d82 100644 --- a/fabric/src/theme/tokens/baseTheme.ts +++ b/fabric/src/theme/tokens/baseTheme.ts @@ -31,6 +31,7 @@ export const baseTheme: Omit<FabricTheme, 'colors' | 'scheme'> = { }, zIndices: { sticky: 10, + header: 30, overlay: 50, onTopOfTheWorld: 1000, // use sparingly, only for edge cases }, diff --git a/fabric/src/theme/types.ts b/fabric/src/theme/types.ts index ce074e6cad..9a6bd819a5 100644 --- a/fabric/src/theme/types.ts +++ b/fabric/src/theme/types.ts @@ -123,7 +123,7 @@ type ThemeShadows = { [k in ShadowName]: ShadowValue } -type ZIndexName = 'sticky' | 'overlay' | 'onTopOfTheWorld' +type ZIndexName = 'sticky' | 'header' | 'overlay' | 'onTopOfTheWorld' type ZIndexValue = number type ThemeZIndices = { [k in ZIndexName]: ZIndexValue From 6b4a7870111aacae56a5d52610a1081965fee560 Mon Sep 17 00:00:00 2001 From: Sophia <littlejohn.sophia@gmail.com> Date: Tue, 29 Aug 2023 12:02:57 -0400 Subject: [PATCH 12/39] Fix error in coalescing (#1553) --- onboarding-api/src/emails/sendDocumentsMessage.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/onboarding-api/src/emails/sendDocumentsMessage.ts b/onboarding-api/src/emails/sendDocumentsMessage.ts index e75e502ab4..0332857c56 100644 --- a/onboarding-api/src/emails/sendDocumentsMessage.ts +++ b/onboarding-api/src/emails/sendDocumentsMessage.ts @@ -37,7 +37,7 @@ export const sendDocumentsMessage = async ( { to: [ { - email: debugEmail ?? metadata?.pool?.issuer?.email, + email: debugEmail || metadata?.pool?.issuer?.email, }, ], dynamic_template_data: { From ea491a14e3c1c91952a60d88298a9fa4a4574071 Mon Sep 17 00:00:00 2001 From: Sophia <littlejohn.sophia@gmail.com> Date: Tue, 29 Aug 2023 17:20:46 -0400 Subject: [PATCH 13/39] OnboardingAPI: demo/algol setup (#1554) * Update env variables * Add new demo pure proxy * Fix tinlake onboarding --- .../src/components/InvestRedeem/InvestRedeemProvider.tsx | 5 ----- centrifuge-app/src/pages/Pool/Overview/index.tsx | 2 +- onboarding-api/env-vars/demo.env | 6 +++--- 3 files changed, 4 insertions(+), 9 deletions(-) diff --git a/centrifuge-app/src/components/InvestRedeem/InvestRedeemProvider.tsx b/centrifuge-app/src/components/InvestRedeem/InvestRedeemProvider.tsx index beac16627d..1db477855f 100644 --- a/centrifuge-app/src/components/InvestRedeem/InvestRedeemProvider.tsx +++ b/centrifuge-app/src/components/InvestRedeem/InvestRedeemProvider.tsx @@ -1,4 +1,3 @@ -import { useWallet } from '@centrifuge/centrifuge-react' import * as React from 'react' import { InvestRedeemCentrifugeProvider } from './InvestRedeemCentrifugeProvider' import { InvestRedeemTinlakeProvider } from './InvestRedeemTinlakeProvider' @@ -14,10 +13,6 @@ export function useInvestRedeem() { export function InvestRedeemProvider(props: Props) { const isTinlakePool = props.poolId.startsWith('0x') - const { connectedNetwork } = useWallet() - if (connectedNetwork && [1, 5, 8453, 84531].includes(connectedNetwork as any)) { - return null - } const Comp = isTinlakePool ? InvestRedeemTinlakeProvider : InvestRedeemCentrifugeProvider diff --git a/centrifuge-app/src/pages/Pool/Overview/index.tsx b/centrifuge-app/src/pages/Pool/Overview/index.tsx index 90fa740d60..0f957e8df1 100644 --- a/centrifuge-app/src/pages/Pool/Overview/index.tsx +++ b/centrifuge-app/src/pages/Pool/Overview/index.tsx @@ -66,7 +66,7 @@ export function PoolDetailSideBar({ const isTinlakePool = poolId.startsWith('0x') const tinlakeNetworks = [ethConfig.network === 'goerli' ? 5 : 1] as Network[] // TODO: fetch supported networks from centrifuge chain - const centrifugeNetworks = ['centrifuge', 84531] as Network[] + const centrifugeNetworks = ['centrifuge', 1, 5] as Network[] return ( <InvestRedeem diff --git a/onboarding-api/env-vars/demo.env b/onboarding-api/env-vars/demo.env index 38a44ff4bb..a791f6cec1 100644 --- a/onboarding-api/env-vars/demo.env +++ b/onboarding-api/env-vars/demo.env @@ -1,7 +1,7 @@ REDIRECT_URL=https://app-demo.k-f.dev -MEMBERLIST_ADMIN_PURE_PROXY=kALJqPUHFzDR2VkoQYWefPQyzjGzKznNny2smXGQpSf3aMw19 -COLLATOR_WSS_URL=wss://fullnode.demo.cntrfg.com -RELAY_WSS_URL=wss://fullnode-relay.demo.cntrfg.com +MEMBERLIST_ADMIN_PURE_PROXY=kAM1ELFDHdHeLDAkAdwEnfufoCL5hpUycGs4ZQkSQKVpHFoXm +COLLATOR_WSS_URL=wss://fullnode.algol.cntrfg.com/public-ws +RELAY_WSS_URL=wss://fullnode-relay.algol.cntrfg.com INFURA_KEY=bf808e7d3d924fbeb74672d9341d0550 EVM_NETWORK=goerli ONBOARDING_STORAGE_BUCKET=centrifuge-onboarding-api-dev From 599bb82f2a4239b255af62b2eb98f0118cee80e5 Mon Sep 17 00:00:00 2001 From: Jeroen Offerijns <Offerijns@users.noreply.github.com> Date: Wed, 30 Aug 2023 15:13:51 +0200 Subject: [PATCH 14/39] CentrifugeJS: Add runtime api definition (#1552) --- centrifuge-js/package.json | 2 +- centrifuge-js/src/CentrifugeBase.ts | 66 +++++++++++++++++------------ 2 files changed, 39 insertions(+), 29 deletions(-) diff --git a/centrifuge-js/package.json b/centrifuge-js/package.json index 0190139925..d9d887e9ca 100644 --- a/centrifuge-js/package.json +++ b/centrifuge-js/package.json @@ -1,6 +1,6 @@ { "name": "@centrifuge/centrifuge-js", - "version": "0.4.1", + "version": "0.5.0", "description": "", "homepage": "https://github.com/centrifuge/apps/tree/main/centrifuge-js#readme", "author": "", diff --git a/centrifuge-js/src/CentrifugeBase.ts b/centrifuge-js/src/CentrifugeBase.ts index 439e7cd242..45554a0d1a 100644 --- a/centrifuge-js/src/CentrifugeBase.ts +++ b/centrifuge-js/src/CentrifugeBase.ts @@ -83,36 +83,15 @@ const defaultConfig: Config = { const relayChainTypes = {} const parachainTypes = { - // NFTs - ClassId: 'u64', - InstanceId: 'u128', - // Crowdloan - RootHashOf: 'Hash', - TrieIndex: 'u32', - RelayChainAccountId: 'AccountId', - ParachainAccountIdOf: 'AccountId', - Proof: { - leafHash: 'Hash', - sortedHashes: 'Vec<Hash>', + ActiveLoanInfo: { + activeLoan: 'PalletLoansEntitiesLoansActiveLoan', + presentValue: 'Balance', + outstandingPrincipal: 'Balance', + outstandingInterest: 'Balance', }, - PoolId: 'u64', - TrancheId: '[u8; 16]', RewardDomain: { _enum: ['Block', 'Liquidity'], }, - StakingCurrency: { - _enum: ['BlockRewards'], - }, - CurrencyId: { - _enum: { - Native: 'Native', - Tranche: '(PoolId, TrancheId)', - KSM: 'KSM', - AUSD: 'AUSD', - ForeignAsset: 'u32', - Staking: 'StakingCurrency', - }, - }, } const parachainRpcMethods: Record<string, Record<string, DefinitionRpc>> = { @@ -142,7 +121,7 @@ const parachainRpcMethods: Record<string, Record<string, DefinitionRpc>> = { type: 'AccountId', }, ], - type: 'Vec<CurrencyId>', + type: 'Vec<CfgTypesTokensCurrencyId>', }, computeReward: { description: 'Compute the claimable reward for the given triplet of domain, currency and account', @@ -153,7 +132,7 @@ const parachainRpcMethods: Record<string, Record<string, DefinitionRpc>> = { }, { name: 'currency_id', - type: 'CurrencyId', + type: 'CfgTypesTokensCurrencyId', }, { name: 'account_id', @@ -187,6 +166,37 @@ const parachainRuntimeApi: DefinitionsCall = { version: 1, }, ], + LoansApi: [ + { + methods: { + portfolio: { + description: 'Get active pool loan', + params: [ + { + name: 'pool_id', + type: 'u64', + }, + ], + type: 'Vec<(u64, ActiveLoanInfo)>', + }, + portfolio_loan: { + description: 'Get active pool loan', + params: [ + { + name: 'pool_id', + type: 'u64', + }, + { + name: 'loan_id', + type: 'u64', + }, + ], + type: 'Option<PalletLoansEntitiesLoansActiveLoan>', + }, + }, + version: 1, + }, + ], } type Events = ISubmittableResult['events'] From 486744236e7d10fa867f1979a6c15a7690ff91ae Mon Sep 17 00:00:00 2001 From: Sophia <littlejohn.sophia@gmail.com> Date: Wed, 30 Aug 2023 09:51:38 -0400 Subject: [PATCH 15/39] Hide portfolio card behind debug flag (#1556) --- .../src/components/DebugFlags/config.ts | 6 +++ .../src/components/PortfolioCta/index.tsx | 42 +++++++++++-------- 2 files changed, 30 insertions(+), 18 deletions(-) diff --git a/centrifuge-app/src/components/DebugFlags/config.ts b/centrifuge-app/src/components/DebugFlags/config.ts index 866e55c54c..c792086903 100644 --- a/centrifuge-app/src/components/DebugFlags/config.ts +++ b/centrifuge-app/src/components/DebugFlags/config.ts @@ -45,6 +45,7 @@ export type Key = | 'editAdminConfig' | 'showPodAccountCreation' | 'convertEvmAddress' + | 'showPortfolio' export const flagsConfig: Record<Key, DebugFlagConfig> = { address: { @@ -113,4 +114,9 @@ export const flagsConfig: Record<Key, DebugFlagConfig> = { default: null, alwaysShow: true, }, + showPortfolio: { + type: 'checkbox', + default: false, + alwaysShow: true, + }, } diff --git a/centrifuge-app/src/components/PortfolioCta/index.tsx b/centrifuge-app/src/components/PortfolioCta/index.tsx index c520300ecc..247f821d53 100644 --- a/centrifuge-app/src/components/PortfolioCta/index.tsx +++ b/centrifuge-app/src/components/PortfolioCta/index.tsx @@ -9,6 +9,7 @@ import { formatBalance, formatBalanceAbbreviated } from '../../utils/formatting' import { useAddress } from '../../utils/useAddress' import { useListedPools } from '../../utils/useListedPools' import { useLoansAcrossPools } from '../../utils/useLoans' +import { useDebugFlags } from '../DebugFlags' import { useComputeLiquidityRewards } from '../LiquidityRewards/hooks' import { Cubes } from './Cubes' @@ -19,6 +20,7 @@ export function PortfolioCta() { const balances = useBalances(address) const consts = useCentrifugeConsts() const [, listedTokens] = useListedPools() + const { showPortfolio } = useDebugFlags() const stakes = balances?.tranches.map(({ poolId, trancheId }) => ({ poolId, trancheId })) ?? [] const rewards = useComputeLiquidityRewards(address, stakes) @@ -65,9 +67,9 @@ export function PortfolioCta() { borderRadius="card" borderStyle="solid" borderWidth={1} - borderColor="borderSecondary" + borderColor={address && showPortfolio ? 'borderSecondary' : 'transparent'} style={{ - boxShadow: `0px 3px 2px -2px ${colors.borderPrimary}`, + boxShadow: address && showPortfolio ? `0px 3px 2px -2px ${colors.borderPrimary}` : '', }} > {!address && <Cubes />} @@ -75,22 +77,26 @@ export function PortfolioCta() { <Stack gap={2} alignItems="start"> {address ? ( <> - <Text as="h2" variant="heading2"> - Your portfolio - </Text> - - <Shelf as="dl" gap={6} flexWrap="wrap" rowGap={2}> - {terms.map(({ title, value }, index) => ( - <Stack key={`${title}${index}`} gap="4px"> - <Text as="dt" variant="body3" whiteSpace="nowrap"> - {title} - </Text> - <Text as="dd" variant="body2" whiteSpace="nowrap"> - {value} - </Text> - </Stack> - ))} - </Shelf> + {showPortfolio && ( + <> + {' '} + <Text as="h2" variant="heading2"> + Your portfolio + </Text> + <Shelf as="dl" gap={6} flexWrap="wrap" rowGap={2}> + {terms.map(({ title, value }, index) => ( + <Stack key={`${title}${index}`} gap="4px"> + <Text as="dt" variant="body3" whiteSpace="nowrap"> + {title} + </Text> + <Text as="dd" variant="body2" whiteSpace="nowrap"> + {value} + </Text> + </Stack> + ))} + </Shelf> + </> + )} </> ) : ( <> From 6cce32ab724b894b63fc2fc67202d5790c4ae112 Mon Sep 17 00:00:00 2001 From: Sophia <littlejohn.sophia@gmail.com> Date: Wed, 30 Aug 2023 10:24:13 -0400 Subject: [PATCH 16/39] Hide portfolio card take 2 (#1557) --- .../src/components/PortfolioCta/index.tsx | 66 ++++++++++++------- 1 file changed, 42 insertions(+), 24 deletions(-) diff --git a/centrifuge-app/src/components/PortfolioCta/index.tsx b/centrifuge-app/src/components/PortfolioCta/index.tsx index 247f821d53..e15171bced 100644 --- a/centrifuge-app/src/components/PortfolioCta/index.tsx +++ b/centrifuge-app/src/components/PortfolioCta/index.tsx @@ -57,7 +57,7 @@ export function PortfolioCta() { }, ] - return ( + return showPortfolio ? ( <Box as="article" position="relative" @@ -67,9 +67,9 @@ export function PortfolioCta() { borderRadius="card" borderStyle="solid" borderWidth={1} - borderColor={address && showPortfolio ? 'borderSecondary' : 'transparent'} + borderColor={'borderSecondary'} style={{ - boxShadow: address && showPortfolio ? `0px 3px 2px -2px ${colors.borderPrimary}` : '', + boxShadow: `0px 3px 2px -2px ${colors.borderPrimary}`, }} > {!address && <Cubes />} @@ -77,26 +77,21 @@ export function PortfolioCta() { <Stack gap={2} alignItems="start"> {address ? ( <> - {showPortfolio && ( - <> - {' '} - <Text as="h2" variant="heading2"> - Your portfolio - </Text> - <Shelf as="dl" gap={6} flexWrap="wrap" rowGap={2}> - {terms.map(({ title, value }, index) => ( - <Stack key={`${title}${index}`} gap="4px"> - <Text as="dt" variant="body3" whiteSpace="nowrap"> - {title} - </Text> - <Text as="dd" variant="body2" whiteSpace="nowrap"> - {value} - </Text> - </Stack> - ))} - </Shelf> - </> - )} + <Text as="h2" variant="heading2"> + Your portfolio + </Text> + <Shelf as="dl" gap={6} flexWrap="wrap" rowGap={2}> + {terms.map(({ title, value }, index) => ( + <Stack key={`${title}${index}`} gap="4px"> + <Text as="dt" variant="body3" whiteSpace="nowrap"> + {title} + </Text> + <Text as="dd" variant="body2" whiteSpace="nowrap"> + {value} + </Text> + </Stack> + ))} + </Shelf> </> ) : ( <> @@ -108,5 +103,28 @@ export function PortfolioCta() { )} </Stack> </Box> - ) + ) : !address ? ( + <Box + as="article" + position="relative" + p={3} + pb={5} + overflow="hidden" + borderRadius="card" + borderStyle="solid" + borderWidth={1} + borderColor={'borderSecondary'} + style={{ + boxShadow: `0px 3px 2px -2px ${colors.borderPrimary}`, + }} + > + {!address && <Cubes />} + <Stack gap={2} alignItems="start"> + <Text as="h2" variant="body1" style={{ maxWidth: '35ch' }}> + Pools on Centrifuge let investors earn yield from real-world assets. + </Text> + <Button onClick={() => showNetworks()}>Get started</Button> + </Stack> + </Box> + ) : null } From bc4a9d383a2cea1ba7a646971ea49f0355646375 Mon Sep 17 00:00:00 2001 From: Onno Visser <visser.onno@gmail.com> Date: Thu, 31 Aug 2023 10:54:18 +0200 Subject: [PATCH 17/39] Centrifuge App: Make wallet button non sticky (#1559) --- centrifuge-app/src/components/LayoutBase/styles.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/centrifuge-app/src/components/LayoutBase/styles.tsx b/centrifuge-app/src/components/LayoutBase/styles.tsx index bf6e22e4fe..ae351ddf12 100644 --- a/centrifuge-app/src/components/LayoutBase/styles.tsx +++ b/centrifuge-app/src/components/LayoutBase/styles.tsx @@ -113,7 +113,7 @@ export const LogoContainer = styled(Stack)` export const WalletContainer = styled(Stack)` z-index: ${({ theme }) => theme.zIndices.header}; - position: sticky; + /* position: sticky; */ top: 0; grid-area: wallet; // WalletContainer & WalletPositioner are positioned above the main content and would block user interaction (click). From 76b39555e1fd34357168e0024a56a3ddda54946f Mon Sep 17 00:00:00 2001 From: Sophia <littlejohn.sophia@gmail.com> Date: Tue, 5 Sep 2023 11:04:53 -0400 Subject: [PATCH 18/39] OnboardingAPI: fix-ups for algol (#1560) * Send proper chain id to api * Fix typescript errors * Fix onboarding for algol/demo * Fix global onboarding status * Fix global status for evm * Update email dynamic data to match new templates * Remove duplicate pool name --- .../queries/useGlobalOnboardingStatus.ts | 17 ++++++++++++----- .../pages/Onboarding/queries/useSignRemark.ts | 4 +++- onboarding-api/.env.example | 16 +++++++++++++--- .../user/getGlobalOnboardingStatus.ts | 12 +++--------- .../controllers/user/updateInvestorStatus.ts | 4 ++-- onboarding-api/src/database/index.ts | 4 ++-- .../src/emails/sendApproveInvestorMessage.ts | 1 - .../src/emails/sendApproveIssuerMessage.ts | 10 +++++++--- .../src/emails/sendDocumentsMessage.ts | 6 ++++++ .../src/emails/sendRejectInvestorMessage.ts | 9 +++++++-- onboarding-api/src/utils/envCheck.ts | 2 +- onboarding-api/src/utils/fetchUser.ts | 2 +- 12 files changed, 57 insertions(+), 30 deletions(-) diff --git a/centrifuge-app/src/pages/Onboarding/queries/useGlobalOnboardingStatus.ts b/centrifuge-app/src/pages/Onboarding/queries/useGlobalOnboardingStatus.ts index 570129cdbf..5cbd235278 100644 --- a/centrifuge-app/src/pages/Onboarding/queries/useGlobalOnboardingStatus.ts +++ b/centrifuge-app/src/pages/Onboarding/queries/useGlobalOnboardingStatus.ts @@ -1,20 +1,27 @@ -import { useWallet } from '@centrifuge/centrifuge-react' +import { useCentrifuge, useWallet } from '@centrifuge/centrifuge-react' +import { encodeAddress } from '@polkadot/util-crypto' import { useQuery } from 'react-query' import { getSelectedWallet } from '../../../utils/getSelectedWallet' export const useGlobalOnboardingStatus = () => { const wallet = useWallet() + const cent = useCentrifuge() const selectedWallet = getSelectedWallet(wallet) const query = useQuery( ['global-onboarding-status', selectedWallet?.address], async () => { - if (selectedWallet) { + if (selectedWallet && selectedWallet.address) { + const chainId = await cent.getChainId() + const address = + selectedWallet.network === 'substrate' + ? encodeAddress(selectedWallet.address, chainId) + : selectedWallet.address const response = await fetch( - `${import.meta.env.REACT_APP_ONBOARDING_API_URL}/getGlobalOnboardingStatus?address=${ - selectedWallet.address - }&network=${selectedWallet.network}`, + `${import.meta.env.REACT_APP_ONBOARDING_API_URL}/getGlobalOnboardingStatus?address=${address}&network=${ + selectedWallet.network + }&chainId=${wallet.connectedNetwork}`, { method: 'GET', headers: { diff --git a/centrifuge-app/src/pages/Onboarding/queries/useSignRemark.ts b/centrifuge-app/src/pages/Onboarding/queries/useSignRemark.ts index 092d143cd4..562b1e3115 100644 --- a/centrifuge-app/src/pages/Onboarding/queries/useSignRemark.ts +++ b/centrifuge-app/src/pages/Onboarding/queries/useSignRemark.ts @@ -65,11 +65,13 @@ export const useSignRemark = ( // @ts-expect-error blockNumber = result.blockNumber.toString() } + const chainId = connectedNetwork === 'centrifuge' ? await centrifuge.getChainId() : connectedNetwork + await sendDocumentsToIssuer({ txHash, blockNumber, isEvmOnSubstrate, - chainId: connectedNetwork || 'centrifuge', + chainId: chainId || 136, }) setIsSubstrateTxLoading(false) } catch (e) { diff --git a/onboarding-api/.env.example b/onboarding-api/.env.example index 23f2d5e640..309a009571 100644 --- a/onboarding-api/.env.example +++ b/onboarding-api/.env.example @@ -1,6 +1,16 @@ +# VARIABLES +REDIRECT_URL=http://localhost:3000 +MEMBERLIST_ADMIN_PURE_PROXY=kAM1ELFDHdHeLDAkAdwEnfufoCL5hpUycGs4ZQkSQKVpHFoXm +COLLATOR_WSS_URL=wss://fullnode.algol.cntrfg.com/public-ws +RELAY_WSS_URL=wss://fullnode-relay.algol.cntrfg.com +INFURA_KEY=bf808e7d3d924fbeb74672d9341d0550 +EVM_NETWORK=goerli +ONBOARDING_STORAGE_BUCKET=centrifuge-onboarding-api-dev +# SECRETS SHUFTI_PRO_SECRET_KEY= SHUFTI_PRO_CLIENT_ID= -NODE_ENV=development JWT_SECRET= -REDIRECT_URL=http://localhost:3000 -SENDGRID_API_KEY= \ No newline at end of file +SENDGRID_API_KEY= +COOKIE_SECRET= +EVM_MEMBERLIST_ADMIN_PRIVATE_KEY= +PURE_PROXY_CONTROLLER_SEED= \ No newline at end of file diff --git a/onboarding-api/src/controllers/user/getGlobalOnboardingStatus.ts b/onboarding-api/src/controllers/user/getGlobalOnboardingStatus.ts index 26d9c99314..9cc601ed86 100644 --- a/onboarding-api/src/controllers/user/getGlobalOnboardingStatus.ts +++ b/onboarding-api/src/controllers/user/getGlobalOnboardingStatus.ts @@ -1,21 +1,15 @@ import { Request, Response } from 'express' -import { InferType, mixed, object, string } from 'yup' -import { SupportedNetworks } from '../../database' +import { walletSchema } from '../../database' import { fetchUser } from '../../utils/fetchUser' import { reportHttpError } from '../../utils/httpError' import { validateInput } from '../../utils/validateInput' -const getGlobalOnboardingStatusInput = object({ - address: string().required(), - network: mixed<SupportedNetworks>().required().oneOf(['evm', 'substrate']), -}) - export const getGlobalOnboardingStatusController = async ( - req: Request<{}, {}, {}, InferType<typeof getGlobalOnboardingStatusInput>>, + req: Request<{}, {}, {}, Request['wallet']>, res: Response ) => { try { - await validateInput(req.query, getGlobalOnboardingStatusInput) + await validateInput(req.query, walletSchema) const user = await fetchUser(req.query, { suppressError: true }) diff --git a/onboarding-api/src/controllers/user/updateInvestorStatus.ts b/onboarding-api/src/controllers/user/updateInvestorStatus.ts index 3046246619..888517ff63 100644 --- a/onboarding-api/src/controllers/user/updateInvestorStatus.ts +++ b/onboarding-api/src/controllers/user/updateInvestorStatus.ts @@ -106,13 +106,13 @@ export const updateInvestorStatusController = async ( metadata, countersignedAgreementPDF ), - sendApproveIssuerMessage(wallet.address, metadata, tranche as Pool['tranches'][0], countersignedAgreementPDF), + sendApproveIssuerMessage(wallet, metadata, tranche as Pool['tranches'][0], countersignedAgreementPDF), validateAndWriteToFirestore(wallet, updatedUser, user.investorType, ['poolSteps']), ]) return res.status(200).send({ status: 'approved', poolId, trancheId, txHash }) } else if (user?.email && status === 'rejected') { await Promise.all([ - sendRejectInvestorMessage(user.email, metadata), + sendRejectInvestorMessage(user.email, tranche as Pool['tranches'][0], metadata), validateAndWriteToFirestore(wallet, updatedUser, user.investorType, ['poolSteps']), ]) return res.status(200).send({ status: 'rejected', poolId, trancheId }) diff --git a/onboarding-api/src/database/index.ts b/onboarding-api/src/database/index.ts index adb63287f1..fc7b26a829 100644 --- a/onboarding-api/src/database/index.ts +++ b/onboarding-api/src/database/index.ts @@ -22,7 +22,7 @@ const uboSchema = object({ countryOfCitizenship: string().required(), }) -const walletSchema = object({ +export const walletSchema = object({ evm: array().of(string()), substrate: array().of(string()), evmOnSubstrate: array().of(string()), @@ -115,7 +115,7 @@ export const individualUserSchema = object({ investorType: string().default('individual') as StringSchema<Individual>, wallets: walletSchema, kycReference: string().optional(), - email: string().default(null).nullable(), // TODO: coming soon + email: string().default(null).nullable(), name: string().required(), dateOfBirth: string().required(), countryOfCitizenship: string().required(), // TODO: validate with list of countries diff --git a/onboarding-api/src/emails/sendApproveInvestorMessage.ts b/onboarding-api/src/emails/sendApproveInvestorMessage.ts index 7edea5a9a1..28f41948d9 100644 --- a/onboarding-api/src/emails/sendApproveInvestorMessage.ts +++ b/onboarding-api/src/emails/sendApproveInvestorMessage.ts @@ -17,7 +17,6 @@ export const sendApproveInvestorMessage = async ( }, ], dynamic_template_data: { - poolName: poolMetadata?.pool.name, trancheName: tranche?.currency.name, poolUrl: `${process.env.REDIRECT_URL}/pools/${poolId}`, }, diff --git a/onboarding-api/src/emails/sendApproveIssuerMessage.ts b/onboarding-api/src/emails/sendApproveIssuerMessage.ts index 48bc1f8bca..382c5957e3 100644 --- a/onboarding-api/src/emails/sendApproveIssuerMessage.ts +++ b/onboarding-api/src/emails/sendApproveIssuerMessage.ts @@ -1,12 +1,15 @@ import { Pool } from '@centrifuge/centrifuge-js' +import { Request } from 'express' import { sendEmail, templateIds } from '.' +import { fetchUser } from '../utils/fetchUser' export const sendApproveIssuerMessage = async ( - walletAddress: string, + wallet: Request['wallet'], metadata: Record<string, any>, tranche: Pool['tranches'][0], countersignedAgreementPDF: Uint8Array ) => { + const user = await fetchUser(wallet) const message = { personalizations: [ { @@ -16,7 +19,8 @@ export const sendApproveIssuerMessage = async ( }, ], dynamic_template_data: { - tokenName: tranche.currency.name, + trancheName: tranche.currency.name, + investorEmail: user.email, }, }, ], @@ -28,7 +32,7 @@ export const sendApproveIssuerMessage = async ( attachments: [ { content: Buffer.from(countersignedAgreementPDF).toString('base64'), - filename: `${walletAddress}-${tranche.currency.name?.replaceAll(' ', '-')}-subscription-agreement.pdf`, + filename: `${wallet.address}-${tranche.currency.name?.replaceAll(' ', '-')}-subscription-agreement.pdf`, type: 'application/pdf', disposition: 'attachment', }, diff --git a/onboarding-api/src/emails/sendDocumentsMessage.ts b/onboarding-api/src/emails/sendDocumentsMessage.ts index 0332857c56..3034f58382 100644 --- a/onboarding-api/src/emails/sendDocumentsMessage.ts +++ b/onboarding-api/src/emails/sendDocumentsMessage.ts @@ -2,6 +2,7 @@ import { Request } from 'express' import * as jwt from 'jsonwebtoken' import { sendEmail, templateIds } from '.' import { onboardingBucket } from '../database' +import { fetchUser } from '../utils/fetchUser' import { HttpError } from '../utils/httpError' import { NetworkSwitch } from '../utils/networks/networkSwitch' @@ -11,6 +12,7 @@ export type UpdateInvestorStatusPayload = { trancheId: string } +// send documents to issuer to approve or reject the prospective investor export const sendDocumentsMessage = async ( wallet: Request['wallet'], poolId: string, @@ -19,6 +21,8 @@ export const sendDocumentsMessage = async ( debugEmail?: string ) => { const { metadata, pool } = await new NetworkSwitch(wallet.network).getPoolById(poolId) + const tranche = pool?.tranches.find((t) => t.id === trancheId) + const investorEmail = (await fetchUser(wallet)).email const payload: UpdateInvestorStatusPayload = { wallet, poolId, trancheId } const token = jwt.sign(payload, process.env.JWT_SECRET, { expiresIn: '14d', @@ -48,6 +52,8 @@ export const sendDocumentsMessage = async ( token )}&status=approved&metadata=${pool?.metadata}&network=${wallet.network}`, disclaimerLink: `${process.env.REDIRECT_URL}/disclaimer`, + trancheName: tranche?.currency.name, + investorEmail, }, }, ], diff --git a/onboarding-api/src/emails/sendRejectInvestorMessage.ts b/onboarding-api/src/emails/sendRejectInvestorMessage.ts index dd512cc910..cacf7d04af 100644 --- a/onboarding-api/src/emails/sendRejectInvestorMessage.ts +++ b/onboarding-api/src/emails/sendRejectInvestorMessage.ts @@ -1,6 +1,11 @@ +import { Pool } from '@centrifuge/centrifuge-js' import { sendEmail, templateIds } from '.' -export const sendRejectInvestorMessage = async (to: string, metadata: Record<string, any>) => { +export const sendRejectInvestorMessage = async ( + to: string, + tranche: Pool['tranches'][0], + metadata: Record<string, any> +) => { const message = { personalizations: [ { @@ -10,8 +15,8 @@ export const sendRejectInvestorMessage = async (to: string, metadata: Record<str }, ], dynamic_template_data: { - poolName: metadata?.pool.name, issuerEmail: metadata.pool.issuer.email, + trancheName: tranche?.currency.name, }, }, ], diff --git a/onboarding-api/src/utils/envCheck.ts b/onboarding-api/src/utils/envCheck.ts index 2f3a112b8f..10c7d2e7d1 100644 --- a/onboarding-api/src/utils/envCheck.ts +++ b/onboarding-api/src/utils/envCheck.ts @@ -1,2 +1,2 @@ -const devEnvs = ['demo', 'development', 'catalyst'] +const devEnvs = ['algol', 'development', 'catalyst'] export const IS_DEV_ENV = devEnvs.some((env) => process.env.COLLATOR_WSS_URL.includes(env)) diff --git a/onboarding-api/src/utils/fetchUser.ts b/onboarding-api/src/utils/fetchUser.ts index 0ca7cc18ca..91de34c831 100644 --- a/onboarding-api/src/utils/fetchUser.ts +++ b/onboarding-api/src/utils/fetchUser.ts @@ -25,7 +25,7 @@ export async function fetchUser<T>(wallet: Request['wallet'], options?: OptionsO if (!userSnapshotOnOtherNetwork.empty) { const { user, id } = userSnapshotOnOtherNetwork.docs.map((doc) => ({ user: doc.data(), id: doc.id }))[0] await validateAndWriteToFirestore( - { address: id, network }, + { address: id, network, chainId: wallet.chainId }, { wallets: { ...user.wallets, From 8e832a50850cc550e8d8966b01c56f42e8e4d9fa Mon Sep 17 00:00:00 2001 From: Sophia <littlejohn.sophia@gmail.com> Date: Wed, 6 Sep 2023 09:58:32 -0400 Subject: [PATCH 19/39] Base capacity on most junior trnache instead of senior (#1562) --- centrifuge-app/src/pages/Pools.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/centrifuge-app/src/pages/Pools.tsx b/centrifuge-app/src/pages/Pools.tsx index 81ce05b5ab..c8e830cb3d 100644 --- a/centrifuge-app/src/pages/Pools.tsx +++ b/centrifuge-app/src/pages/Pools.tsx @@ -104,7 +104,7 @@ function poolsToPoolCardProps( status: tinlakePool && tinlakePool.addresses.CLERK !== undefined && tinlakePool.tinlakeMetadata.maker?.ilk ? 'Maker Pool' - : pool.tranches.at(-1)?.capacity.toFloat() + : pool.tranches.at(0)?.capacity.toFloat() // pool is displayed as "open for investments" if the most junior tranche has a capacity ? 'Open for investments' : ('Closed' as PoolStatusKey), iconUri: metaData?.pool?.icon?.uri ? cent.metadata.parseMetadataUrl(metaData?.pool?.icon?.uri) : undefined, From 3e24e579aaaec4e74f40e6ca7818f3558ddcbe49 Mon Sep 17 00:00:00 2001 From: Onno Visser <visser.onno@gmail.com> Date: Wed, 6 Sep 2023 16:05:04 +0200 Subject: [PATCH 20/39] Centrifuge App: Fix pod auth (#1565) --- centrifuge-app/src/utils/usePodAuth.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/centrifuge-app/src/utils/usePodAuth.ts b/centrifuge-app/src/utils/usePodAuth.ts index c9a52f6e9e..f47a41fcb2 100644 --- a/centrifuge-app/src/utils/usePodAuth.ts +++ b/centrifuge-app/src/utils/usePodAuth.ts @@ -8,7 +8,9 @@ import { usePodUrl } from './usePools' export function usePodAuth(poolId: string, accountOverride?: CombinedSubstrateAccount) { const { selectedCombinedAccount } = useWallet().substrate const podUrl = usePodUrl(poolId) - const suitableAccounts = useSuitableAccounts({ poolId, poolRole: ['Borrower'], proxyType: ['PodAuth'] }) + const suitableAccounts = useSuitableAccounts({ poolId, poolRole: ['Borrower'], proxyType: ['PodAuth'] }).filter( + (acc) => acc.proxies?.length === 1 + ) const account = accountOverride || selectedCombinedAccount || suitableAccounts[0] const cent = useCentrifuge() const utils = useCentrifugeUtils() From bc395452ee655520f27b58141ed2a75abb89a904 Mon Sep 17 00:00:00 2001 From: Onno Visser <visser.onno@gmail.com> Date: Wed, 6 Sep 2023 16:12:53 +0200 Subject: [PATCH 21/39] Centrifuge App: Add debug flag for pool create type (#1563) --- .../src/components/DebugFlags/config.ts | 11 +++++++++++ .../src/pages/IssuerCreatePool/index.tsx | 17 ++++++++++------- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/centrifuge-app/src/components/DebugFlags/config.ts b/centrifuge-app/src/components/DebugFlags/config.ts index c792086903..714a67508f 100644 --- a/centrifuge-app/src/components/DebugFlags/config.ts +++ b/centrifuge-app/src/components/DebugFlags/config.ts @@ -1,4 +1,5 @@ import React from 'react' +import { config } from '../../config' import { ConvertEvmAddress } from './components/ConvertEvmAddress' const params = new URLSearchParams(typeof window !== 'undefined' ? window.location.search : {}) @@ -46,6 +47,7 @@ export type Key = | 'showPodAccountCreation' | 'convertEvmAddress' | 'showPortfolio' + | 'poolCreationType' export const flagsConfig: Record<Key, DebugFlagConfig> = { address: { @@ -119,4 +121,13 @@ export const flagsConfig: Record<Key, DebugFlagConfig> = { default: false, alwaysShow: true, }, + poolCreationType: { + type: 'select', + default: config.poolCreationType || 'immediate', + options: { + immediate: 'immediate', + propose: 'propose', + notePreimage: 'notePreimage', + }, + }, } diff --git a/centrifuge-app/src/pages/IssuerCreatePool/index.tsx b/centrifuge-app/src/pages/IssuerCreatePool/index.tsx index 044203c25a..1a4bf95cdb 100644 --- a/centrifuge-app/src/pages/IssuerCreatePool/index.tsx +++ b/centrifuge-app/src/pages/IssuerCreatePool/index.tsx @@ -1,4 +1,4 @@ -import { CurrencyBalance, isSameAddress, Perquintill, Rate } from '@centrifuge/centrifuge-js' +import { CurrencyBalance, isSameAddress, Perquintill, Rate, TransactionOptions } from '@centrifuge/centrifuge-js' import { CurrencyKey, PoolMetadataInput, TrancheInput } from '@centrifuge/centrifuge-js/dist/modules/pools' import { useBalances, @@ -25,6 +25,7 @@ import { Field, FieldProps, Form, FormikErrors, FormikProvider, setIn, useFormik import * as React from 'react' import { useHistory } from 'react-router' import { combineLatest, lastValueFrom, switchMap, tap } from 'rxjs' +import { useDebugFlags } from '../../components/DebugFlags' import { PreimageHashDialog } from '../../components/Dialogs/PreimageHashDialog' import { ShareMultisigDialog } from '../../components/Dialogs/ShareMultisigDialog' import { FieldWithErrorMessage } from '../../components/FieldWithErrorMessage' @@ -154,6 +155,8 @@ function CreatePoolForm() { const [preimageHash, setPreimageHash] = React.useState('') const [createdPoolId, setCreatedPoolId] = React.useState('') const [multisigData, setMultisigData] = React.useState<{ hash: string; callData: string }>() + const { poolCreationType } = useDebugFlags() + const createType = (poolCreationType as TransactionOptions['createType']) || config.poolCreationType || 'immediate' React.useEffect(() => { // If the hash can't be found on Pinata the request can take a long time to time out @@ -182,7 +185,7 @@ function CreatePoolForm() { notePreimage: 'Note preimage', } const { execute: createPoolTx, isLoading: transactionIsPending } = useCentrifugeTransaction( - `${txMessage[config.poolCreationType || 'immediate']} 2/2`, + `${txMessage[createType]} 2/2`, (cent) => ( args: [ @@ -251,7 +254,7 @@ function CreatePoolForm() { onSuccess: (args) => { if (form.values.adminMultisigEnabled) setIsMultisigDialogOpen(true) const [, , , poolId] = args - if (config.poolCreationType === 'immediate') { + if (createType === 'immediate') { setCreatedPoolId(poolId) } }, @@ -259,7 +262,7 @@ function CreatePoolForm() { ) const { execute: createProxies, isLoading: createProxiesIsPending } = useCentrifugeTransaction( - `${txMessage[config.poolCreationType || 'immediate']} 1/2`, + `${txMessage[createType]} 1/2`, (cent) => { return (_: [nextTx: (adminProxy: string, aoProxy: string) => void], options) => cent.getApi().pipe( @@ -395,7 +398,7 @@ function CreatePoolForm() { CurrencyBalance.fromFloat(values.maxReserve, currency.decimals), metadataValues, ], - { createType: config.poolCreationType } + { createType } ) }, ]) @@ -420,7 +423,7 @@ function CreatePoolForm() { }, [isStoredIssuerLoading]) React.useEffect(() => { - if (config.poolCreationType === 'notePreimage') { + if (createType === 'notePreimage') { const $events = centrifuge .getEvents() .pipe( @@ -436,7 +439,7 @@ function CreatePoolForm() { .subscribe() return () => $events.unsubscribe() } - }, [centrifuge]) + }, [centrifuge, createType]) const formRef = React.useRef<HTMLFormElement>(null) useFocusInvalidInput(form, formRef) From 2f6f48176ab41b7e7b741581c2198e03ace116c0 Mon Sep 17 00:00:00 2001 From: Sophia <littlejohn.sophia@gmail.com> Date: Wed, 6 Sep 2023 15:13:32 -0400 Subject: [PATCH 22/39] Onboarding: Rename connectors pallet to liquidityPools (prep for chain upgrade) (#1555) * Rename connectors to liquidity pools * Remove logs --- centrifuge-app/src/components/PoolFilter/utils.ts | 1 - centrifuge-app/src/pages/Pools.tsx | 5 +---- onboarding-api/src/utils/networks/centrifuge.ts | 2 +- 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/centrifuge-app/src/components/PoolFilter/utils.ts b/centrifuge-app/src/components/PoolFilter/utils.ts index c867fbfd90..ef40b4feda 100644 --- a/centrifuge-app/src/components/PoolFilter/utils.ts +++ b/centrifuge-app/src/components/PoolFilter/utils.ts @@ -16,7 +16,6 @@ export function filterPools(pools: PoolCardProps[], searchParams: URLSearchParam filtered = filtered.filter( (pool) => pool.status && (poolStatuses.size ? poolStatuses.has(toKebabCase(pool.status)) : pool.status !== 'Closed') ) - console.log('filtered', filtered) if (assetClasses.size) { filtered = filtered.filter((pool) => pool.assetClass && assetClasses.has(toKebabCase(pool.assetClass))) diff --git a/centrifuge-app/src/pages/Pools.tsx b/centrifuge-app/src/pages/Pools.tsx index c8e830cb3d..00c1445c7a 100644 --- a/centrifuge-app/src/pages/Pools.tsx +++ b/centrifuge-app/src/pages/Pools.tsx @@ -31,8 +31,7 @@ export function PoolsPage() { function Pools() { const cent = useCentrifuge() const { search } = useLocation() - const [listedPools, listedTokens, metadataIsLoading] = useListedPools() - console.log('listedPools', listedPools) + const [listedPools, _listedTokens, metadataIsLoading] = useListedPools() const centPools = listedPools.filter(({ id }) => !id.startsWith('0x')) as Pool[] const centPoolsMetaData: PoolMetaDataPartial[] = useMetadataMulti<PoolMetadata>( @@ -41,9 +40,7 @@ function Pools() { const centPoolsMetaDataById = getMetasById(centPools, centPoolsMetaData) const pools = !!listedPools?.length ? poolsToPoolCardProps(listedPools, centPoolsMetaDataById, cent) : [] - console.log('pools', pools) const filteredPools = !!pools?.length ? filterPools(pools, new URLSearchParams(search)) : [] - console.log('flfitleirlerpOPopolp', filteredPools) if (!listedPools.length) { return ( diff --git a/onboarding-api/src/utils/networks/centrifuge.ts b/onboarding-api/src/utils/networks/centrifuge.ts index 7c7d31ff49..f5d31d914b 100644 --- a/onboarding-api/src/utils/networks/centrifuge.ts +++ b/onboarding-api/src/utils/networks/centrifuge.ts @@ -76,7 +76,7 @@ export const addCentInvestorToMemberList = async (wallet: Request['wallet'], poo } // add investor to liquidity pools if they are investing on any domain other than centrifuge if (wallet.network === 'evm') { - const updateMemberSubmittable = api.tx.connectors.updateMember( + const updateMemberSubmittable = api.tx.liquidityPools.updateMember( poolId, trancheId, { From 88eae037c11375cc6e717b0d5e68c65c8d8c9b4c Mon Sep 17 00:00:00 2001 From: Onno Visser <visser.onno@gmail.com> Date: Wed, 6 Sep 2023 23:45:28 +0200 Subject: [PATCH 23/39] Centrifuge App: Only show pools with permissions in Issuer menu (#1564) --- .../src/components/Menu-deprecated/index.tsx | 7 +- .../src/components/Menu/IssuerMenu.tsx | 5 +- centrifuge-app/src/components/Menu/index.tsx | 18 ++- centrifuge-app/src/utils/usePermissions.ts | 58 ++++---- centrifuge-js/src/modules/pools.ts | 131 +++++++++++------- centrifuge-js/src/utils/index.ts | 2 +- .../WalletProvider/WalletProvider.tsx | 6 +- .../src/hooks/useCentrifugeQueries.ts | 75 ---------- centrifuge-react/src/index.ts | 1 - 9 files changed, 130 insertions(+), 173 deletions(-) delete mode 100644 centrifuge-react/src/hooks/useCentrifugeQueries.ts diff --git a/centrifuge-app/src/components/Menu-deprecated/index.tsx b/centrifuge-app/src/components/Menu-deprecated/index.tsx index 83e5d3d7e1..6723461169 100644 --- a/centrifuge-app/src/components/Menu-deprecated/index.tsx +++ b/centrifuge-app/src/components/Menu-deprecated/index.tsx @@ -2,7 +2,7 @@ import { Box, IconInvestments, IconNft, Menu as Panel, MenuItemGroup, Shelf, Sta import { config } from '../../config' import { useAddress } from '../../utils/useAddress' import { useIsAboveBreakpoint } from '../../utils/useIsAboveBreakpoint' -import { usePools } from '../../utils/usePools' +import { usePoolsThatAnyConnectedAddressHasPermissionsFor } from '../../utils/usePermissions' import { RouterLinkButton } from '../RouterLinkButton' import { GovernanceMenu } from './GovernanceMenu' import { IssuerMenu } from './IssuerMenu' @@ -10,8 +10,7 @@ import { PageLink } from './PageLink' import { PoolLink } from './PoolLink' export function Menu() { - // const pools = usePoolsThatAnyConnectedAddressHasPermissionsFor() || [] - const pools = usePools() || [] + const pools = usePoolsThatAnyConnectedAddressHasPermissionsFor() || [] const isXLarge = useIsAboveBreakpoint('XL') const address = useAddress('substrate') @@ -38,7 +37,7 @@ export function Menu() { <GovernanceMenu /> {(pools.length > 0 || config.poolCreationType === 'immediate') && ( - <IssuerMenu defaultOpen={isXLarge} stacked={!isXLarge} poolIds={pools.map(({ id }) => id)}> + <IssuerMenu defaultOpen={isXLarge} stacked={!isXLarge}> {isXLarge ? ( <Stack as="ul" gap={1}> {!!pools.length && diff --git a/centrifuge-app/src/components/Menu/IssuerMenu.tsx b/centrifuge-app/src/components/Menu/IssuerMenu.tsx index 58ed994907..85cc76b7bc 100644 --- a/centrifuge-app/src/components/Menu/IssuerMenu.tsx +++ b/centrifuge-app/src/components/Menu/IssuerMenu.tsx @@ -6,14 +6,13 @@ import { Toggle } from './Toggle' type IssuerMenuProps = { defaultOpen?: boolean - poolIds?: string[] stacked?: boolean children?: React.ReactNode } -export function IssuerMenu({ defaultOpen = false, poolIds = [], stacked, children }: IssuerMenuProps) { +export function IssuerMenu({ defaultOpen = false, stacked, children }: IssuerMenuProps) { const match = useRouteMatch<{ pid: string }>('/issuer/:pid') - const isActive = match && poolIds.includes(match.params.pid) + const isActive = !!match const [open, setOpen] = React.useState(defaultOpen) const { space } = useTheme() const fullWidth = `calc(100vw - 2 * ${space[1]}px)` diff --git a/centrifuge-app/src/components/Menu/index.tsx b/centrifuge-app/src/components/Menu/index.tsx index e8479b6928..cf9e1e4d79 100644 --- a/centrifuge-app/src/components/Menu/index.tsx +++ b/centrifuge-app/src/components/Menu/index.tsx @@ -2,7 +2,7 @@ import { Box, IconInvestments, IconNft, Menu as Panel, MenuItemGroup, Shelf, Sta import { config } from '../../config' import { useAddress } from '../../utils/useAddress' import { useIsAboveBreakpoint } from '../../utils/useIsAboveBreakpoint' -import { usePools } from '../../utils/usePools' +import { usePoolsThatAnyConnectedAddressHasPermissionsFor } from '../../utils/usePermissions' import { RouterLinkButton } from '../RouterLinkButton' import { GovernanceMenu } from './GovernanceMenu' import { IssuerMenu } from './IssuerMenu' @@ -10,8 +10,7 @@ import { PageLink } from './PageLink' import { PoolLink } from './PoolLink' export function Menu() { - // const pools = usePoolsThatAnyConnectedAddressHasPermissionsFor() || [] - const pools = usePools() || [] + const pools = usePoolsThatAnyConnectedAddressHasPermissionsFor() || [] const isLarge = useIsAboveBreakpoint('L') const address = useAddress('substrate') @@ -38,15 +37,14 @@ export function Menu() { <GovernanceMenu /> {(pools.length > 0 || config.poolCreationType === 'immediate') && ( - <IssuerMenu defaultOpen={isLarge} stacked={!isLarge} poolIds={pools.map(({ id }) => id)}> + <IssuerMenu defaultOpen={isLarge} stacked={!isLarge}> {isLarge ? ( <Stack as="ul" gap={1}> - {!!pools.length && - pools.map((pool) => ( - <Box key={pool.id} as="li" pl={4}> - <PoolLink pool={pool} /> - </Box> - ))} + {pools.map((pool) => ( + <Box key={pool.id} as="li" pl={4}> + <PoolLink pool={pool} /> + </Box> + ))} {address && config.poolCreationType === 'immediate' && ( <Shelf justifyContent="center" as="li" mt={1}> <CreatePool /> diff --git a/centrifuge-app/src/utils/usePermissions.ts b/centrifuge-app/src/utils/usePermissions.ts index 9783859ef2..2fbf758210 100644 --- a/centrifuge-app/src/utils/usePermissions.ts +++ b/centrifuge-app/src/utils/usePermissions.ts @@ -12,7 +12,7 @@ import { combineLatest, filter, map, repeatWhen, switchMap } from 'rxjs' import { diffPermissions } from '../pages/IssuerPool/Configuration/Admins' import { useCollections } from './useCollections' import { useLoan } from './useLoans' -import { usePool, usePoolMetadata } from './usePools' +import { usePool, usePoolMetadata, usePools } from './usePools' export function usePermissions(address?: string) { const [result] = useCentrifugeQuery(['permissions', address], (cent) => cent.pools.getUserPermissions([address!]), { @@ -29,38 +29,38 @@ export function usePoolPermissions(poolId?: string) { return result } -// export function useUserPermissionsMulti(addresses: string[]) { -// const [results] = useCentrifugeQueries( -// addresses.map((address) => ({ -// queryKey: ['permissions', address], -// queryCallback: (cent) => cent.pools.getUserPermissions([address!]), -// })) -// ) - -// return results -// } +export function useUserPermissionsMulti(addresses: string[], options?: { enabled?: boolean }) { + const [result] = useCentrifugeQuery( + ['permissions', ...addresses], + (cent) => cent.pools.getUserPermissions([addresses]), + { + enabled: !!addresses.length && options?.enabled !== false, + } + ) + return result +} -// // Better name welcomed lol -// export function usePoolsThatAnyConnectedAddressHasPermissionsFor() { -// const { -// substrate: { combinedAccounts }, -// } = useWallet() -// const actingAddresses = [...new Set(combinedAccounts?.map((acc) => acc.actingAddress))] -// const permissionResults = useUserPermissionsMulti(actingAddresses) +// Better name welcomed lol +export function usePoolsThatAnyConnectedAddressHasPermissionsFor() { + const { + substrate: { combinedAccounts, proxiesAreLoading }, + } = useWallet() + const actingAddresses = [...new Set(combinedAccounts?.map((acc) => acc.actingAddress))] + const permissionsResult = useUserPermissionsMulti(actingAddresses, { enabled: !proxiesAreLoading }) -// const poolIds = new Set( -// permissionResults -// .map((permissions) => -// Object.entries(permissions?.pools || {}).map(([poolId, roles]) => (roles.roles.length ? poolId : [])) -// ) -// .flat(2) -// ) + const poolIds = new Set( + permissionsResult + ?.map((permissions) => + Object.entries(permissions?.pools || {}).map(([poolId, roles]) => (roles.roles.length ? poolId : [])) + ) + .flat(2) + ) -// const pools = usePools(false) -// const filtered = pools?.filter((p) => poolIds.has(p.id)) + const pools = usePools(false) + const filtered = pools?.filter((p) => poolIds.has(p.id)) -// return filtered -// } + return filtered +} // Returns whether the connected address can borrow from a pool in principle export function useCanBorrow(poolId: string) { diff --git a/centrifuge-js/src/modules/pools.ts b/centrifuge-js/src/modules/pools.ts index 5b2bb11352..661086e9a7 100644 --- a/centrifuge-js/src/modules/pools.ts +++ b/centrifuge-js/src/modules/pools.ts @@ -681,6 +681,18 @@ type BorrowerTransaction = { amount: CurrencyBalance | undefined } +export type Permissions = { + pools: { + [poolId: string]: PoolRoles + } + currencies: { + [currency: string]: { + roles: CurrencyRole[] + holder: boolean + } + } +} + const formatPoolKey = (keys: StorageKey<[u32]>) => (keys.toHuman() as string[])[0].replace(/\D/g, '') const formatLoanKey = (keys: StorageKey<[u32, u32]>) => (keys.toHuman() as string[])[1].replace(/\D/g, '') @@ -1124,8 +1136,14 @@ export function getPoolsModule(inst: Centrifuge) { ) } - function getUserPermissions(args: [address: Account]) { - const [address] = args + function getUserPermissions<T extends Account | Account[]>( + args: [address: T] + ): T extends Array<Account> ? Observable<Permissions[]> : Observable<Permissions> { + const [maybeArray] = args + const addresses = (Array.isArray(maybeArray) ? (maybeArray as Account[]) : [maybeArray as Account]).map( + (addr) => addressToHex(addr) as string + ) + const addressSet = new Set(addresses) const $api = inst.getApi() const $events = inst.getEvents().pipe( @@ -1135,59 +1153,76 @@ export function getPoolsModule(inst: Centrifuge) { ) if (!event) return false - const [accountId] = (event.toJSON() as any).event.data - return isSameAddress(address, accountId) + const [accountId] = (event.toHuman() as any).event.data + return addressSet.has(addressToHex(accountId)) }) ) return $api.pipe( - switchMap((api) => api.query.permissions.permission.entries(address)), - map((permissionsData) => { - const roles: { - pools: { - [poolId: string]: PoolRoles - } - currencies: { - [currency: string]: { - roles: CurrencyRole[] - holder: boolean - } - } - } = { - pools: {}, - currencies: {}, - } - - permissionsData.forEach(([keys, value]) => { - const key = (keys.toHuman() as any)[1] as { Pool: string } | { Currency: any } - if ('Pool' in key) { - const poolId = key.Pool.replace(/\D/g, '') - const permissions = value.toJSON() as any - roles.pools[poolId] = { - roles: ( - [ - 'PoolAdmin', - 'Borrower', - 'PricingAdmin', - 'LiquidityAdmin', - 'InvestorAdmin', - 'LoanAdmin', - 'PODReadAccess', + switchMap((api) => + api.query.permissions.permission.keys().pipe( + switchMap((keys) => { + const userKeys = keys + .map((key) => { + const [account, scope] = key.toHuman() as any as [string, { Pool: string } | { Currency: any }] + return [ + addressToHex(account), + 'Pool' in scope ? { Pool: scope.Pool.replace(/\D/g, '') } : scope, ] as const - ).filter((role) => AdminRoleBits[role] & permissions.poolAdmin.bits), - tranches: {}, - } - permissions.trancheInvestor.info - .filter((info: any) => info.permissionedTill * 1000 > Date.now()) - .forEach((info: any) => { - roles.pools[poolId].tranches[info.trancheId] = new Date(info.permissionedTill * 1000).toISOString() }) - } - }) - return roles - }), + .filter(([account, scope]) => { + return 'Pool' in scope && addressSet.has(account) + }) + return api.query.permissions.permission.multi(userKeys).pipe( + map((permissionsData) => { + const permissionsByAddressIndex: Permissions[] = [] + + function setPoolRoles(user: string, poolId: string, roles: PoolRoles) { + const i = addresses.indexOf(user) + const obj = permissionsByAddressIndex[i] ?? { + pools: {}, + currencies: {}, + } + obj.pools[poolId] = roles + permissionsByAddressIndex[i] = obj + } + permissionsData.forEach((value, i) => { + const [account, scope] = userKeys[i] + if ('Pool' in scope) { + const poolId = scope.Pool.replace(/\D/g, '') + const permissions = value.toJSON() as any + const roles: PoolRoles = { + roles: ( + [ + 'PoolAdmin', + 'Borrower', + 'PricingAdmin', + 'LiquidityAdmin', + 'InvestorAdmin', + 'LoanAdmin', + 'PODReadAccess', + ] as const + ).filter((role) => AdminRoleBits[role] & permissions.poolAdmin.bits), + tranches: {}, + } + permissions.trancheInvestor.info + .filter((info: any) => info.permissionedTill * 1000 > Date.now()) + .forEach((info: any) => { + roles.tranches[info.trancheId] = new Date(info.permissionedTill * 1000).toISOString() + }) + + setPoolRoles(account, poolId, roles) + } + }) + return Array.isArray(maybeArray) ? permissionsByAddressIndex : permissionsByAddressIndex[0] + }) + ) + }) + ) + ), + repeatWhen(() => $events) - ) + ) as any } function getPoolPermissions(args: [poolId: string]) { diff --git a/centrifuge-js/src/utils/index.ts b/centrifuge-js/src/utils/index.ts index 8b5f3d027c..2d826349a2 100644 --- a/centrifuge-js/src/utils/index.ts +++ b/centrifuge-js/src/utils/index.ts @@ -114,7 +114,7 @@ export function getDateMonthsFromNow(month: number) { return new Date(date.setMonth(date.getMonth() + month)) } -export function addressToHex(addr: string) { +export function addressToHex(addr: string | Uint8Array) { return u8aToHex(decodeAddress(addr)) } diff --git a/centrifuge-react/src/components/WalletProvider/WalletProvider.tsx b/centrifuge-react/src/components/WalletProvider/WalletProvider.tsx index b6c8376f51..4097aa261c 100644 --- a/centrifuge-react/src/components/WalletProvider/WalletProvider.tsx +++ b/centrifuge-react/src/components/WalletProvider/WalletProvider.tsx @@ -41,6 +41,7 @@ export type WalletContextType = { evmChainId?: number accounts: SubstrateAccount[] | null proxies: Record<string, Proxy[]> | undefined + proxiesAreLoading: boolean multisigs: ComputedMultisig[] combinedAccounts: CombinedSubstrateAccount[] | null selectedAccount: SubstrateAccount | null @@ -210,7 +211,7 @@ export function WalletProvider({ wallet: state.evm.selectedWallet as any, })) : null - const { data: proxies } = useQuery( + const { data: proxies, isLoading: proxiesAreLoading } = useQuery( [ 'proxies', state.substrate.accounts?.map((acc) => acc.address), @@ -232,7 +233,7 @@ export function WalletProvider({ ) const delegatees = [...new Set(Object.values(proxies ?? {})?.flatMap((p) => p.map((d) => d.delegator)))] - const { data: nestedProxies } = useQuery( + const { data: nestedProxies, isLoading: nestedProxiesAreLoading } = useQuery( ['nestedProxies', delegatees], () => firstValueFrom(cent.proxies.getMultiUserProxies([delegatees])), { @@ -454,6 +455,7 @@ export function WalletProvider({ selectedProxies: selectedCombinedAccount?.proxies || null, selectedMultisig: selectedCombinedAccount?.multisig || null, proxies: combinedProxies, + proxiesAreLoading: nestedProxiesAreLoading || proxiesAreLoading, subscanUrl, }, evm: { diff --git a/centrifuge-react/src/hooks/useCentrifugeQueries.ts b/centrifuge-react/src/hooks/useCentrifugeQueries.ts deleted file mode 100644 index 59f5612e1d..0000000000 --- a/centrifuge-react/src/hooks/useCentrifugeQueries.ts +++ /dev/null @@ -1,75 +0,0 @@ -import Centrifuge from '@centrifuge/centrifuge-js' -import * as React from 'react' -import { useQueries, useQueryClient } from 'react-query' -import { firstValueFrom, Observable } from 'rxjs' -import { useCentrifuge } from '../components/CentrifugeProvider' -import { useCentrifugeKey } from '../components/CentrifugeProvider/CentrifugeProvider' -import { CentrifugeQueryOptions, getQuerySource } from './useCentrifugeQuery' - -type MultiQueryOptions<T> = ({ - queryKey: readonly unknown[] - queryCallback: (cent: Centrifuge) => Observable<T> -} & CentrifugeQueryOptions)[] - -// TODO: Fix infinite loop when receiving new data sometimes -export function useCentrifugeQueries<T>( - queries: readonly [...MultiQueryOptions<T>] -): readonly [(T | null | undefined)[], (Observable<T | null> | undefined)[]] { - const cent = useCentrifuge() - const centKey = useCentrifugeKey() - const queryClient = useQueryClient() - - // Using react-query to cache the observables to ensure that all consumers subscribe to the same multicasted observable - const sourceResults = useQueries( - queries.map((query) => { - const { queryKey, queryCallback, ...options } = query - const { suspense, enabled = true } = options || {} - return { - queryKey: ['querySource', centKey, ...queryKey], - queryFn: () => getQuerySource(cent, queryKey, queryCallback, options), - suspense, - staleTime: Infinity, - enabled, - } - }) - ) - - const dataResults = useQueries( - queries.map((query, i) => { - const { queryKey, queryCallback, ...options } = query - const { suspense, enabled = true } = options || {} - const $source = sourceResults[i].data - return { - queryKey: ['queryData', centKey, ...queryKey, !!$source], - queryFn: () => ($source ? firstValueFrom($source) : null), - suspense, - // Infinite staleTime as useQueries here is only used to populate the cache initially and - // to handle suspending the component when the suspense option is enabled. - // Further data is subscribed to, and added to the cache, after the component has mounted. - staleTime: Infinity, - enabled: $source && enabled, - retry: false, - } - }) - ) - - React.useEffect(() => { - const subs = sourceResults.map((r, i) => - r.data?.subscribe({ - next: (data) => { - if (data) { - const cached = queryClient.getQueryData<T>(['queryData', centKey, ...queries[i].queryKey, true]) - if (cached !== data) { - queryClient.setQueryData<T>(['queryData', centKey, ...queries[i].queryKey, true], data) - } - } - }, - }) - ) - return () => { - subs.forEach((sub) => sub?.unsubscribe()) - } - }, [sourceResults]) - - return [dataResults.map((r) => r.data), sourceResults.map((r) => r.data)] as const -} diff --git a/centrifuge-react/src/index.ts b/centrifuge-react/src/index.ts index 0dc9c137b2..668d1a01ee 100644 --- a/centrifuge-react/src/index.ts +++ b/centrifuge-react/src/index.ts @@ -6,7 +6,6 @@ export * from './components/WalletMenu' export * from './components/WalletProvider' export { useAsyncCallback } from './hooks/useAsyncCallback' export { useBalances } from './hooks/useBalances' -export * from './hooks/useCentrifugeQueries' export * from './hooks/useCentrifugeQuery' export { useCentrifugeTransaction } from './hooks/useCentrifugeTransaction' export { useEns } from './hooks/useEns' From 46055002ee1498ca49bd17366c3b0e8ecebbbd4f Mon Sep 17 00:00:00 2001 From: Sophia <littlejohn.sophia@gmail.com> Date: Thu, 7 Sep 2023 09:22:26 -0400 Subject: [PATCH 24/39] Update pure proxy and add write off groups (#1566) --- centrifuge-app/.env-config/.env.development | 2 +- centrifuge-js/src/modules/pools.ts | 3 ++- onboarding-api/env-vars/development.env | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/centrifuge-app/.env-config/.env.development b/centrifuge-app/.env-config/.env.development index a1a9a0e836..32875c8155 100644 --- a/centrifuge-app/.env-config/.env.development +++ b/centrifuge-app/.env-config/.env.development @@ -17,4 +17,4 @@ REACT_APP_WHITELISTED_ACCOUNTS= REACT_APP_TINLAKE_SUBGRAPH_URL=https://graph.centrifuge.io/tinlake REACT_APP_REWARDS_TREE_URL=https://storage.googleapis.com/rad-rewards-trees-kovan-staging/latest.json REACT_APP_WALLETCONNECT_ID=c32fa79350803519804a67fcab0b742a -REACT_APP_MEMBERLIST_ADMIN_PURE_PROXY=kAKfp33p1SHRq6d1BMtGndP7Cek6pH6oZKKUoA7wJXRUqf6FY +REACT_APP_MEMBERLIST_ADMIN_PURE_PROXY=kAJ27w29x7gHM75xajP2yXVLjVBaKmmUTxHwgRuCoAcWaoEiz diff --git a/centrifuge-js/src/modules/pools.ts b/centrifuge-js/src/modules/pools.ts index 661086e9a7..99e1be6718 100644 --- a/centrifuge-js/src/modules/pools.ts +++ b/centrifuge-js/src/modules/pools.ts @@ -742,7 +742,8 @@ export function getPoolsModule(inst: Centrifuge) { trancheInput, currency, maxReserve.toString(), - pinnedMetadata.ipfsHash + pinnedMetadata.ipfsHash, + [] ) if (options?.createType === 'propose') { const proposalTx = api.tx.utility.batchAll([ diff --git a/onboarding-api/env-vars/development.env b/onboarding-api/env-vars/development.env index 3bb080d8a0..747e8d40ca 100644 --- a/onboarding-api/env-vars/development.env +++ b/onboarding-api/env-vars/development.env @@ -1,5 +1,5 @@ REDIRECT_URL=https://app-dev.k-f.dev -MEMBERLIST_ADMIN_PURE_PROXY=kAKfp33p1SHRq6d1BMtGndP7Cek6pH6oZKKUoA7wJXRUqf6FY +MEMBERLIST_ADMIN_PURE_PROXY=kAJ27w29x7gHM75xajP2yXVLjVBaKmmUTxHwgRuCoAcWaoEiz COLLATOR_WSS_URL=wss://fullnode.development.cntrfg.com RELAY_WSS_URL=wss://fullnode-relay.development.cntrfg.com INFURA_KEY=bf808e7d3d924fbeb74672d9341d0550 From b1f0ec471506cf1c2121ce8d77db7c5bf4dad8b2 Mon Sep 17 00:00:00 2001 From: Onno Visser <visser.onno@gmail.com> Date: Thu, 7 Sep 2023 17:37:18 +0200 Subject: [PATCH 25/39] CentrifugeJS: Change Price decimals (#1567) --- centrifuge-app/src/utils/tinlake/useTinlakePools.ts | 2 +- centrifuge-js/src/modules/pools.ts | 2 +- centrifuge-js/src/utils/BN.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/centrifuge-app/src/utils/tinlake/useTinlakePools.ts b/centrifuge-app/src/utils/tinlake/useTinlakePools.ts index 86b68334e5..a79d6a64bf 100644 --- a/centrifuge-app/src/utils/tinlake/useTinlakePools.ts +++ b/centrifuge-app/src/utils/tinlake/useTinlakePools.ts @@ -403,7 +403,7 @@ async function getPools(pools: IpfsPools): Promise<{ pools: TinlakePool[] }> { const toCurrencyBalance = (val: BigNumber) => new CurrencyBalance(val.toString(), 18) const toTokenBalance = (val: BigNumber) => new TokenBalance(val.toString(), 18) const toRate = (val: BigNumber) => new Rate(val.toString()) - const toPrice = (val: BigNumber) => new Price(val.toString()) + const toPrice = (val: BigNumber) => new Rate(val.toString()) const calls: Call[] = [] pools.active.forEach((pool) => { diff --git a/centrifuge-js/src/modules/pools.ts b/centrifuge-js/src/modules/pools.ts index 99e1be6718..a06e58b82e 100644 --- a/centrifuge-js/src/modules/pools.ts +++ b/centrifuge-js/src/modules/pools.ts @@ -26,7 +26,7 @@ import { CurrencyBalance, Perquintill, Price, Rate, TokenBalance } from '../util import { Dec } from '../utils/Decimal' const PerquintillBN = new BN(10).pow(new BN(18)) -const PriceBN = new BN(10).pow(new BN(27)) +const PriceBN = new BN(10).pow(new BN(18)) const MaxU128 = '340282366920938463463374607431768211455' const SEC_PER_DAY = 24 * 60 * 60 diff --git a/centrifuge-js/src/utils/BN.ts b/centrifuge-js/src/utils/BN.ts index ba6612a650..8f915234c4 100644 --- a/centrifuge-js/src/utils/BN.ts +++ b/centrifuge-js/src/utils/BN.ts @@ -55,7 +55,7 @@ export class TokenBalance extends CurrencyBalance { } export class Price extends BNSubType { - static decimals = 27 + static decimals = 18 static fromFloat(number: Numeric) { return Price._fromFloat<Price>(number) } From 82fa09da8c5d858276f48dd79055d93ec5b347d2 Mon Sep 17 00:00:00 2001 From: Sophia <littlejohn.sophia@gmail.com> Date: Thu, 7 Sep 2023 14:20:46 -0400 Subject: [PATCH 26/39] Point pinata to centrifuge gateway (#1570) --- centrifuge-app/.env-config/.env.altair | 2 +- centrifuge-app/.env-config/.env.catalyst | 2 +- centrifuge-app/.env-config/.env.demo | 2 +- centrifuge-app/.env-config/.env.development | 2 +- centrifuge-app/.env-config/.env.example | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/centrifuge-app/.env-config/.env.altair b/centrifuge-app/.env-config/.env.altair index af289426e9..aa97b424d6 100644 --- a/centrifuge-app/.env-config/.env.altair +++ b/centrifuge-app/.env-config/.env.altair @@ -2,7 +2,7 @@ REACT_APP_COLLATOR_WSS_URL=wss://fullnode.altair.centrifuge.io REACT_APP_DEFAULT_NODE_URL= REACT_APP_DEFAULT_UNLIST_POOLS=false REACT_APP_FAUCET_URL='' -REACT_APP_IPFS_GATEWAY=https://altair.mypinata.cloud/ +REACT_APP_IPFS_GATEWAY=https://centrifuge.mypinata.cloud/ REACT_APP_IS_DEMO=false REACT_APP_NETWORK=altair REACT_APP_ONBOARDING_API_URL=https://europe-central2-centrifuge-production-x.cloudfunctions.net/onboarding-api-altair diff --git a/centrifuge-app/.env-config/.env.catalyst b/centrifuge-app/.env-config/.env.catalyst index aa45a5272c..ef208e5934 100644 --- a/centrifuge-app/.env-config/.env.catalyst +++ b/centrifuge-app/.env-config/.env.catalyst @@ -2,7 +2,7 @@ REACT_APP_COLLATOR_WSS_URL=wss://fullnode.catalyst.cntrfg.com REACT_APP_DEFAULT_NODE_URL= REACT_APP_DEFAULT_UNLIST_POOLS=true REACT_APP_FAUCET_URL= -REACT_APP_IPFS_GATEWAY=https://altair.mypinata.cloud/ +REACT_APP_IPFS_GATEWAY=https://centrifuge.mypinata.cloud/ REACT_APP_IS_DEMO=false REACT_APP_NETWORK=centrifuge REACT_APP_ONBOARDING_API_URL=https://europe-central2-peak-vista-185616.cloudfunctions.net/onboarding-api-catalyst diff --git a/centrifuge-app/.env-config/.env.demo b/centrifuge-app/.env-config/.env.demo index db85519d73..b655634f8a 100644 --- a/centrifuge-app/.env-config/.env.demo +++ b/centrifuge-app/.env-config/.env.demo @@ -2,7 +2,7 @@ REACT_APP_COLLATOR_WSS_URL=wss://fullnode.algol.cntrfg.com/public-ws REACT_APP_DEFAULT_NODE_URL=https://pod.algol.k-f.dev REACT_APP_DEFAULT_UNLIST_POOLS=true REACT_APP_FAUCET_URL=https://europe-central2-peak-vista-185616.cloudfunctions.net/faucet-api-demo -REACT_APP_IPFS_GATEWAY=https://altair.mypinata.cloud/ +REACT_APP_IPFS_GATEWAY=https://centrifuge.mypinata.cloud/ REACT_APP_IS_DEMO=true REACT_APP_ONBOARDING_API_URL=https://europe-central2-peak-vista-185616.cloudfunctions.net/onboarding-api-demo REACT_APP_PINNING_API_URL=https://europe-central2-peak-vista-185616.cloudfunctions.net/pinning-api-demo diff --git a/centrifuge-app/.env-config/.env.development b/centrifuge-app/.env-config/.env.development index 32875c8155..504841d09b 100644 --- a/centrifuge-app/.env-config/.env.development +++ b/centrifuge-app/.env-config/.env.development @@ -2,7 +2,7 @@ REACT_APP_COLLATOR_WSS_URL=wss://fullnode.development.cntrfg.com REACT_APP_DEFAULT_NODE_URL=https://pod-development.k-f.dev REACT_APP_DEFAULT_UNLIST_POOLS=false REACT_APP_FAUCET_URL=https://europe-central2-peak-vista-185616.cloudfunctions.net/faucet-api-dev -REACT_APP_IPFS_GATEWAY=https://altair.mypinata.cloud/ +REACT_APP_IPFS_GATEWAY=https://centrifuge.mypinata.cloud/ REACT_APP_IS_DEMO=false REACT_APP_NETWORK=centrifuge REACT_APP_ONBOARDING_API_URL=https://europe-central2-peak-vista-185616.cloudfunctions.net/onboarding-api-dev diff --git a/centrifuge-app/.env-config/.env.example b/centrifuge-app/.env-config/.env.example index a6caf14422..6429fbfcc4 100644 --- a/centrifuge-app/.env-config/.env.example +++ b/centrifuge-app/.env-config/.env.example @@ -2,7 +2,7 @@ REACT_APP_COLLATOR_WSS_URL=wss://fullnode.development.cntrfg.com REACT_APP_DEFAULT_NODE_URL=https://pod.development.cntrfg.com REACT_APP_DEFAULT_UNLIST_POOLS= REACT_APP_FAUCET_URL=https://europe-central2-peak-vista-185616.cloudfunctions.net/faucetDev -REACT_APP_IPFS_GATEWAY=https://altair.mypinata.cloud/ +REACT_APP_IPFS_GATEWAY=https://centrifuge.mypinata.cloud/ REACT_APP_IS_DEMO=false REACT_APP_NETWORK=altair REACT_APP_ONBOARDING_API_URL=https://europe-central2-centrifuge-fargate-apps-dev.cloudfunctions.net/onboarding From 82ddcd831ffc2d1e5e57fba11c7f6c4567519cf9 Mon Sep 17 00:00:00 2001 From: Onno Visser <visser.onno@gmail.com> Date: Fri, 8 Sep 2023 16:45:55 +0200 Subject: [PATCH 27/39] CentrifugeJS: Return empty permissions (#1571) --- centrifuge-js/src/modules/pools.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/centrifuge-js/src/modules/pools.ts b/centrifuge-js/src/modules/pools.ts index a06e58b82e..5f9334f2c0 100644 --- a/centrifuge-js/src/modules/pools.ts +++ b/centrifuge-js/src/modules/pools.ts @@ -1215,7 +1215,12 @@ export function getPoolsModule(inst: Centrifuge) { setPoolRoles(account, poolId, roles) } }) - return Array.isArray(maybeArray) ? permissionsByAddressIndex : permissionsByAddressIndex[0] + return Array.isArray(maybeArray) + ? permissionsByAddressIndex + : permissionsByAddressIndex[0] ?? { + pools: {}, + currencies: {}, + } }) ) }) From f89ccc0e2ab8dc6690b3ac7bab991d33e77fe461 Mon Sep 17 00:00:00 2001 From: Onno Visser <visser.onno@gmail.com> Date: Mon, 11 Sep 2023 20:27:06 +0200 Subject: [PATCH 28/39] Centrifuge App: Fix token price formatting (#1573) --- centrifuge-app/src/utils/formatting.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/centrifuge-app/src/utils/formatting.ts b/centrifuge-app/src/utils/formatting.ts index f8221c0b3b..7b0d4b4ffe 100644 --- a/centrifuge-app/src/utils/formatting.ts +++ b/centrifuge-app/src/utils/formatting.ts @@ -1,14 +1,17 @@ -import { CurrencyBalance, CurrencyMetadata, Perquintill, Price, TokenBalance } from '@centrifuge/centrifuge-js' +import { CurrencyBalance, CurrencyMetadata, Perquintill, Price, Rate, TokenBalance } from '@centrifuge/centrifuge-js' import Decimal from 'decimal.js-light' export function formatBalance( - amount: CurrencyBalance | TokenBalance | Price | Decimal | number, + amount: CurrencyBalance | TokenBalance | Price | Rate | Decimal | number, currency?: string | CurrencyMetadata, precision = 0, minPrecision = precision ) { const formattedAmount = ( - amount instanceof TokenBalance || amount instanceof CurrencyBalance || amount instanceof Price + amount instanceof TokenBalance || + amount instanceof CurrencyBalance || + amount instanceof Price || + amount instanceof Rate ? amount.toFloat() : amount instanceof Decimal ? amount.toNumber() From ced2ca2f5e73dd98aac73207dc42afad19d0f53b Mon Sep 17 00:00:00 2001 From: Guillermo Perez <gpmayorga@users.noreply.github.com> Date: Mon, 11 Sep 2023 17:14:52 -0400 Subject: [PATCH 29/39] Add moonbeam alpha environment to CI (#1575) * Deploy when pushing to main --- .github/actions/prepare-deploy/action.yml | 5 +++ .github/workflows/moonbeam-alpha-deploy.yml | 34 +++++++++++++++++++ .gitignore | 1 + .../.env-config/.env.moonbeam-alpha | 20 +++++++++++ onboarding-api/env-vars/moonbeam-alpha.env | 7 ++++ .../env-vars/moonbeam-alpha.secrets | 7 ++++ pinning-api/env-vars/moonbeam-alpha.secrets | 2 ++ 7 files changed, 76 insertions(+) create mode 100644 .github/workflows/moonbeam-alpha-deploy.yml create mode 100644 centrifuge-app/.env-config/.env.moonbeam-alpha create mode 100644 onboarding-api/env-vars/moonbeam-alpha.env create mode 100644 onboarding-api/env-vars/moonbeam-alpha.secrets create mode 100644 pinning-api/env-vars/moonbeam-alpha.secrets diff --git a/.github/actions/prepare-deploy/action.yml b/.github/actions/prepare-deploy/action.yml index 5d07477106..490b0bb75b 100644 --- a/.github/actions/prepare-deploy/action.yml +++ b/.github/actions/prepare-deploy/action.yml @@ -70,6 +70,11 @@ runs: echo "function_name=${{ inputs.app_base_name }}-demo" >> $GITHUB_OUTPUT echo "front_url=${{ inputs.app_base_name }}-demo.k-f.dev" >> $GITHUB_OUTPUT echo "env_name=demo" >> $GITHUB_OUTPUT + elif ${{ contains(inputs.deploy_to, 'moonbeam-alpha') }}; then + # moonbeam-alpha + echo "function_name=${{ inputs.app_base_name }}-moonbeam-alpha" >> $GITHUB_OUTPUT + echo "front_url=${{ inputs.app_base_name }}-moonbeam-alpha.k-f.dev" >> $GITHUB_OUTPUT + echo "env_name=moonbeam-alpha" >> $GITHUB_OUTPUT elif ${{ github.ref == 'refs/heads/main' }}; then # DEV echo "function_name=${{ inputs.app_base_name }}-dev" >> $GITHUB_OUTPUT diff --git a/.github/workflows/moonbeam-alpha-deploy.yml b/.github/workflows/moonbeam-alpha-deploy.yml new file mode 100644 index 0000000000..16f0014455 --- /dev/null +++ b/.github/workflows/moonbeam-alpha-deploy.yml @@ -0,0 +1,34 @@ +name: "Moonbeam dev (alpha) deployments (manual)" +on: + push: + branches: main + pull_request: + paths: + - '.github/workflows/moonbeam-alpha-deploy.yml' + +jobs: + app-moonbeam-alpha: + uses: ./.github/workflows/centrifuge-app.yml + secrets: inherit + with: + deploy_env: moonbeam-alpha + + + pinning-moonbeam-alpha: + uses: ./.github/workflows/pinning-api.yml + secrets: inherit + with: + deploy_env: moonbeam-alpha + + + onboarding-moonbeam-alpha: + uses: ./.github/workflows/onboarding-api.yml + secrets: inherit + with: + deploy_env: moonbeam-alpha + + # faucet-moonbeam-alpha: + # uses: ./.github/workflows/faucet-api.yml + # secrets: inherit + # with: + # deploy_env: moonbeam-alpha \ No newline at end of file diff --git a/.gitignore b/.gitignore index 58ee203ebb..89a8e425fa 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,4 @@ yarn-error.log !.env.demo !.env.catalyst !.env.production +!.env.moonbeam-alpha diff --git a/centrifuge-app/.env-config/.env.moonbeam-alpha b/centrifuge-app/.env-config/.env.moonbeam-alpha new file mode 100644 index 0000000000..2c9953161d --- /dev/null +++ b/centrifuge-app/.env-config/.env.moonbeam-alpha @@ -0,0 +1,20 @@ +REACT_APP_COLLATOR_WSS_URL=wss://fullnode.moonbase-dev.cntrfg.com/public-ws +REACT_APP_DEFAULT_NODE_URL=https://pod.moonbeam-alpha.k-f.dev +REACT_APP_DEFAULT_UNLIST_POOLS=true +REACT_APP_FAUCET_URL=https://europe-central2-peak-vista-185616.cloudfunctions.net/faucet-api-moonbean-alpha +REACT_APP_IPFS_GATEWAY=https://centrifuge.mypinata.cloud/ +REACT_APP_IS_DEMO=true +REACT_APP_ONBOARDING_API_URL=https://europe-central2-peak-vista-185616.cloudfunctions.net/onboarding-api-moonbean-alpha +REACT_APP_PINNING_API_URL=https://europe-central2-peak-vista-185616.cloudfunctions.net/pinning-api-moonbean-alpha +REACT_APP_POOL_CREATION_TYPE=immediate +REACT_APP_RELAY_WSS_URL=wss://frag-moonbase-relay-rpc-ws.g.moonbase.moonbeam.network +REACT_APP_SUBQUERY_URL=https://api.subquery.network/sq/centrifuge/pools-demo +REACT_APP_SUBSCAN_URL= +REACT_APP_TINLAKE_NETWORK=goerli +REACT_APP_INFURA_KEY=bf808e7d3d924fbeb74672d9341d0550 +REACT_APP_WHITELISTED_ACCOUNTS= +REACT_APP_NETWORK=centrifuge +REACT_APP_REWARDS_TREE_URL=https://storage.googleapis.com/rad-rewards-trees-kovan-staging/latest.json +REACT_APP_MEMBERLIST_ADMIN_PURE_PROXY=kALwmJutBq95s41U9fWnoApCUgvPqPGTh1GSmFnQh5f9fWo93 +REACT_APP_WALLETCONNECT_ID=c32fa79350803519804a67fcab0b742a +REACT_APP_TINLAKE_SUBGRAPH_URL=https://graph.centrifuge.io/tinlake diff --git a/onboarding-api/env-vars/moonbeam-alpha.env b/onboarding-api/env-vars/moonbeam-alpha.env new file mode 100644 index 0000000000..ce95c37a2d --- /dev/null +++ b/onboarding-api/env-vars/moonbeam-alpha.env @@ -0,0 +1,7 @@ +REDIRECT_URL=https://app-moonbase.k-f.dev +MEMBERLIST_ADMIN_PURE_PROXY=kAM1ELFDHdHeLDAkAdwEnfufoCL5hpUycGs4ZQkSQKVpHFoXm +COLLATOR_WSS_URL=wss://fullnode.moonbase-dev.cntrfg.com/public-ws +RELAY_WSS_URL=wss://frag-moonbase-relay-rpc-ws.g.moonbase.moonbeam.network +INFURA_KEY=bf808e7d3d924fbeb74672d9341d0550 +EVM_NETWORK=goerli +ONBOARDING_STORAGE_BUCKET=centrifuge-onboarding-api-dev \ No newline at end of file diff --git a/onboarding-api/env-vars/moonbeam-alpha.secrets b/onboarding-api/env-vars/moonbeam-alpha.secrets new file mode 100644 index 0000000000..ecdceefbd5 --- /dev/null +++ b/onboarding-api/env-vars/moonbeam-alpha.secrets @@ -0,0 +1,7 @@ +SHUFTI_PRO_SECRET_KEY=projects/peak-vista-185616/secrets/SHUFTI_PRO_SECRET_KEY +SHUFTI_PRO_CLIENT_ID=projects/peak-vista-185616/secrets/SHUFTI_PRO_CLIENT_ID +JWT_SECRET=projects/peak-vista-185616/secrets/JWT_SECRET +SENDGRID_API_KEY=projects/peak-vista-185616/secrets/SENDGRID_API_KEY +COOKIE_SECRET=projects/peak-vista-185616/secrets/COOKIE_SECRET +PURE_PROXY_CONTROLLER_SEED=projects/peak-vista-185616/secrets/PURE_PROXY_CONTROLLER_SEED +EVM_MEMBERLIST_ADMIN_PRIVATE_KEY=projects/peak-vista-185616/secrets/EVM_MEMBERLIST_ADMIN_PRIVATE_KEY \ No newline at end of file diff --git a/pinning-api/env-vars/moonbeam-alpha.secrets b/pinning-api/env-vars/moonbeam-alpha.secrets new file mode 100644 index 0000000000..ed859fd326 --- /dev/null +++ b/pinning-api/env-vars/moonbeam-alpha.secrets @@ -0,0 +1,2 @@ +PINATA_API_KEY=projects/peak-vista-185616/secrets/PINATA_API_KEY +PINATA_SECRET_API_KEY=projects/peak-vista-185616/secrets/PINATA_SECRET_API_KEY From 2e038db53780a53ae8a3fcb2dd6764825b9190ec Mon Sep 17 00:00:00 2001 From: Sophia <littlejohn.sophia@gmail.com> Date: Tue, 12 Sep 2023 11:12:29 -0400 Subject: [PATCH 30/39] Onboarding: Convert tax forms to optional step (#1572) * Give email dialogs a facelift * Add key to investor settings to make tax form optional on a pool level * Update onboarding frontend with taxes optional * Remove altair pinata reference * Add taxDoc as optional field in database * Fix visibilty for pools that don't require tax forms * Update step numbers * Remove Box --- .../ConfirmResendEmailVerificationDialog.tsx | 15 +- .../EditOnboardingEmailAddressDialog.tsx | 40 ++-- .../src/components/Onboarding/FileUpload.tsx | 9 +- .../Investors/OnboardingSettings.tsx | 12 + .../Onboarding/SignSubscriptionAgreement.tsx | 83 ++++++- .../src/pages/Onboarding/TaxInfo.tsx | 206 ++++++------------ .../Onboarding/UltimateBeneficialOwners.tsx | 1 + centrifuge-app/src/pages/Onboarding/index.tsx | 33 ++- .../pages/Onboarding/queries/useTaxInfo.ts | 2 +- centrifuge-app/src/types/index.ts | 6 +- .../src/utils/getActiveOnboardingStep.ts | 35 ++- centrifuge-js/README.md | 2 +- centrifuge-js/src/CentrifugeBase.ts | 2 +- centrifuge-js/src/modules/pools.ts | 1 + .../emails/signAndSendDocuments.ts | 5 + .../src/controllers/kyb/verifyBusiness.ts | 2 +- .../src/controllers/user/getTaxInfo.ts | 20 +- .../src/controllers/user/startKyc.ts | 12 +- .../src/controllers/user/uploadTaxInfo.ts | 21 +- onboarding-api/src/database/index.ts | 8 +- onboarding-api/src/emails/index.ts | 10 +- .../src/emails/sendDocumentsMessage.ts | 40 ++-- onboarding-api/src/utils/fetchTaxInfo.ts | 21 ++ 23 files changed, 290 insertions(+), 296 deletions(-) create mode 100644 onboarding-api/src/utils/fetchTaxInfo.ts diff --git a/centrifuge-app/src/components/Dialogs/ConfirmResendEmailVerificationDialog.tsx b/centrifuge-app/src/components/Dialogs/ConfirmResendEmailVerificationDialog.tsx index 58b9ac0cdc..0ca0eb4437 100644 --- a/centrifuge-app/src/components/Dialogs/ConfirmResendEmailVerificationDialog.tsx +++ b/centrifuge-app/src/components/Dialogs/ConfirmResendEmailVerificationDialog.tsx @@ -4,22 +4,23 @@ import { useSendVerifyEmail } from '../../pages/Onboarding/queries/useSendVerify type Props = { isDialogOpen: boolean setIsDialogOpen: (isDialogOpen: boolean) => void + currentEmail: string } -export const ConfirmResendEmailVerificationDialog = ({ isDialogOpen, setIsDialogOpen }: Props) => { +export const ConfirmResendEmailVerificationDialog = ({ isDialogOpen, setIsDialogOpen, currentEmail }: Props) => { const { mutate: sendVerifyEmail, isLoading } = useSendVerifyEmail() return ( <Dialog - width="25%" + width="30%" isOpen={isLoading ? true : isDialogOpen} onClose={() => setIsDialogOpen(false)} - title={<Text variant="heading1">Send Confirmation Email</Text>} + title={<Text variant="heading2">Send Confirmation Email</Text>} > - <Box p={2}> - <Stack gap={4}> - <Text variant="body1">Are you sure you want to resend a confirmation email?</Text> - <Shelf justifyContent="flex-end" gap={2}> + <Box> + <Stack gap={3}> + <Text variant="body1">Are you sure you want to resend a confirmation email to {currentEmail}?</Text> + <Shelf gap={2} justifyContent="flex-end"> <Button onClick={() => setIsDialogOpen(false)} variant="secondary" disabled={isLoading}> Cancel </Button> diff --git a/centrifuge-app/src/components/Dialogs/EditOnboardingEmailAddressDialog.tsx b/centrifuge-app/src/components/Dialogs/EditOnboardingEmailAddressDialog.tsx index cb78df4cc0..3f39b9c3a5 100644 --- a/centrifuge-app/src/components/Dialogs/EditOnboardingEmailAddressDialog.tsx +++ b/centrifuge-app/src/components/Dialogs/EditOnboardingEmailAddressDialog.tsx @@ -1,4 +1,4 @@ -import { Box, Button, Dialog, Shelf, Stack, Text, TextInput } from '@centrifuge/fabric' +import { Button, Dialog, Shelf, Stack, Text, TextInput } from '@centrifuge/fabric' import * as React from 'react' import { useMutation } from 'react-query' import { string } from 'yup' @@ -49,27 +49,25 @@ export const EditOnboardingEmailAddressDialog = ({ isDialogOpen, setIsDialogOpen width="30%" isOpen={isLoading ? true : isDialogOpen} onClose={() => setIsDialogOpen(false)} - title={<Text variant="heading1">Edit Email Address</Text>} + title={<Text variant="heading2">Edit Email Address</Text>} > - <Box p={4}> - <Stack gap={4}> - <TextInput value={currentEmail} label="Current Email Address" disabled /> - <TextInput value={newEmail} label="New Email Address" onChange={(event) => setNewEmail(event.target.value)} /> - <Shelf justifyContent="flex-end" gap={2}> - <Button onClick={() => setIsDialogOpen(false)} variant="secondary" disabled={isLoading}> - Cancel - </Button> - <Button - onClick={() => updateEmail()} - loading={isLoading} - disabled={isLoading || !isValid} - loadingMessage="Updating" - > - Update - </Button> - </Shelf> - </Stack> - </Box> + <Stack gap={3}> + <TextInput value={currentEmail} label="Current Email Address" disabled /> + <TextInput value={newEmail} label="New Email Address" onChange={(event) => setNewEmail(event.target.value)} /> + <Shelf justifyContent="flex-end" gap={2}> + <Button onClick={() => setIsDialogOpen(false)} variant="secondary" disabled={isLoading}> + Cancel + </Button> + <Button + onClick={() => updateEmail()} + loading={isLoading} + disabled={isLoading || !isValid || newEmail === currentEmail} + loadingMessage="Updating" + > + Update + </Button> + </Shelf> + </Stack> </Dialog> ) } diff --git a/centrifuge-app/src/components/Onboarding/FileUpload.tsx b/centrifuge-app/src/components/Onboarding/FileUpload.tsx index 500d9a1c9d..7400a80a2e 100644 --- a/centrifuge-app/src/components/Onboarding/FileUpload.tsx +++ b/centrifuge-app/src/components/Onboarding/FileUpload.tsx @@ -131,7 +131,7 @@ export function FileUpload({ } return ( - <Stack gap={1} width="100%" height={280}> + <Stack gap={1} width="100%" height={150}> <Box px={2} py={1} @@ -157,7 +157,7 @@ export function FileUpload({ tabIndex={-1} ref={inputRef} /> - <Stack gap={4} height="100%" justifyContent="center" alignItems="center"> + <Stack gap={2} height="100%" justifyContent="center" alignItems="center"> {curFile ? ( <> <Shelf gap={1}> @@ -189,11 +189,6 @@ export function FileUpload({ ) : ( <> <Stack gap={1} alignItems="center"> - <Text as="span" variant="body1" textAlign="center"> - Drop a file to upload - <br /> - or - </Text> <UploadButton forwardedAs="button" variant="body1" diff --git a/centrifuge-app/src/pages/IssuerPool/Investors/OnboardingSettings.tsx b/centrifuge-app/src/pages/IssuerPool/Investors/OnboardingSettings.tsx index 79487639b0..86699b2fd9 100644 --- a/centrifuge-app/src/pages/IssuerPool/Investors/OnboardingSettings.tsx +++ b/centrifuge-app/src/pages/IssuerPool/Investors/OnboardingSettings.tsx @@ -38,6 +38,7 @@ type OnboardingSettingsInput = { externalOnboardingUrl?: string openForOnboarding: { [trancheId: string]: boolean } podReadAccess: boolean + taxInfoRequired: boolean } export const OnboardingSettings = () => { @@ -122,6 +123,7 @@ export const OnboardingSettings = () => { {} ), podReadAccess: !!poolMetadata?.onboarding?.podReadAccess || false, + taxInfoRequired: !!poolMetadata?.onboarding?.taxInfoRequired || true, } }, [pool, poolMetadata, centrifuge.metadata]) @@ -202,6 +204,7 @@ export const OnboardingSettings = () => { kybRestrictedCountries, externalOnboardingUrl: useExternalUrl ? values.externalOnboardingUrl : undefined, podReadAccess: values.podReadAccess, + taxInfoRequired: values.taxInfoRequired, }, } @@ -363,6 +366,15 @@ export const OnboardingSettings = () => { disabled={!isEditing || formik.isSubmitting || isLoading} /> </Stack> + <Stack gap={2}> + <Text variant="heading4">Tax document requirement</Text> + <Checkbox + label="Require investors to upload tax documents before signing the subscription agreement" + checked={formik.values.taxInfoRequired} + onChange={(e) => formik.setFieldValue('taxInfoRequired', !!e.target.checked)} + disabled={!isEditing || formik.isSubmitting || isLoading} + /> + </Stack> <RestrictedCountriesTable isEditing={isEditing} isLoading={isLoading} formik={formik} type="KYB" /> <RestrictedCountriesTable isEditing={isEditing} isLoading={isLoading} formik={formik} type="KYC" /> </Stack> diff --git a/centrifuge-app/src/pages/Onboarding/SignSubscriptionAgreement.tsx b/centrifuge-app/src/pages/Onboarding/SignSubscriptionAgreement.tsx index 52b8573a76..f48d6cd325 100644 --- a/centrifuge-app/src/pages/Onboarding/SignSubscriptionAgreement.tsx +++ b/centrifuge-app/src/pages/Onboarding/SignSubscriptionAgreement.tsx @@ -3,13 +3,18 @@ import { AnchorButton, Box, Button, Checkbox, IconDownload, Shelf, Spinner, Stac import { useFormik } from 'formik' import * as React from 'react' import { boolean, object } from 'yup' -import { ActionBar, Content, ContentHeader } from '../../components/Onboarding' +import { ConfirmResendEmailVerificationDialog } from '../../components/Dialogs/ConfirmResendEmailVerificationDialog' +import { EditOnboardingEmailAddressDialog } from '../../components/Dialogs/EditOnboardingEmailAddressDialog' +import { ActionBar, Content, ContentHeader, Notification, NotificationBar } from '../../components/Onboarding' import { OnboardingPool, useOnboarding } from '../../components/OnboardingProvider' import { PDFViewer } from '../../components/PDFViewer' +import { ValidationToast } from '../../components/ValidationToast' import { OnboardingUser } from '../../types' import { usePool, usePoolMetadata } from '../../utils/usePools' import { useSignAndSendDocuments } from './queries/useSignAndSendDocuments' import { useSignRemark } from './queries/useSignRemark' +import { useUploadTaxInfo } from './queries/useUploadTaxInfo' +import { TaxInfo } from './TaxInfo' type Props = { signedAgreementUrl: string | undefined @@ -32,6 +37,7 @@ export const SignSubscriptionAgreement = ({ signedAgreementUrl }: Props) => { const { data: poolMetadata } = usePoolMetadata(poolData) const centrifuge = useCentrifuge() + const isTaxDocsRequired = poolMetadata?.onboarding?.taxInfoRequired const hasSignedAgreement = !!onboardingUser.poolSteps?.[poolId]?.[trancheId]?.signAgreement.completed const unsignedAgreementUrl = poolMetadata?.onboarding?.tranches?.[trancheId]?.agreement?.uri ? centrifuge.metadata.parseMetadataUrl(poolMetadata.onboarding.tranches[trancheId].agreement?.uri!) @@ -39,12 +45,16 @@ export const SignSubscriptionAgreement = ({ signedAgreementUrl }: Props) => { ? centrifuge.metadata.parseMetadataUrl(GENERIC_SUBSCRIPTION_AGREEMENT) : null + const isEmailVerified = !!onboardingUser.globalSteps.verifyEmail.completed const formik = useFormik({ initialValues: { isAgreed: hasSignedAgreement, + isEmailVerified, + taxInfo: undefined, }, validationSchema, - onSubmit: () => { + onSubmit: async (values) => { + isTaxDocsRequired && (await uploadTaxInfo(values.taxInfo)) signRemark([ `I hereby sign the subscription agreement of pool ${poolId} and tranche ${trancheId}: ${poolMetadata?.onboarding?.tranches?.[trancheId]?.agreement?.uri}`, ]) @@ -53,6 +63,7 @@ export const SignSubscriptionAgreement = ({ signedAgreementUrl }: Props) => { const { mutate: sendDocumentsToIssuer, isLoading: isSending } = useSignAndSendDocuments() const { execute: signRemark, isLoading: isSigningTransaction } = useSignRemark(sendDocumentsToIssuer) + const { mutate: uploadTaxInfo, isLoading: isTaxUploadLoading } = useUploadTaxInfo() // tinlake pools without subdocs cannot accept investors const isPoolClosedToOnboarding = poolId.startsWith('0x') && !unsignedAgreementUrl @@ -70,6 +81,12 @@ export const SignSubscriptionAgreement = ({ signedAgreementUrl }: Props) => { return !isPoolClosedToOnboarding && isCountrySupported ? ( <Content> + {formik.errors.isEmailVerified && <ValidationToast label={formik.errors.isEmailVerified} />} + {!hasSignedAgreement && onboardingUser.investorType === 'individual' && ( + <NotificationBar> + <EmailVerificationInlineFeedback email={onboardingUser?.email as string} completed={isEmailVerified} /> + </NotificationBar> + )} <ContentHeader title="Sign subscription agreement" body="Read the subscription agreement and click the box below to automatically e-sign the subscription agreement. You don't need to download and sign manually." @@ -129,6 +146,14 @@ export const SignSubscriptionAgreement = ({ signedAgreementUrl }: Props) => { </AnchorButton> )} </Stack> + {isTaxDocsRequired && ( + <TaxInfo + value={formik.values.taxInfo} + setValue={(file) => formik.setFieldValue('taxInfo', file)} + touched={formik.touched.taxInfo} + error={formik.errors.taxInfo} + /> + )} <Checkbox {...formik.getFieldProps('isAgreed')} checked={formik.values.isAgreed} @@ -137,19 +162,29 @@ export const SignSubscriptionAgreement = ({ signedAgreementUrl }: Props) => { I hereby sign and agree to the terms of the subscription agreement </Text> } - disabled={isSigningTransaction || isSending || hasSignedAgreement} + disabled={isSigningTransaction || isSending || hasSignedAgreement || isTaxUploadLoading} errorMessage={formik.errors.isAgreed} /> <ActionBar> - <Button onClick={() => previousStep()} variant="secondary" disabled={isSigningTransaction || isSending}> + <Button + onClick={() => previousStep()} + variant="secondary" + disabled={isSigningTransaction || isSending || isTaxUploadLoading} + > Back </Button> <Button onClick={hasSignedAgreement ? () => nextStep() : () => formik.handleSubmit()} loadingMessage="Signing" - loading={isSigningTransaction || isSending} - disabled={isSigningTransaction || isSending} + loading={isSigningTransaction || isSending || isTaxUploadLoading} + disabled={ + isSigningTransaction || + isSending || + isTaxUploadLoading || + (isTaxDocsRequired && !formik.values.taxInfo) || + !formik.values.isAgreed + } > {hasSignedAgreement ? 'Next' : 'Sign'} </Button> @@ -187,3 +222,39 @@ export const SignSubscriptionAgreement = ({ signedAgreementUrl }: Props) => { </Content> ) } + +const EmailVerificationInlineFeedback = ({ email, completed }: { email: string; completed: boolean }) => { + const [isEditOnboardingEmailAddressDialogOpen, setIsEditOnboardingEmailAddressDialogOpen] = React.useState(false) + const [isConfirmResendEmailVerificationDialogOpen, setIsConfirmResendEmailVerificationDialogOpen] = + React.useState(false) + + if (completed) { + return <Notification>Email address verified</Notification> + } + + return ( + <> + <Notification type="alert"> + Please verify your email address. Email sent to {email}. If you did not receive an email,{' '} + <button onClick={() => setIsConfirmResendEmailVerificationDialogOpen(true)}>send again</button> or{' '} + <button onClick={() => setIsEditOnboardingEmailAddressDialogOpen(true)}>edit email</button>. Otherwise contact{' '} + <a href="mailto:support@centrifuge.io?subject=Onboarding email verification&body=I’m reaching out about…"> + support@centrifuge.io + </a> + . + </Notification> + + <EditOnboardingEmailAddressDialog + currentEmail={email} + isDialogOpen={isEditOnboardingEmailAddressDialogOpen} + setIsDialogOpen={setIsEditOnboardingEmailAddressDialogOpen} + /> + + <ConfirmResendEmailVerificationDialog + isDialogOpen={isConfirmResendEmailVerificationDialogOpen} + setIsDialogOpen={setIsConfirmResendEmailVerificationDialogOpen} + currentEmail={email} + /> + </> + ) +} diff --git a/centrifuge-app/src/pages/Onboarding/TaxInfo.tsx b/centrifuge-app/src/pages/Onboarding/TaxInfo.tsx index bdb69dbceb..70750ad88b 100644 --- a/centrifuge-app/src/pages/Onboarding/TaxInfo.tsx +++ b/centrifuge-app/src/pages/Onboarding/TaxInfo.tsx @@ -1,99 +1,32 @@ -import { AnchorButton, Box, Button } from '@centrifuge/fabric' -import { useFormik } from 'formik' +import { AnchorButton, Box, RadioButton, Shelf, Stack, Text } from '@centrifuge/fabric' import * as React from 'react' -import { boolean, mixed, object } from 'yup' -import { ConfirmResendEmailVerificationDialog } from '../../components/Dialogs/ConfirmResendEmailVerificationDialog' -import { EditOnboardingEmailAddressDialog } from '../../components/Dialogs/EditOnboardingEmailAddressDialog' -import { - ActionBar, - Content, - ContentHeader, - FileUpload, - Notification, - NotificationBar, -} from '../../components/Onboarding' +import { FileUpload } from '../../components/Onboarding' import { useOnboarding } from '../../components/OnboardingProvider' -import { ValidationToast } from '../../components/ValidationToast' import { OnboardingUser } from '../../types' import { useTaxInfo } from './queries/useTaxInfo' -import { useUploadTaxInfo } from './queries/useUploadTaxInfo' -const validationSchema = object({ - taxInfo: mixed().required('Please upload a tax form'), - isEmailVerified: boolean().oneOf([true], 'Please verify your email address'), -}) - -const EmailVerificationInlineFeedback = ({ email, completed }: { email: string; completed: boolean }) => { - const [isEditOnboardingEmailAddressDialogOpen, setIsEditOnboardingEmailAddressDialogOpen] = React.useState(false) - const [isConfirmResendEmailVerificationDialogOpen, setIsConfirmResendEmailVerificationDialogOpen] = - React.useState(false) - - if (completed) { - return <Notification>Email address verified</Notification> - } - - return ( - <> - <Notification type="alert"> - Please verify your email address. Email sent to {email}. If you did not receive an email,{' '} - <button onClick={() => setIsConfirmResendEmailVerificationDialogOpen(true)}>send again</button> or{' '} - <button onClick={() => setIsEditOnboardingEmailAddressDialogOpen(true)}>edit email</button>. Otherwise contact{' '} - <a href="mailto:support@centrifuge.io?subject=Onboarding email verification&body=I’m reaching out about…"> - support@centrifuge.io - </a> - . - </Notification> - - <EditOnboardingEmailAddressDialog - currentEmail={email} - isDialogOpen={isEditOnboardingEmailAddressDialogOpen} - setIsDialogOpen={setIsEditOnboardingEmailAddressDialogOpen} - /> - - <ConfirmResendEmailVerificationDialog - isDialogOpen={isConfirmResendEmailVerificationDialogOpen} - setIsDialogOpen={setIsConfirmResendEmailVerificationDialogOpen} - /> - </> - ) +type TaxInfoProps = { + value: File | undefined | string + setValue: (value: File | null | string) => void + touched: boolean | undefined + error: string | undefined } -export const TaxInfo = () => { - const { onboardingUser, previousStep, nextStep, refetchOnboardingUser } = useOnboarding<NonNullable<OnboardingUser>>() +export const TaxInfo = ({ value, setValue, touched, error }: TaxInfoProps) => { + const { onboardingUser, pool } = useOnboarding<NonNullable<OnboardingUser>>() const { data: taxInfoData } = useTaxInfo() - const { mutate: uploadTaxInfo, isLoading } = useUploadTaxInfo() - - const isCompleted = !!onboardingUser.globalSteps.verifyTaxInfo.completed - const isEmailVerified = !!onboardingUser.globalSteps.verifyEmail.completed - - const formik = useFormik({ - initialValues: { - taxInfo: undefined, - isEmailVerified, - }, - validationSchema, - onSubmit: (values: { taxInfo: File | undefined }) => { - uploadTaxInfo(values.taxInfo) - }, - }) - - const onFocus = () => { - refetchOnboardingUser() - } + const [uploadNewFile, setUploadNewFile] = React.useState(true) React.useEffect(() => { - if (isEmailVerified) { - formik.setFieldValue('isEmailVerified', true) - window.removeEventListener('focus', onFocus) - } else { - window.addEventListener('focus', onFocus) + if (!uploadNewFile && taxInfoData) { + setValue(taxInfoData) } - - return () => { - window.removeEventListener('focus', onFocus) + if (uploadNewFile) { + setValue(null) } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isEmailVerified]) + }, [uploadNewFile, taxInfoData]) + + const isCompleted = !!onboardingUser.taxDocument const validateFileUpload = (file: File) => { if (file.type !== 'application/pdf') { @@ -130,63 +63,54 @@ export const TaxInfo = () => { }, [onboardingUser]) return ( - <> - {formik.errors.isEmailVerified && <ValidationToast label={formik.errors.isEmailVerified} />} - <Content> - {!isCompleted && onboardingUser.investorType === 'individual' && ( - <NotificationBar> - <EmailVerificationInlineFeedback email={onboardingUser?.email as string} completed={isEmailVerified} /> - </NotificationBar> - )} - - <ContentHeader - title="Tax information" - body={ - <> - Please complete and upload a {taxForm.type} form. The form can be found at{' '} - <a href={taxForm.url} target="_blank" rel="noreferrer"> - {taxForm.label} - </a> - . - </> - } - /> - - <Box> - {isCompleted ? ( - <Box> - <AnchorButton variant="secondary" href={taxInfoData} target="__blank"> - View uploaded tax form - </AnchorButton> - </Box> - ) : ( - <FileUpload - onFileChange={(file) => formik.setFieldValue('taxInfo', file)} - disabled={isLoading || isCompleted} - file={formik.values.taxInfo || null} - errorMessage={formik.touched.taxInfo ? formik.errors.taxInfo : undefined} - validate={validateFileUpload} - accept=".pdf" + <Stack gap={2}> + <Text variant="heading2">Tax Information</Text> + <Text variant="body1"> + {pool?.name} requires all investors to provide a completed {taxForm.type} form. The form can be found at{' '} + <a href={taxForm.url} target="_blank" rel="noreferrer"> + {taxForm.label} + </a> + . + </Text> + + {isCompleted && ( + <Stack gap={1}> + <Text variant="body1">Choose to use an existing tax form or upload a new one</Text> + <Shelf gap={2}> + <RadioButton + label="New" + name="useUploadedFile" + checked={uploadNewFile} + onChange={() => setUploadNewFile(true)} /> - )} - </Box> - </Content> - - <ActionBar> - <Button onClick={() => previousStep()} variant="secondary" disabled={isLoading}> - Back - </Button> - <Button - onClick={() => { - isCompleted ? nextStep() : formik.handleSubmit() - }} - disabled={isLoading || (!formik.values.taxInfo && !taxInfoData) || !formik.values.isEmailVerified} - loading={isLoading} - loadingMessage="Uploading" - > - Next - </Button> - </ActionBar> - </> + <RadioButton + label="Existing" + name="useUploadedFile" + checked={!uploadNewFile} + onChange={() => setUploadNewFile(false)} + /> + </Shelf> + </Stack> + )} + + <Stack gap={2}> + {uploadNewFile && ( + <FileUpload + onFileChange={setValue} + file={value || null} + errorMessage={touched ? error : undefined} + validate={validateFileUpload} + accept=".pdf" + /> + )} + {isCompleted && !uploadNewFile && ( + <Box> + <AnchorButton variant="secondary" href={taxInfoData} target="__blank"> + View tax form + </AnchorButton> + </Box> + )} + </Stack> + </Stack> ) } diff --git a/centrifuge-app/src/pages/Onboarding/UltimateBeneficialOwners.tsx b/centrifuge-app/src/pages/Onboarding/UltimateBeneficialOwners.tsx index ffb8e83e81..aa80005272 100644 --- a/centrifuge-app/src/pages/Onboarding/UltimateBeneficialOwners.tsx +++ b/centrifuge-app/src/pages/Onboarding/UltimateBeneficialOwners.tsx @@ -94,6 +94,7 @@ const EmailVerificationInlineFeedback = ({ email, completed }: { email: string; <ConfirmResendEmailVerificationDialog isDialogOpen={isConfirmResendEmailVerificationDialogOpen} setIsDialogOpen={setIsConfirmResendEmailVerificationDialogOpen} + currentEmail={email} /> </> ) diff --git a/centrifuge-app/src/pages/Onboarding/index.tsx b/centrifuge-app/src/pages/Onboarding/index.tsx index 1e9d7fbbaf..1c1bfa9591 100644 --- a/centrifuge-app/src/pages/Onboarding/index.tsx +++ b/centrifuge-app/src/pages/Onboarding/index.tsx @@ -15,7 +15,6 @@ import { LinkWallet } from './LinkWallet' import { useGlobalOnboardingStatus } from './queries/useGlobalOnboardingStatus' import { useSignedAgreement } from './queries/useSignedAgreement' import { SignSubscriptionAgreement } from './SignSubscriptionAgreement' -import { TaxInfo } from './TaxInfo' import { UltimateBeneficialOwners } from './UltimateBeneficialOwners' export const OnboardingPage: React.FC = () => { @@ -77,7 +76,6 @@ export const OnboardingPage: React.FC = () => { {investorType === 'individual' && (activeStep > 2 || !!onboardingUser?.investorType) && ( <> <Step label="Identity verification" /> - <Step label="Tax information" /> {onboardingUser?.countryOfCitizenship === 'us' && <Step label="Accreditation" />} {pool ? ( <> @@ -94,7 +92,6 @@ export const OnboardingPage: React.FC = () => { <Step label="Business information" /> <Step label="Confirm ultimate beneficial owners" /> <Step label="Authorized signer verification" /> - <Step label="Tax information" /> {onboardingUser?.investorType === 'entity' && onboardingUser?.jurisdictionCode.startsWith('us') && ( <Step label="Accreditation" /> )} @@ -119,55 +116,53 @@ export const OnboardingPage: React.FC = () => { {activeStep === 3 && <KnowYourBusiness />} {activeStep === 4 && <UltimateBeneficialOwners />} {activeStep === 5 && <KnowYourCustomer />} - {activeStep === 6 && <TaxInfo />} {onboardingUser?.investorType === 'entity' && onboardingUser.jurisdictionCode.startsWith('us') ? ( <> - {activeStep === 7 && <Accreditation />} + {activeStep === 6 && <Accreditation />} {pool ? ( <> - {activeStep === 8 && ( + {activeStep === 7 && ( <SignSubscriptionAgreement signedAgreementUrl={signedAgreementData as string} /> )} - {activeStep === 9 && <ApprovalStatus signedAgreementUrl={signedAgreementData} />} + {activeStep === 8 && <ApprovalStatus signedAgreementUrl={signedAgreementData} />} </> ) : ( - activeStep === 8 && <GlobalStatus /> + activeStep === 7 && <GlobalStatus /> )} </> ) : pool ? ( <> - {activeStep === 7 && <SignSubscriptionAgreement signedAgreementUrl={signedAgreementData} />} - {activeStep === 8 && <ApprovalStatus signedAgreementUrl={signedAgreementData} />} + {activeStep === 6 && <SignSubscriptionAgreement signedAgreementUrl={signedAgreementData} />} + {activeStep === 7 && <ApprovalStatus signedAgreementUrl={signedAgreementData} />} </> ) : ( - activeStep === 7 && <GlobalStatus /> + activeStep === 6 && <GlobalStatus /> )} </> )} {investorType === 'individual' && ( <> {activeStep === 3 && <KnowYourCustomer />} - {activeStep === 4 && <TaxInfo />} {onboardingUser?.investorType === 'individual' && onboardingUser.countryOfCitizenship === 'us' ? ( <> - {activeStep === 5 && <Accreditation />} + {activeStep === 4 && <Accreditation />} {pool ? ( <> - {activeStep === 6 && <SignSubscriptionAgreement signedAgreementUrl={signedAgreementData} />} - {activeStep === 7 && <ApprovalStatus signedAgreementUrl={signedAgreementData} />} + {activeStep === 5 && <SignSubscriptionAgreement signedAgreementUrl={signedAgreementData} />} + {activeStep === 6 && <ApprovalStatus signedAgreementUrl={signedAgreementData} />} </> ) : ( - activeStep === 6 && <GlobalStatus /> + activeStep === 5 && <GlobalStatus /> )} </> ) : pool ? ( <> - {activeStep === 5 && <SignSubscriptionAgreement signedAgreementUrl={signedAgreementData} />} - {activeStep === 6 && <ApprovalStatus signedAgreementUrl={signedAgreementData} />} + {activeStep === 4 && <SignSubscriptionAgreement signedAgreementUrl={signedAgreementData} />} + {activeStep === 5 && <ApprovalStatus signedAgreementUrl={signedAgreementData} />} </> ) : ( - activeStep === 5 && <GlobalStatus /> + activeStep === 4 && <GlobalStatus /> )} </> )} diff --git a/centrifuge-app/src/pages/Onboarding/queries/useTaxInfo.ts b/centrifuge-app/src/pages/Onboarding/queries/useTaxInfo.ts index 996748e8ca..c6824a5a09 100644 --- a/centrifuge-app/src/pages/Onboarding/queries/useTaxInfo.ts +++ b/centrifuge-app/src/pages/Onboarding/queries/useTaxInfo.ts @@ -33,7 +33,7 @@ export const useTaxInfo = () => { }, { refetchOnWindowFocus: false, - enabled: !!onboardingUser?.globalSteps?.verifyTaxInfo?.completed, + enabled: !!onboardingUser?.taxDocument, } ) diff --git a/centrifuge-app/src/types/index.ts b/centrifuge-app/src/types/index.ts index d856648495..cf1ab2bf32 100644 --- a/centrifuge-app/src/types/index.ts +++ b/centrifuge-app/src/types/index.ts @@ -88,10 +88,6 @@ type PoolOnboardingSteps = { } type IndividualUserSteps = { - verifyTaxInfo: { - completed: boolean - timeStamp: string | null - } verifyAccreditation: { completed: boolean | null timeStamp: string | null @@ -135,6 +131,7 @@ export type EntityUser = { kycReference: string manualKybReference: string | null manualKybStatus?: 'review.pending' | 'verification.accepted' | 'verification.declined' | 'request.pending' + taxDocument?: string | null } type IndividualUser = { @@ -149,6 +146,7 @@ type IndividualUser = { globalSteps: IndividualUserSteps poolSteps: PoolOnboardingSteps kycReference: string + taxDocument?: string | null } export type OnboardingUser = IndividualUser | EntityUser diff --git a/centrifuge-app/src/utils/getActiveOnboardingStep.ts b/centrifuge-app/src/utils/getActiveOnboardingStep.ts index 00532f1761..d531126218 100644 --- a/centrifuge-app/src/utils/getActiveOnboardingStep.ts +++ b/centrifuge-app/src/utils/getActiveOnboardingStep.ts @@ -6,41 +6,38 @@ const BASE_ENTITY_STEPS = { VERIFY_BUSINESS: 3, CONFIRM_OWNERS: 4, VERIFY_IDENTITY: 5, - VERIFY_TAX_INFO: 6, } const ENTITY_US_STEPS = { ...BASE_ENTITY_STEPS, - VERIFY_ACCREDITATION: 7, - SIGN_AGREEMENT: 8, - COMPLETE: 9, + VERIFY_ACCREDITATION: 6, + SIGN_AGREEMENT: 7, + COMPLETE: 8, } const ENTITY_NON_US_STEPS = { ...BASE_ENTITY_STEPS, - SIGN_AGREEMENT: 7, - COMPLETE: 8, + SIGN_AGREEMENT: 6, + COMPLETE: 7, } const BASE_INDIVIDUAL_STEPS = { LINK_WALLET: 1, CHOOSE_INVESTOR_TYPE: 2, VERIFY_IDENTITY: 3, - VERIFY_TAX_INFO: 4, } const INDIVIDUAL_US_STEPS = { ...BASE_INDIVIDUAL_STEPS, - VERIFY_ACCREDITATION: 5, - SIGN_AGREEMENT: 6, - COMPLETE: 7, + VERIFY_ACCREDITATION: 4, + SIGN_AGREEMENT: 5, + COMPLETE: 6, } const INDIVIDUAL_NON_US_STEPS = { ...BASE_INDIVIDUAL_STEPS, - VERIFY_TAX_INFO: 4, - SIGN_AGREEMENT: 5, - COMPLETE: 6, + SIGN_AGREEMENT: 4, + COMPLETE: 5, } export const getActiveOnboardingStep = ( @@ -53,7 +50,7 @@ export const getActiveOnboardingStep = ( if (!onboardingUser) return 2 const { investorType, countryOfCitizenship } = onboardingUser - const { verifyIdentity, verifyTaxInfo, verifyAccreditation } = onboardingUser.globalSteps + const { verifyIdentity, verifyAccreditation } = onboardingUser.globalSteps const hasSignedAgreement = !!( poolId && @@ -68,13 +65,11 @@ export const getActiveOnboardingStep = ( if (jurisdictionCode.startsWith('us')) { if (hasSignedAgreement) return ENTITY_US_STEPS.COMPLETE if (verifyAccreditation.completed) return ENTITY_US_STEPS.SIGN_AGREEMENT - if (verifyTaxInfo.completed) return ENTITY_US_STEPS.VERIFY_ACCREDITATION } else { if (hasSignedAgreement) return ENTITY_NON_US_STEPS.COMPLETE - if (verifyTaxInfo.completed) return ENTITY_NON_US_STEPS.SIGN_AGREEMENT } - if (verifyIdentity.completed) return BASE_ENTITY_STEPS.VERIFY_TAX_INFO + if (verifyIdentity.completed) return ENTITY_NON_US_STEPS.SIGN_AGREEMENT if (confirmOwners.completed) return BASE_ENTITY_STEPS.VERIFY_IDENTITY if (verifyBusiness.completed || isPendingManualKybReview) return BASE_ENTITY_STEPS.CONFIRM_OWNERS @@ -85,12 +80,10 @@ export const getActiveOnboardingStep = ( if (countryOfCitizenship === 'us') { if (hasSignedAgreement) return INDIVIDUAL_US_STEPS.COMPLETE if (verifyAccreditation.completed) return INDIVIDUAL_US_STEPS.SIGN_AGREEMENT - if (verifyTaxInfo.completed) return INDIVIDUAL_US_STEPS.VERIFY_ACCREDITATION - if (verifyIdentity.completed) return BASE_INDIVIDUAL_STEPS.VERIFY_TAX_INFO + if (verifyIdentity.completed) return INDIVIDUAL_US_STEPS.VERIFY_ACCREDITATION } else { if (hasSignedAgreement) return INDIVIDUAL_NON_US_STEPS.COMPLETE - if (verifyTaxInfo.completed) return INDIVIDUAL_NON_US_STEPS.SIGN_AGREEMENT - if (verifyIdentity.completed) return BASE_INDIVIDUAL_STEPS.VERIFY_TAX_INFO + if (verifyIdentity.completed) return INDIVIDUAL_NON_US_STEPS.SIGN_AGREEMENT } return BASE_INDIVIDUAL_STEPS.VERIFY_IDENTITY diff --git a/centrifuge-js/README.md b/centrifuge-js/README.md index a1b495dc2a..22c4e08be2 100644 --- a/centrifuge-js/README.md +++ b/centrifuge-js/README.md @@ -42,7 +42,7 @@ Altair collator websocket URL. #### `metadataHost` -Default value: `https://altair.mypinata.cloud` +Default value: `https://centrifuge.mypinata.cloud` IPFS gateway url for retrieving metadata. diff --git a/centrifuge-js/src/CentrifugeBase.ts b/centrifuge-js/src/CentrifugeBase.ts index 45554a0d1a..781914d636 100644 --- a/centrifuge-js/src/CentrifugeBase.ts +++ b/centrifuge-js/src/CentrifugeBase.ts @@ -77,7 +77,7 @@ const defaultConfig: Config = { kusamaWsUrl: 'wss://kusama-rpc.polkadot.io', centrifugeSubqueryUrl: 'https://api.subquery.network/sq/centrifuge/pools', altairSubqueryUrl: 'https://api.subquery.network/sq/centrifuge/pools-altair', - metadataHost: 'https://altair.mypinata.cloud', + metadataHost: 'https://centrifuge.mypinata.cloud', } const relayChainTypes = {} diff --git a/centrifuge-js/src/modules/pools.ts b/centrifuge-js/src/modules/pools.ts index 5f9334f2c0..1c30f4e230 100644 --- a/centrifuge-js/src/modules/pools.ts +++ b/centrifuge-js/src/modules/pools.ts @@ -632,6 +632,7 @@ export type PoolMetadata = { externalOnboardingUrl?: string tranches: { [trancheId: string]: { agreement: FileType | undefined; openForOnboarding: boolean } } podReadAccess?: boolean + taxInfoRequired?: boolean } } diff --git a/onboarding-api/src/controllers/emails/signAndSendDocuments.ts b/onboarding-api/src/controllers/emails/signAndSendDocuments.ts index d140c351c7..b63534da16 100644 --- a/onboarding-api/src/controllers/emails/signAndSendDocuments.ts +++ b/onboarding-api/src/controllers/emails/signAndSendDocuments.ts @@ -34,6 +34,11 @@ export const signAndSendDocumentsController = async ( const { poolSteps, globalSteps, investorType, name, email, ...user } = await fetchUser(wallet) const { metadata } = await new NetworkSwitch(wallet.network).getPoolById(poolId) + + if (metadata.onboarding?.requireTaxInfo && !user.taxDocument) { + throw new HttpError(400, 'Tax info required') + } + if ( investorType === 'individual' && metadata?.onboarding?.kycRestrictedCountries?.includes(user.countryOfCitizenship) diff --git a/onboarding-api/src/controllers/kyb/verifyBusiness.ts b/onboarding-api/src/controllers/kyb/verifyBusiness.ts index 02c3a0ad0a..dfc2092d80 100644 --- a/onboarding-api/src/controllers/kyb/verifyBusiness.ts +++ b/onboarding-api/src/controllers/kyb/verifyBusiness.ts @@ -73,9 +73,9 @@ export const verifyBusinessController = async ( confirmOwners: { completed: false, timeStamp: null }, verifyIdentity: { completed: false, timeStamp: null }, verifyAccreditation: { completed: false, timeStamp: null }, - verifyTaxInfo: { completed: false, timeStamp: null }, }, poolSteps: {}, + taxDocument: null, } if (!(jurisdictionCode.slice(0, 2) in KYB_COUNTRY_CODES)) { diff --git a/onboarding-api/src/controllers/user/getTaxInfo.ts b/onboarding-api/src/controllers/user/getTaxInfo.ts index b1e289bbf6..b082365a07 100644 --- a/onboarding-api/src/controllers/user/getTaxInfo.ts +++ b/onboarding-api/src/controllers/user/getTaxInfo.ts @@ -1,23 +1,11 @@ import { Request, Response } from 'express' -import { onboardingBucket } from '../../database' -import { HttpError, reportHttpError } from '../../utils/httpError' +import { fetchTaxInfo } from '../../utils/fetchTaxInfo' +import { reportHttpError } from '../../utils/httpError' export const getTaxInfoController = async (req: Request, res: Response) => { try { - const { - wallet: { address }, - } = req - - const taxInfo = await onboardingBucket.file(`tax-information/${address}.pdf`) - - const [taxInfoExists] = await taxInfo.exists() - - if (!taxInfoExists) { - throw new HttpError(404, 'Tax info not found') - } - - const pdf = await taxInfo.download() - return res.send({ taxInfo: pdf[0] }) + const taxInfo = await fetchTaxInfo(req.wallet) + return res.send({ taxInfo }) } catch (e) { const error = reportHttpError(e) return res.status(error.code).send({ error: error.message }) diff --git a/onboarding-api/src/controllers/user/startKyc.ts b/onboarding-api/src/controllers/user/startKyc.ts index f7a74aa449..912e9b480f 100644 --- a/onboarding-api/src/controllers/user/startKyc.ts +++ b/onboarding-api/src/controllers/user/startKyc.ts @@ -76,19 +76,13 @@ export const startKycController = async (req: Request<any, any, InferType<typeof countryOfCitizenship: body.countryOfCitizenship, countryOfResidency: body.countryOfResidency, globalSteps: { - verifyIdentity: { - completed: false, - timeStamp: null, - }, - verifyEmail: { - completed: false, - timeStamp: null, - }, + verifyIdentity: { completed: false, timeStamp: null }, + verifyEmail: { completed: false, timeStamp: null }, verifyAccreditation: { completed: false, timeStamp: null }, - verifyTaxInfo: { completed: false, timeStamp: null }, }, poolSteps: {}, email: body.email as string, + taxDocument: null, } await validateAndWriteToFirestore(wallet, newUser, 'individual') } diff --git a/onboarding-api/src/controllers/user/uploadTaxInfo.ts b/onboarding-api/src/controllers/user/uploadTaxInfo.ts index 184fd34580..db20b43552 100644 --- a/onboarding-api/src/controllers/user/uploadTaxInfo.ts +++ b/onboarding-api/src/controllers/user/uploadTaxInfo.ts @@ -20,18 +20,21 @@ export const uploadTaxInfoController = async (req: Request, res: Response) => { const { wallet } = req const user = await fetchUser(wallet) - await writeToOnboardingBucket(Uint8Array.from(req.body), `tax-information/${wallet.address}.pdf`) + let taxDocument = '' + if (user?.taxDocument) { + const taxDoc = user.taxDocument.split('/') + const version = taxDoc[taxDoc.length - 1].split('.')[0].split('_')[1] ?? '0' + const newVersion = Number(version) + 1 + taxDocument = `tax-information/${wallet.address}_${newVersion}.pdf` + } else { + taxDocument = `tax-information/${wallet.address}.pdf` + } const updatedUser: Subset<OnboardingUser> = { - globalSteps: { - verifyTaxInfo: { - completed: true, - timeStamp: new Date().toISOString(), - }, - }, + taxDocument: `${process.env.ONBOARDING_STORAGE_BUCKET}/${taxDocument}`, } - - await validateAndWriteToFirestore(wallet, updatedUser, user.investorType, ['globalSteps.verifyTaxInfo']) + await writeToOnboardingBucket(Uint8Array.from(req.body), taxDocument) + await validateAndWriteToFirestore(wallet, updatedUser, user.investorType, ['taxDocument']) const freshUserData = await fetchUser(wallet) return res.status(200).send({ ...freshUserData }) diff --git a/onboarding-api/src/database/index.ts b/onboarding-api/src/database/index.ts index fc7b26a829..f7ddeb18af 100644 --- a/onboarding-api/src/database/index.ts +++ b/onboarding-api/src/database/index.ts @@ -78,10 +78,6 @@ const globalStepsSchema = object({ completed: bool(), timeStamp: string().nullable(), }), - verifyTaxInfo: object({ - completed: bool(), - timeStamp: string().nullable(), - }), verifyAccreditation: object({ completed: bool(), timeStamp: string().nullable(), @@ -109,6 +105,7 @@ export const entityUserSchema = object({ poolSteps: poolStepsSchema, manualKybReference: string().nullable().default(null), address: string().nullable().default(null), + taxDocument: string().nullable().default(null), }) export const individualUserSchema = object({ @@ -120,9 +117,10 @@ export const individualUserSchema = object({ dateOfBirth: string().required(), countryOfCitizenship: string().required(), // TODO: validate with list of countries countryOfResidency: string().required(), // TODO: validate with list of countries - globalSteps: globalStepsSchema.pick(['verifyIdentity', 'verifyAccreditation', 'verifyTaxInfo', 'verifyEmail']), + globalSteps: globalStepsSchema.pick(['verifyIdentity', 'verifyAccreditation', 'verifyEmail']).optional(), poolSteps: poolStepsSchema, address: string().nullable().default(null), + taxDocument: string().nullable().default(null), }) export type EntityUser = InferType<typeof entityUserSchema> diff --git a/onboarding-api/src/emails/index.ts b/onboarding-api/src/emails/index.ts index e5cc74da1a..75a87a921e 100644 --- a/onboarding-api/src/emails/index.ts +++ b/onboarding-api/src/emails/index.ts @@ -2,10 +2,10 @@ import * as sendgridMail from '@sendgrid/mail' import { HttpError } from '../utils/httpError' export const templateIds = { - verifyEmail: 'd-624f08ad697943929064772c0ac2aca1', - updateInvestorStatus: 'd-42fe587e381345ecb52dd072c299a499', - investorApproved: 'd-36a4418ce4144d71bfc327e907cf6c49', - investorRejected: 'd-279cfc9465054ec580f27c043f2744c6', + verifyEmail: 'd-624f08ad697943929064772c0ac2aca1', // sent to investor to verify email + updateInvestorStatus: 'd-42fe587e381345ecb52dd072c299a499', // sent to issuer to approve or reject the prospective investor + investorApproved: 'd-36a4418ce4144d71bfc327e907cf6c49', // sent to investor when approved by issuer + investorRejected: 'd-279cfc9465054ec580f27c043f2744c6', // sent to investor when rejected by issuer manualOnboardedApproved: 'd-696b01394a834ba7b88b791ef97c25f3', manualOnboardedPoolApproved: 'd-522e48402bab4976b41f7c7552918444', manualOnboardedDeclined: 'd-9439062e51f64c8d93d95f788547299d', @@ -22,7 +22,7 @@ export const sendEmail = async (message: any) => { try { return sendgridMail.send(message) } catch (error) { - console.log('email error', JSON.stringify(error)) + console.error('email error', JSON.stringify(error)) // @ts-expect-error error typing throw new HttpError(400, error?.message || 'Unable to send email verification') } diff --git a/onboarding-api/src/emails/sendDocumentsMessage.ts b/onboarding-api/src/emails/sendDocumentsMessage.ts index 3034f58382..e34ac1d192 100644 --- a/onboarding-api/src/emails/sendDocumentsMessage.ts +++ b/onboarding-api/src/emails/sendDocumentsMessage.ts @@ -1,9 +1,8 @@ import { Request } from 'express' import * as jwt from 'jsonwebtoken' import { sendEmail, templateIds } from '.' -import { onboardingBucket } from '../database' +import { fetchTaxInfo } from '../utils/fetchTaxInfo' import { fetchUser } from '../utils/fetchUser' -import { HttpError } from '../utils/httpError' import { NetworkSwitch } from '../utils/networks/networkSwitch' export type UpdateInvestorStatusPayload = { @@ -28,13 +27,23 @@ export const sendDocumentsMessage = async ( expiresIn: '14d', }) - const taxInfoFile = await onboardingBucket.file(`tax-information/${wallet.address}.pdf`) - const [taxInfoExists] = await taxInfoFile.exists() - - if (!taxInfoExists) { - throw new HttpError(400, 'Tax info not found') + const attachments = [ + { + content: Buffer.from(signedAgreement).toString('base64'), + filename: 'pool-agreement.pdf', + type: 'application/pdf', + disposition: 'attachment', + }, + ] + const taxInfoPDF = metadata.onboarding.taxInfoRequired ? await fetchTaxInfo(wallet) : null + if (taxInfoPDF) { + attachments.push({ + content: taxInfoPDF.toString('base64'), + filename: 'tax-info.pdf', + type: 'application/pdf', + disposition: 'attachment', + }) } - const taxInfoPDF = await taxInfoFile.download() const message = { personalizations: [ @@ -62,20 +71,7 @@ export const sendDocumentsMessage = async ( name: 'Centrifuge', email: 'hello@centrifuge.io', }, - attachments: [ - { - content: taxInfoPDF[0].toString('base64'), - filename: 'tax-info.pdf', - type: 'application/pdf', - disposition: 'attachment', - }, - { - content: Buffer.from(signedAgreement).toString('base64'), - filename: 'pool-agreement.pdf', - type: 'application/pdf', - disposition: 'attachment', - }, - ], + attachments, } await sendEmail(message) } diff --git a/onboarding-api/src/utils/fetchTaxInfo.ts b/onboarding-api/src/utils/fetchTaxInfo.ts new file mode 100644 index 0000000000..45b2cc4081 --- /dev/null +++ b/onboarding-api/src/utils/fetchTaxInfo.ts @@ -0,0 +1,21 @@ +import { Request } from 'express' +import { onboardingBucket } from '../database' +import { fetchUser } from './fetchUser' +import { HttpError } from './httpError' + +export const fetchTaxInfo = async (wallet: Request['wallet']) => { + const user = await fetchUser(wallet) + const path = user?.taxDocument + ? `${user.taxDocument.split('/').at(-2)}/${user.taxDocument.split('/').at(-1)}` + : `tax-information/${wallet.address}.pdf` + const taxInfo = await onboardingBucket.file(path) + + const [taxInfoExists] = await taxInfo.exists() + + if (!taxInfoExists) { + throw new HttpError(404, 'Tax info not found') + } + + const taxInfoPDF = await taxInfo.download() + return taxInfoPDF[0] +} From 35f57b9c00f89c746a9c61dbe426c3ba01673238 Mon Sep 17 00:00:00 2001 From: Onno Visser <visser.onno@gmail.com> Date: Tue, 12 Sep 2023 18:04:50 +0200 Subject: [PATCH 31/39] Centrifuge App: Fix foreign assets (#1577) --- centrifuge-js/src/modules/pools.ts | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/centrifuge-js/src/modules/pools.ts b/centrifuge-js/src/modules/pools.ts index 1c30f4e230..bb8a96f689 100644 --- a/centrifuge-js/src/modules/pools.ts +++ b/centrifuge-js/src/modules/pools.ts @@ -43,7 +43,7 @@ type CurrencyRole = 'PermissionedAssetManager' | 'PermissionedAssetIssuer' export type PoolRoleInput = AdminRole | { TrancheInvestor: [trancheId: string, permissionedTill: number] } -export type CurrencyKey = string | { ForeignAsset: number } | { Tranche: [string, string] } +export type CurrencyKey = string | { ForeignAsset: string } | { Tranche: [string, string] } export type CurrencyMetadata = { key: CurrencyKey @@ -2951,10 +2951,20 @@ export function findBalance<T extends Pick<AccountCurrencyBalance, 'currency'>>( return balances.find((balance) => looksLike(balance.currency.key, key)) } -function parseCurrencyKey(key: CurrencyKey): CurrencyKey { - if (typeof key !== 'string' && 'Tranche' in key) { - return { - Tranche: [key.Tranche[0].replace(/\D/g, ''), key.Tranche[1]], +export function parseCurrencyKey(key: CurrencyKey | { foreignAsset: number | string }): CurrencyKey { + if (typeof key === 'object') { + if ('Tranche' in key) { + return { + Tranche: [key.Tranche[0].replace(/\D/g, ''), key.Tranche[1]], + } + } else if ('ForeignAsset' in key) { + return { + ForeignAsset: String(key.ForeignAsset).replace(/\D/g, ''), + } + } else if ('foreignAsset' in key) { + return { + ForeignAsset: String(key.foreignAsset).replace(/\D/g, ''), + } } } return key From c110a133e91b1a930a890000f9cc7367f918651d Mon Sep 17 00:00:00 2001 From: Onno Visser <visser.onno@gmail.com> Date: Wed, 13 Sep 2023 11:39:32 +0200 Subject: [PATCH 32/39] Centrifuge App: Fix pool create type (#1579) --- centrifuge-app/src/pages/IssuerCreatePool/index.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/centrifuge-app/src/pages/IssuerCreatePool/index.tsx b/centrifuge-app/src/pages/IssuerCreatePool/index.tsx index 1a4bf95cdb..56f5f6791c 100644 --- a/centrifuge-app/src/pages/IssuerCreatePool/index.tsx +++ b/centrifuge-app/src/pages/IssuerCreatePool/index.tsx @@ -205,7 +205,10 @@ function CreatePoolForm() { const multisigAddr = adminMultisig && createKeyMulti(adminMultisig.signers, adminMultisig.threshold) console.log('adminMultisig', multisigAddr) const poolArgs = args.slice(2) as any - return combineLatest([cent.getApi(), cent.pools.createPool(poolArgs, { batch: true })]).pipe( + return combineLatest([ + cent.getApi(), + cent.pools.createPool(poolArgs, { createType: options?.createType, batch: true }), + ]).pipe( switchMap(([api, poolSubmittable]) => { const adminProxyDelegate = multisigAddr ?? address const otherMultisigSigners = From 85036cea98d5c97b5c4054e2c895a35a5507c0f0 Mon Sep 17 00:00:00 2001 From: Sophia <littlejohn.sophia@gmail.com> Date: Thu, 14 Sep 2023 10:28:04 -0400 Subject: [PATCH 33/39] Hide tables in liqudity tab before epoch has been closed (#1581) --- .../src/components/LiquidityTransactionsSection.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/centrifuge-app/src/components/LiquidityTransactionsSection.tsx b/centrifuge-app/src/components/LiquidityTransactionsSection.tsx index 8cce59a6c6..20e09e30aa 100644 --- a/centrifuge-app/src/components/LiquidityTransactionsSection.tsx +++ b/centrifuge-app/src/components/LiquidityTransactionsSection.tsx @@ -104,7 +104,7 @@ export default function LiquidityTransactionsSection({ : [] }, [chartData, dataColors, tooltips, pool.currency.symbol]) - return ( + return chartData?.length ? ( <PageSection title={title} titleAddition={ @@ -133,5 +133,5 @@ export default function LiquidityTransactionsSection({ <StackedBarChart data={chartData} names={dataNames} colors={dataColors} currency={pool.currency.symbol} /> )} </PageSection> - ) + ) : null } From 494eb960e8453edf282a8c1b0f19f9f54ec7468f Mon Sep 17 00:00:00 2001 From: Sophia <littlejohn.sophia@gmail.com> Date: Fri, 15 Sep 2023 05:21:14 -0400 Subject: [PATCH 34/39] Allow onboarding for users not living in country of their passport (#1583) --- onboarding-api/src/controllers/user/startKyc.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/onboarding-api/src/controllers/user/startKyc.ts b/onboarding-api/src/controllers/user/startKyc.ts index 912e9b480f..4e7ff44089 100644 --- a/onboarding-api/src/controllers/user/startKyc.ts +++ b/onboarding-api/src/controllers/user/startKyc.ts @@ -91,7 +91,7 @@ export const startKycController = async (req: Request<any, any, InferType<typeof reference: kycReference, callback_url: '', email: userData?.email ?? '', - country: body.countryOfCitizenship, + country: '', language: 'EN', redirect_url: '', verification_mode: 'any', From 9122ad08a6e82358bda6983797a4f7b0e28d6960 Mon Sep 17 00:00:00 2001 From: Onno Visser <visser.onno@gmail.com> Date: Fri, 15 Sep 2023 16:52:27 +0200 Subject: [PATCH 35/39] CentrifugeJS: Fix currencies (#1584) --- centrifuge-js/src/modules/pools.ts | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/centrifuge-js/src/modules/pools.ts b/centrifuge-js/src/modules/pools.ts index bb8a96f689..f42ee185c2 100644 --- a/centrifuge-js/src/modules/pools.ts +++ b/centrifuge-js/src/modules/pools.ts @@ -733,7 +733,7 @@ export function getPoolsModule(inst: Centrifuge) { api.query.ormlAssetRegistry.metadata(currency).pipe( take(1), switchMap((rawCurrencyMeta) => { - const currencyMeta = rawCurrencyMeta.toHuman() as AssetCurrencyData + const currencyMeta = rawCurrencyMeta.toPrimitive() as AssetCurrencyData return pinPoolMetadata(metadata, poolId, currencyMeta.decimals, options) }), switchMap((pinnedMetadata) => { @@ -1760,12 +1760,13 @@ export function getPoolsModule(inst: Centrifuge) { switchMap((api) => api.query.poolSystem.pool(poolId).pipe( switchMap((rawPool) => { - const pool = rawPool.toHuman() as any - return api.query.ormlAssetRegistry.metadata(pool.currency).pipe( + const pool = rawPool.toPrimitive() as any + const curKey = parseCurrencyKey(pool.currency) + return api.query.ormlAssetRegistry.metadata(curKey).pipe( map((rawCurrencyMeta) => { - const value = rawCurrencyMeta.toHuman() as AssetCurrencyData + const value = rawCurrencyMeta.toPrimitive() as AssetCurrencyData const currency: CurrencyMetadata = { - key: pool.currency, + key: curKey, decimals: value.decimals, name: value.name, symbol: value.symbol, @@ -2137,8 +2138,8 @@ export function getPoolsModule(inst: Centrifuge) { switchMap((api) => api.query.ormlAssetRegistry.metadata.entries()), map((rawMetas) => { const metas = rawMetas.map(([rawKey, rawValue]) => { - const key = parseCurrencyKey((rawKey.toHuman() as any)[0] as CurrencyKey) - const value = rawValue.toHuman() as AssetCurrencyData + const key = parseCurrencyKey((rawKey.toHuman() as any)[0]) + const value = rawValue.toPrimitive() as AssetCurrencyData const currency: CurrencyMetadata = { key, decimals: value.decimals, @@ -2360,7 +2361,7 @@ export function getPoolsModule(inst: Centrifuge) { api.query.priceOracle.values.entries(), api.query.interestAccrual.rates(), api.query.interestAccrual.lastUpdated(), - api.query.ormlAssetRegistry.metadata((poolValue.toHuman() as any).currency), + api.query.ormlAssetRegistry.metadata((poolValue.toPrimitive() as any).currency), ]).pipe(take(1)) }), map( @@ -2373,7 +2374,7 @@ export function getPoolsModule(inst: Centrifuge) { interestLastUpdated, rawCurrency, ]) => { - const currency = rawCurrency.toHuman() as AssetCurrencyData + const currency = rawCurrency.toPrimitive() as AssetCurrencyData const rates = rateValues.toPrimitive() as InterestAccrual[] const oraclePrices: Record< From aa1df751bc0f89be7a79d25ec62a09e1fdc43fe8 Mon Sep 17 00:00:00 2001 From: JP <jp@k-f.co> Date: Mon, 18 Sep 2023 11:19:11 -0500 Subject: [PATCH 36/39] approve token fix (#1574) * fix * typo and revert env --- .../src/components/InvestRedeem/InvestRedeem.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/centrifuge-app/src/components/InvestRedeem/InvestRedeem.tsx b/centrifuge-app/src/components/InvestRedeem/InvestRedeem.tsx index caf1a0e0ac..c69c072b6d 100644 --- a/centrifuge-app/src/components/InvestRedeem/InvestRedeem.tsx +++ b/centrifuge-app/src/components/InvestRedeem/InvestRedeem.tsx @@ -534,7 +534,11 @@ function InvestForm({ onCancel, hasInvestment, autoFocus, investLabel = 'Invest' </Stack> </Stack> ) : changeOrderFormShown ? ( - renderInput(() => setChangeOrderFormShown(false)) + state.needsPoolCurrencyApproval ? ( + renderInput(onCancel, { onClick: actions.approvePoolCurrency, loading: isApproving }) + ) : ( + renderInput(onCancel) + ) ) : hasPendingOrder ? ( <PendingOrder type="invest" @@ -705,7 +709,11 @@ function RedeemForm({ onCancel, autoFocus }: RedeemFormProps) { </Stack> </Stack> ) : changeOrderFormShown ? ( - renderInput(() => setChangeOrderFormShown(false)) + state.needsTrancheTokenApproval ? ( + renderInput(onCancel, { onClick: actions.approveTrancheToken, loading: isApproving }) + ) : ( + renderInput(onCancel) + ) ) : hasPendingOrder ? ( <PendingOrder type="redeem" From 955d727ea538e5270ee6bdd0ff3312766327bffd Mon Sep 17 00:00:00 2001 From: JP <jp@k-f.co> Date: Mon, 18 Sep 2023 11:29:49 -0500 Subject: [PATCH 37/39] add upcoming pools (#1586) * add upcoming pools * reorder pools * fix alignment --- .../src/components/PoolCard/PoolStatus.tsx | 3 ++- .../src/components/PoolCard/index.tsx | 7 +++--- .../src/components/PoolFilter/SortButton.tsx | 7 +++--- .../src/components/PoolFilter/index.tsx | 2 +- centrifuge-app/src/pages/Pools.tsx | 23 +++++++++++++++++-- 5 files changed, 32 insertions(+), 10 deletions(-) diff --git a/centrifuge-app/src/components/PoolCard/PoolStatus.tsx b/centrifuge-app/src/components/PoolCard/PoolStatus.tsx index 60acdb11ac..57c25f7285 100644 --- a/centrifuge-app/src/components/PoolCard/PoolStatus.tsx +++ b/centrifuge-app/src/components/PoolCard/PoolStatus.tsx @@ -1,12 +1,13 @@ import { StatusChip, StatusChipProps } from '@centrifuge/fabric' import * as React from 'react' -export type PoolStatusKey = 'Maker Pool' | 'Open for investments' | 'Closed' +export type PoolStatusKey = 'Maker Pool' | 'Open for investments' | 'Closed' | 'Upcoming' const statusColor: { [key in PoolStatusKey]: StatusChipProps['status'] } = { 'Maker Pool': 'ok', 'Open for investments': 'info', Closed: 'default', + Upcoming: 'default', } export function PoolStatus({ status }: { status?: PoolStatusKey }) { diff --git a/centrifuge-app/src/components/PoolCard/index.tsx b/centrifuge-app/src/components/PoolCard/index.tsx index 9f984f3e47..3eaf04289e 100644 --- a/centrifuge-app/src/components/PoolCard/index.tsx +++ b/centrifuge-app/src/components/PoolCard/index.tsx @@ -1,5 +1,5 @@ import { Rate } from '@centrifuge/centrifuge-js' -import { Box, Grid, TextWithPlaceholder, Thumbnail } from '@centrifuge/fabric' +import { Box, Grid, Text, TextWithPlaceholder, Thumbnail } from '@centrifuge/fabric' import Decimal from 'decimal.js-light' import * as React from 'react' import { useRouteMatch } from 'react-router' @@ -70,7 +70,7 @@ export function PoolCard({ variant="body1" color="textPrimary" fontWeight={500} - textAlign="right" + textAlign="left" isLoading={isLoading} maxLines={1} > @@ -82,6 +82,7 @@ export function PoolCard({ }) : '—'} </Ellipsis> + {status === 'Upcoming' ? <Text variant="body3"> target</Text> : ''} </TextWithPlaceholder> <Box> @@ -89,7 +90,7 @@ export function PoolCard({ </Box> </Grid> - <Anchor to={`${basePath}/${poolId}`} aria-label="Go to pool details" /> + {status === 'Upcoming' ? null : <Anchor to={`${basePath}/${poolId}`} aria-label="Go to pool details" />} </Root> ) } diff --git a/centrifuge-app/src/components/PoolFilter/SortButton.tsx b/centrifuge-app/src/components/PoolFilter/SortButton.tsx index 4354aee38a..ba31ffe790 100644 --- a/centrifuge-app/src/components/PoolFilter/SortButton.tsx +++ b/centrifuge-app/src/components/PoolFilter/SortButton.tsx @@ -9,6 +9,7 @@ export type SortButtonProps = { label: string searchKey: SortBy tooltip?: string + justifySelf?: 'start' | 'end' } type Sorting = { @@ -16,7 +17,7 @@ type Sorting = { direction: string | null } -export function SortButton({ label, searchKey, tooltip }: SortButtonProps) { +export function SortButton({ label, searchKey, tooltip, justifySelf = 'end' }: SortButtonProps) { const history = useHistory() const { pathname, search } = useLocation() @@ -58,7 +59,7 @@ export function SortButton({ label, searchKey, tooltip }: SortButtonProps) { : `Sort ${label} ascending` } aria-live - style={{ justifySelf: 'end' }} + style={{ justifySelf }} > <FilterButton forwardedAs="span" variant="body3"> {label} @@ -82,7 +83,7 @@ export function SortButton({ label, searchKey, tooltip }: SortButtonProps) { : `Sort ${label} ascending` } aria-live - style={{ justifySelf: 'end' }} + style={{ justifySelf }} > {label} diff --git a/centrifuge-app/src/components/PoolFilter/index.tsx b/centrifuge-app/src/components/PoolFilter/index.tsx index 87ead21872..a3fc4d1ede 100644 --- a/centrifuge-app/src/components/PoolFilter/index.tsx +++ b/centrifuge-app/src/components/PoolFilter/index.tsx @@ -38,7 +38,7 @@ export function PoolFilter({ pools }: PoolFilterProps) { tooltip="Value locked represents the current total value of pool tokens." /> - <SortButton {...poolFilterConfig.apr} /> + <SortButton {...poolFilterConfig.apr} justifySelf="start" /> <FilterMenu {...poolFilterConfig.poolStatus} diff --git a/centrifuge-app/src/pages/Pools.tsx b/centrifuge-app/src/pages/Pools.tsx index 00c1445c7a..11f7f9f204 100644 --- a/centrifuge-app/src/pages/Pools.tsx +++ b/centrifuge-app/src/pages/Pools.tsx @@ -1,4 +1,4 @@ -import Centrifuge, { Pool, PoolMetadata } from '@centrifuge/centrifuge-js' +import Centrifuge, { Pool, PoolMetadata, Rate } from '@centrifuge/centrifuge-js' import { useCentrifuge } from '@centrifuge/centrifuge-react' import { Box, InlineFeedback, Shelf, Stack, Text } from '@centrifuge/fabric' import * as React from 'react' @@ -39,7 +39,26 @@ function Pools() { ).map((q) => q.data) const centPoolsMetaDataById = getMetasById(centPools, centPoolsMetaData) - const pools = !!listedPools?.length ? poolsToPoolCardProps(listedPools, centPoolsMetaDataById, cent) : [] + const upcomingPools = [ + { + apr: Rate.fromApr(0.08), + assetClass: 'Real Estate Bridge Loans', + iconUri: 'https://storage.googleapis.com/tinlake/pool-media/new-silver-2/icon.svg', + name: 'New Silver Series 3', + status: 'Upcoming' as PoolStatusKey, + }, + { + apr: Rate.fromApr(0.15), + assetClass: 'Voluntary Carbon Offsets', + iconUri: 'https://storage.googleapis.com/tinlake/pool-media/flowcarbon-1/FlowcarbonBadge.svg', + name: 'Flowcarbon Nature Offsets Series 2', + status: 'Upcoming' as PoolStatusKey, + }, + ] + + const pools = !!listedPools?.length + ? [...upcomingPools, ...poolsToPoolCardProps(listedPools, centPoolsMetaDataById, cent)] + : [...upcomingPools] const filteredPools = !!pools?.length ? filterPools(pools, new URLSearchParams(search)) : [] if (!listedPools.length) { From 981ee1e8dd1714913019bdf8c371d04f1b9d310e Mon Sep 17 00:00:00 2001 From: JP <jp@k-f.co> Date: Mon, 18 Sep 2023 16:16:51 -0500 Subject: [PATCH 38/39] chore: change anemoy status (#1587) --- .../src/components/PoolCard/index.tsx | 2 +- centrifuge-app/src/components/PoolList.tsx | 12 +++++++-- centrifuge-app/src/pages/Pools.tsx | 25 +++++++++++++++++-- 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/centrifuge-app/src/components/PoolCard/index.tsx b/centrifuge-app/src/components/PoolCard/index.tsx index 3eaf04289e..408a2e4cec 100644 --- a/centrifuge-app/src/components/PoolCard/index.tsx +++ b/centrifuge-app/src/components/PoolCard/index.tsx @@ -82,7 +82,7 @@ export function PoolCard({ }) : '—'} </Ellipsis> - {status === 'Upcoming' ? <Text variant="body3"> target</Text> : ''} + {status === 'Upcoming' && apr ? <Text variant="body3"> target</Text> : ''} </TextWithPlaceholder> <Box> diff --git a/centrifuge-app/src/components/PoolList.tsx b/centrifuge-app/src/components/PoolList.tsx index 017b5017b1..7a3b7d6b92 100644 --- a/centrifuge-app/src/components/PoolList.tsx +++ b/centrifuge-app/src/components/PoolList.tsx @@ -1,12 +1,20 @@ import { Box, Stack } from '@centrifuge/fabric' import * as React from 'react' +import styled from 'styled-components' import { PoolCard, PoolCardProps } from './PoolCard' +import { PoolStatusKey } from './PoolCard/PoolStatus' type PoolListProps = { pools: PoolCardProps[] isLoading?: boolean } +const PoolCardBox = styled<typeof Box & { status?: PoolStatusKey }>(Box)` + &:hover { + cursor: ${(props) => (props.status === 'Upcoming' ? 'not-allowed' : 'default')}; + } +` + export function PoolList({ pools, isLoading }: PoolListProps) { return ( <Stack as="ul" role="list" gap={1} minWidth={970} py={1}> @@ -19,9 +27,9 @@ export function PoolList({ pools, isLoading }: PoolListProps) { </Box> )) : pools.map((pool) => ( - <Box as="li" key={pool.poolId}> + <PoolCardBox as="li" key={pool.poolId} status={pool.status}> <PoolCard {...pool} /> - </Box> + </PoolCardBox> ))} </Stack> ) diff --git a/centrifuge-app/src/pages/Pools.tsx b/centrifuge-app/src/pages/Pools.tsx index 11f7f9f204..e06861c75c 100644 --- a/centrifuge-app/src/pages/Pools.tsx +++ b/centrifuge-app/src/pages/Pools.tsx @@ -31,7 +31,7 @@ export function PoolsPage() { function Pools() { const cent = useCentrifuge() const { search } = useLocation() - const [listedPools, _listedTokens, metadataIsLoading] = useListedPools() + const [listedPools, , metadataIsLoading] = useListedPools() const centPools = listedPools.filter(({ id }) => !id.startsWith('0x')) as Pool[] const centPoolsMetaData: PoolMetaDataPartial[] = useMetadataMulti<PoolMetadata>( @@ -57,7 +57,28 @@ function Pools() { ] const pools = !!listedPools?.length - ? [...upcomingPools, ...poolsToPoolCardProps(listedPools, centPoolsMetaDataById, cent)] + ? [ + ...upcomingPools, + ...poolsToPoolCardProps(listedPools, centPoolsMetaDataById, cent).map((pool) => { + if (pool.name?.includes('Anemoy Liquid Treasury Fund')) { + return { + ...pool, + status: 'Upcoming' as PoolStatusKey, + apr: Rate.fromApr(0.05), + } + } + + return pool + }), + ].sort((a, b) => { + if (a.status === 'Upcoming') { + return -1 + } + if (b.status === 'Upcoming') { + return 1 + } + return 0 + }) : [...upcomingPools] const filteredPools = !!pools?.length ? filterPools(pools, new URLSearchParams(search)) : [] From 0a38bea87c927698382346ae5a20291d973a6df9 Mon Sep 17 00:00:00 2001 From: Sophia <littlejohn.sophia@gmail.com> Date: Mon, 18 Sep 2023 17:50:45 -0400 Subject: [PATCH 39/39] Display LpEthUSDC as USDC (#1588) --- centrifuge-js/src/modules/pools.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/centrifuge-js/src/modules/pools.ts b/centrifuge-js/src/modules/pools.ts index f42ee185c2..2bd907b18a 100644 --- a/centrifuge-js/src/modules/pools.ts +++ b/centrifuge-js/src/modules/pools.ts @@ -1623,6 +1623,11 @@ export function getPoolsModule(inst: Centrifuge) { const epochExecution = epochExecutionMap[poolId] const currency = findCurrency(currencies, pool.currency)! + // TODO: remove, temporary UI fix + if (currency.symbol === 'LpEthUSDC') { + currency.symbol = 'USDC' + } + const poolValue = new CurrencyBalance( pool.tranches.tranches.reduce((prev: BN, tranche: TrancheDetailsData) => { return new BN(prev.add(new BN(hexToBN(tranche.debt))).add(new BN(hexToBN(tranche.reserve))))