diff --git a/.nvmrc b/.nvmrc
index 6f7f377bf5..3f430af82b 100644
--- a/.nvmrc
+++ b/.nvmrc
@@ -1 +1 @@
-v16
+v18
diff --git a/centrifuge-app/src/assets/images/aave-token-logo.svg b/centrifuge-app/src/assets/images/aave-token-logo.svg
new file mode 100644
index 0000000000..d784fbe0a4
--- /dev/null
+++ b/centrifuge-app/src/assets/images/aave-token-logo.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/centrifuge-app/src/components/DataTable.tsx b/centrifuge-app/src/components/DataTable.tsx
index d9bdfaec2d..caa3f9a0dd 100644
--- a/centrifuge-app/src/components/DataTable.tsx
+++ b/centrifuge-app/src/components/DataTable.tsx
@@ -1,10 +1,27 @@
-import { Grid, IconChevronDown, IconChevronUp, Shelf, Stack, Text } from '@centrifuge/fabric'
+import {
+ Box,
+ Checkbox,
+ Divider,
+ Grid,
+ IconChevronDown,
+ IconChevronUp,
+ IconFilter,
+ Menu,
+ Popover,
+ Shelf,
+ Stack,
+ Text,
+ Tooltip,
+} from '@centrifuge/fabric'
import css from '@styled-system/css'
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'
+import { FiltersState } from '../utils/useFilters'
+import { FilterButton } from './FilterButton'
+import { QuickAction } from './QuickAction'
type GroupedProps = {
groupIndex?: number
@@ -254,8 +271,110 @@ export function SortableTableHeader({
)
}
+export function FilterableTableHeader({
+ filterKey: key,
+ label,
+ options,
+ filters,
+ tooltip,
+}: {
+ filterKey: string
+ label: string
+ options: string[] | Record
+ filters: FiltersState
+ tooltip?: string
+}) {
+ const optionKeys = Array.isArray(options) ? options : Object.keys(options)
+ const form = React.useRef(null)
+
+ function handleChange() {
+ if (!form.current) return
+ const formData = new FormData(form.current)
+ const entries = formData.getAll(key) as string[]
+ filters.setFilter(key, entries)
+ }
+
+ function deselectAll() {
+ filters.setFilter(key, [])
+ }
+
+ function selectAll() {
+ filters.setFilter(key, optionKeys)
+ }
+ const state = filters.getState()
+ const selectedOptions = state[key] as Set | undefined
+
+ return (
+
+ {
+ return (
+
+ {tooltip ? (
+
+
+ {label}
+
+
+
+ ) : (
+
+ {label}
+
+
+ )}
+
+ )
+ }}
+ renderContent={(props, ref) => (
+
+
+
+ )}
+ />
+
+ )
+}
+
const StyledHeader = styled(Shelf)`
- color: ${({ theme }) => theme.colors.textSecondary};
+ color: ${({ theme }) => theme.colors.textPrimary};
cursor: pointer;
appearance: none;
border: none;
diff --git a/centrifuge-app/src/components/DebugFlags/config.ts b/centrifuge-app/src/components/DebugFlags/config.ts
index 714a67508f..f62aee2939 100644
--- a/centrifuge-app/src/components/DebugFlags/config.ts
+++ b/centrifuge-app/src/components/DebugFlags/config.ts
@@ -47,6 +47,7 @@ export type Key =
| 'showPodAccountCreation'
| 'convertEvmAddress'
| 'showPortfolio'
+ | 'showPrime'
| 'poolCreationType'
export const flagsConfig: Record = {
@@ -121,6 +122,11 @@ export const flagsConfig: Record = {
default: false,
alwaysShow: true,
},
+ showPrime: {
+ type: 'checkbox',
+ default: false,
+ alwaysShow: true,
+ },
poolCreationType: {
type: 'select',
default: config.poolCreationType || 'immediate',
diff --git a/centrifuge-app/src/components/LayoutBase/BasePadding.tsx b/centrifuge-app/src/components/LayoutBase/BasePadding.tsx
index 05e1dbe02a..9c9ef1b4b6 100644
--- a/centrifuge-app/src/components/LayoutBase/BasePadding.tsx
+++ b/centrifuge-app/src/components/LayoutBase/BasePadding.tsx
@@ -7,7 +7,7 @@ type BaseSectionProps = BoxProps & {
export function BasePadding({ children, ...boxProps }: BaseSectionProps) {
return (
-
+
{children}
)
diff --git a/centrifuge-app/src/components/LayoutBase/LayoutSection.tsx b/centrifuge-app/src/components/LayoutBase/LayoutSection.tsx
new file mode 100644
index 0000000000..237a2a1ff2
--- /dev/null
+++ b/centrifuge-app/src/components/LayoutBase/LayoutSection.tsx
@@ -0,0 +1,43 @@
+import { Box, BoxProps, Shelf, Stack, Text } from '@centrifuge/fabric'
+import * as React from 'react'
+import { BasePadding } from './BasePadding'
+
+type Props = {
+ title?: React.ReactNode
+ titleAddition?: React.ReactNode
+ subtitle?: string
+ headerRight?: React.ReactNode
+ children: React.ReactNode
+} & BoxProps
+
+export function LayoutSection({ title, titleAddition, subtitle, headerRight, children, ...boxProps }: Props) {
+ return (
+
+ {(title || titleAddition || subtitle || headerRight) && (
+
+
+ {(title || titleAddition) && (
+
+ {title && (
+
+ {title}
+
+ )}
+
+ {titleAddition}
+
+
+ )}
+ {subtitle && (
+
+ {subtitle}
+
+ )}
+
+ {headerRight}
+
+ )}
+ {children}
+
+ )
+}
diff --git a/centrifuge-app/src/components/Menu/index.tsx b/centrifuge-app/src/components/Menu/index.tsx
index 89f774b5a8..4fd89bd618 100644
--- a/centrifuge-app/src/components/Menu/index.tsx
+++ b/centrifuge-app/src/components/Menu/index.tsx
@@ -1,6 +1,7 @@
import {
Box,
IconClock,
+ IconGlobe,
IconInvestments,
IconNft,
IconPieChart,
@@ -24,7 +25,7 @@ export function Menu() {
const pools = usePoolsThatAnyConnectedAddressHasPermissionsFor() || []
const isLarge = useIsAboveBreakpoint('L')
const address = useAddress('substrate')
- const { showPortfolio } = useDebugFlags()
+ const { showPortfolio, showPrime } = useDebugFlags()
return (
)}
+ {showPrime && (
+
+
+ Prime
+
+ )}
{showPortfolio && address && (
diff --git a/centrifuge-app/src/components/Root.tsx b/centrifuge-app/src/components/Root.tsx
index 99c61dfb53..41736f4bd8 100644
--- a/centrifuge-app/src/components/Root.tsx
+++ b/centrifuge-app/src/components/Root.tsx
@@ -169,6 +169,8 @@ const PoolDetailPage = React.lazy(() => import('../pages/Pool'))
const PortfolioPage = React.lazy(() => import('../pages/Portfolio'))
const TransactionHistoryPage = React.lazy(() => import('../pages/Portfolio/TransactionHistory'))
const TokenOverviewPage = React.lazy(() => import('../pages/Tokens'))
+const PrimePage = React.lazy(() => import('../pages/Prime'))
+const PrimeDetailPage = React.lazy(() => import('../pages/Prime/Detail'))
function Routes() {
return (
@@ -218,6 +220,12 @@ function Routes() {
+
+
+
+
+
+
diff --git a/centrifuge-app/src/components/styles.ts b/centrifuge-app/src/components/styles.ts
index 2203668ef8..7617c1d354 100644
--- a/centrifuge-app/src/components/styles.ts
+++ b/centrifuge-app/src/components/styles.ts
@@ -7,8 +7,8 @@ export const buttonActionStyles = css`
background-color: transparent;
border-radius: ${({ theme }) => theme.radii.tooltip}px;
+ &:hover,
&:focus-visible {
- outline: ${({ theme }) => `2px solid ${theme.colors.textSelected}`};
- outline-offset: 4px;
+ color: ${({ theme }) => theme.colors.textInteractiveHover};
}
`
diff --git a/centrifuge-app/src/pages/IssuerCreatePool/index.tsx b/centrifuge-app/src/pages/IssuerCreatePool/index.tsx
index e4e37b2d2d..7f57730056 100644
--- a/centrifuge-app/src/pages/IssuerCreatePool/index.tsx
+++ b/centrifuge-app/src/pages/IssuerCreatePool/index.tsx
@@ -209,7 +209,6 @@ function CreatePoolForm() {
) => {
const [transferToMultisig, aoProxy, adminProxy, , , , , , { adminMultisig }] = args
const multisigAddr = adminMultisig && createKeyMulti(adminMultisig.signers, adminMultisig.threshold)
- console.log('adminMultisig', multisigAddr)
const poolArgs = args.slice(2) as any
return combineLatest([
cent.getApi(),
diff --git a/centrifuge-app/src/pages/Prime/Detail.tsx b/centrifuge-app/src/pages/Prime/Detail.tsx
new file mode 100644
index 0000000000..670153f93d
--- /dev/null
+++ b/centrifuge-app/src/pages/Prime/Detail.tsx
@@ -0,0 +1,15 @@
+import { useParams } from 'react-router'
+import { LayoutBase } from '../../components/LayoutBase'
+
+export default function PrimeDetailPage() {
+ return (
+
+
+
+ )
+}
+
+function PrimeDetail() {
+ const { dao } = useParams<{ dao: string }>()
+ return Prime detail, {dao}
+}
diff --git a/centrifuge-app/src/pages/Prime/index.tsx b/centrifuge-app/src/pages/Prime/index.tsx
new file mode 100644
index 0000000000..0dd310a991
--- /dev/null
+++ b/centrifuge-app/src/pages/Prime/index.tsx
@@ -0,0 +1,209 @@
+import { Price } from '@centrifuge/centrifuge-js'
+import { Network, useCentrifuge, useCentrifugeUtils, useGetNetworkName } from '@centrifuge/centrifuge-react'
+import { AnchorButton, Box, IconExternalLink, Shelf, Text, TextWithPlaceholder } from '@centrifuge/fabric'
+import { useQuery } from 'react-query'
+import { firstValueFrom } from 'rxjs'
+import aaveLogo from '../../assets/images/aave-token-logo.svg'
+import { Column, DataTable, FilterableTableHeader, SortableTableHeader } from '../../components/DataTable'
+import { LayoutBase } from '../../components/LayoutBase'
+import { LayoutSection } from '../../components/LayoutBase/LayoutSection'
+import { formatDate } from '../../utils/date'
+import { formatBalance, formatPercentage } from '../../utils/formatting'
+import { useFilters } from '../../utils/useFilters'
+import { useSubquery } from '../../utils/useSubquery'
+
+type DAO = {
+ slug: string
+ name: string
+ network: Network
+ address: string
+ icon: string
+}
+
+const DAOs: DAO[] = [
+ {
+ slug: 'aave',
+ name: 'Aave',
+ network: 'centrifuge',
+ address: 'kALNreUp6oBmtfG87fe7MakWR8BnmQ4SmKjjfG27iVd3nuTue',
+ icon: aaveLogo,
+ },
+]
+
+export default function PrimePage() {
+ return (
+
+
+
+ )
+}
+
+function Prime() {
+ return (
+ <>
+
+ Centrifuge Prime
+
+
+ Centrifuge Prime was built to meet the needs of large decentralized organizations and protocols. Through
+ Centrifuge Prime, DeFi native organizations can integrate with the largest financial markets in the world
+ and take advantage of real yields from real economic activity - all onchain. Assets tailored to your needs,
+ processes adapted to your governance, and all through decentralized rails.
+
+
+
+
+ Go to website
+
+
+
+
+ >
+ )
+}
+
+type Row = DAO & { value?: number; profit?: number; networkName: string; firstInvestment?: Date }
+
+function DaoPortfoliosTable() {
+ const utils = useCentrifugeUtils()
+ const cent = useCentrifuge()
+ const getNetworkName = useGetNetworkName()
+
+ const daos = DAOs.map((dao) => ({
+ ...dao,
+ address: utils.formatAddress(
+ typeof dao.network === 'number' ? utils.evmToSubstrateAddress(dao.address, dao.network) : dao.address
+ ),
+ }))
+
+ // TODO: Update to use new portfolio Runtime API
+ const { data, isLoading: isPortfoliosLoading } = useQuery(['daoPortfolios', daos.map((dao) => dao.address)], () =>
+ Promise.all(daos.map((dao) => firstValueFrom(cent.pools.getBalances([dao.address]))))
+ )
+
+ const { data: subData, isLoading: isSubqueryLoading } = useSubquery(
+ `query ($accounts: [String!]) {
+ accounts(
+ filter: {id: {in: $accounts}}
+ ) {
+ nodes {
+ id
+ investorTransactions {
+ nodes {
+ timestamp
+ tokenPrice
+ tranche {
+ tokenPrice
+ trancheId
+ }
+ }
+ }
+ }
+ }
+ }`,
+ {
+ accounts: daos.map((dao) => dao.address),
+ }
+ )
+
+ const mapped: Row[] = daos.map((dao, i) => {
+ const investTxs = subData?.accounts.nodes.find((n: any) => n.id === dao.address)?.investorTransactions.nodes
+ const trancheBalances =
+ data?.[i].tranches && Object.fromEntries(data[i].tranches.map((t) => [t.trancheId, t.balance.toFloat()]))
+ const yields =
+ trancheBalances &&
+ Object.keys(trancheBalances).map((tid) => {
+ const firstTx = investTxs?.find((tx: any) => tx.tranche.trancheId === tid)
+ const initialTokenPrice = firstTx && new Price(firstTx.tokenPrice).toFloat()
+ const tokenPrice = firstTx && new Price(firstTx.tranche.tokenPrice).toFloat()
+ const profit = tokenPrice / initialTokenPrice - 1
+ return [tid, profit] as const
+ })
+ const totalValue = trancheBalances && Object.values(trancheBalances)?.reduce((acc, balance) => acc + balance, 0)
+ const weightedYield =
+ yields &&
+ totalValue &&
+ yields.reduce((acc, [tid, profit]) => acc + profit * trancheBalances![tid], 0) / totalValue
+
+ return {
+ ...dao,
+ value: totalValue ?? 0,
+ profit: weightedYield ? weightedYield * 100 : 0,
+ networkName: getNetworkName(dao.network),
+ firstInvestment: investTxs?.[0] && new Date(investTxs[0].timestamp),
+ }
+ })
+
+ const uniqueNetworks = [...new Set(daos.map((dao) => dao.network))]
+ const filters = useFilters({ data: mapped })
+
+ const columns: Column[] = [
+ {
+ align: 'left',
+ header: 'DAO',
+ cell: (row: Row) => (
+
+
+ {row.name}
+
+ ),
+ },
+ {
+ align: 'left',
+ header: (
+ [chain, getNetworkName(chain)]))}
+ />
+ ),
+ cell: (row: Row) => {row.networkName},
+ },
+ {
+ header: ,
+ cell: (row: Row) => (
+
+ {row.value != null && formatBalance(row.value, 'USD')}
+
+ ),
+ sortKey: 'value',
+ },
+ {
+ header: ,
+ cell: (row: Row) => (
+
+ {row.profit != null && formatPercentage(row.profit)}
+
+ ),
+ sortKey: 'profit',
+ },
+ {
+ align: 'left',
+ header: 'First investment',
+ cell: (row: Row) => (
+
+ {row.firstInvestment ? formatDate(row.firstInvestment) : '-'}
+
+ ),
+ width: '5fr',
+ },
+ ]
+
+ return (
+
+ `/prime/${row.slug}`}
+ />
+
+ )
+}
diff --git a/centrifuge-app/src/utils/useFilters.ts b/centrifuge-app/src/utils/useFilters.ts
new file mode 100644
index 0000000000..5d2c23f127
--- /dev/null
+++ b/centrifuge-app/src/utils/useFilters.ts
@@ -0,0 +1,72 @@
+import { useEventCallback } from '@centrifuge/fabric'
+import get from 'lodash/get'
+import * as React from 'react'
+import { useHistory, useLocation } from 'react-router'
+
+export type PaginationProps = {
+ key?: string // In case more than one table on the page uses search params for filters
+ data?: T[]
+ useSearchParams?: boolean
+}
+
+export function useFilters({ key: prefix = 'f_', data = [], useSearchParams = true }: PaginationProps = {}) {
+ const history = useHistory()
+ const { search } = useLocation()
+ const [params, setParams] = React.useState(() => new URLSearchParams(useSearchParams ? search : undefined))
+ const state: Record> = {}
+
+ for (const [prefixedKey, value] of params.entries()) {
+ if (!prefixedKey.startsWith(prefix)) continue
+ const key = prefixedKey.slice(prefix.length)
+ if (state[key]) {
+ state[key].add(value)
+ } else {
+ state[key] = new Set([value])
+ }
+ }
+
+ const setFilter = useEventCallback((key: string, value: string[]) => {
+ setParams((prev) => {
+ const params = new URLSearchParams(prev)
+ params.delete(prefix + key)
+ value.forEach((value, i) => {
+ if (i === 0) {
+ params.set(prefix + key, value)
+ } else {
+ params.append(prefix + key, value)
+ }
+ })
+ return params
+ })
+ })
+
+ const hasFilter = useEventCallback((key: string, value: string) => {
+ return !!state[key]?.has(String(value))
+ })
+
+ const getState = useGetLatest(state)
+
+ const entries = Object.entries(state)
+ const filtered = data.filter((entry) => entries.every(([key, set]) => set.has(String(get(entry, key)))))
+
+ React.useEffect(() => {
+ history.replace({ search: params.toString() })
+ }, [params, history])
+
+ return {
+ setFilter,
+ hasFilter,
+ data: filtered,
+ state,
+ getState,
+ }
+}
+
+export type FiltersState = ReturnType
+
+function useGetLatest(obj: T): () => T {
+ const ref = React.useRef(obj)
+ ref.current = obj
+
+ return React.useState(() => () => ref.current)[0]
+}
diff --git a/centrifuge-app/src/utils/useSubquery.ts b/centrifuge-app/src/utils/useSubquery.ts
new file mode 100644
index 0000000000..ed5eaf5fb5
--- /dev/null
+++ b/centrifuge-app/src/utils/useSubquery.ts
@@ -0,0 +1,10 @@
+import { useCentrifuge } from '@centrifuge/centrifuge-react'
+import { useQuery } from 'react-query'
+import { firstValueFrom } from 'rxjs'
+
+export function useSubquery(query: string, variables?: object) {
+ const cent = useCentrifuge()
+ return useQuery(['subquery', query, variables], () =>
+ firstValueFrom(cent.getSubqueryObservable(query, variables, false))
+ )
+}
diff --git a/fabric/src/components/FileUpload/index.tsx b/fabric/src/components/FileUpload/index.tsx
index 297d718bec..ad403277eb 100644
--- a/fabric/src/components/FileUpload/index.tsx
+++ b/fabric/src/components/FileUpload/index.tsx
@@ -5,7 +5,7 @@ import IconAlertCircle from '../../icon/IconAlertCircle'
import IconSpinner from '../../icon/IconSpinner'
import IconUpload from '../../icon/IconUpload'
import IconX from '../../icon/IconX'
-import useControlledState from '../../utils/useControlledState'
+import { useControlledState } from '../../utils/useControlledState'
import { Box } from '../Box'
import { Button } from '../Button'
import { Flex } from '../Flex'
diff --git a/fabric/src/components/ImageUpload/index.tsx b/fabric/src/components/ImageUpload/index.tsx
index d4d4f8861e..cceac9b8f5 100644
--- a/fabric/src/components/ImageUpload/index.tsx
+++ b/fabric/src/components/ImageUpload/index.tsx
@@ -4,7 +4,7 @@ import { ResponsiveValue } from 'styled-system'
import IconUpload from '../../icon/IconUpload'
import IconX from '../../icon/IconX'
import { Size } from '../../utils/types'
-import useControlledState from '../../utils/useControlledState'
+import { useControlledState } from '../../utils/useControlledState'
import { Box } from '../Box'
import { Button } from '../Button'
import { Flex } from '../Flex'
diff --git a/fabric/src/components/InteractiveCard/index.tsx b/fabric/src/components/InteractiveCard/index.tsx
index 341776c061..b2aeaacb15 100644
--- a/fabric/src/components/InteractiveCard/index.tsx
+++ b/fabric/src/components/InteractiveCard/index.tsx
@@ -1,7 +1,7 @@
import * as React from 'react'
import styled, { useTheme } from 'styled-components'
import { IconChevronRight } from '../../icon'
-import useControlledState from '../../utils/useControlledState'
+import { useControlledState } from '../../utils/useControlledState'
import { Box } from '../Box'
import { VisualButton } from '../Button'
import { Card, CardProps } from '../Card'
diff --git a/fabric/src/components/Pagination/usePagination.ts b/fabric/src/components/Pagination/usePagination.ts
index 8936f8d404..3c80f3192c 100644
--- a/fabric/src/components/Pagination/usePagination.ts
+++ b/fabric/src/components/Pagination/usePagination.ts
@@ -1,6 +1,6 @@
import * as React from 'react'
-import useControlledState from '../../utils/useControlledState'
-import useEventCallback from '../../utils/useEventCallback'
+import { useControlledState } from '../../utils/useControlledState'
+import { useEventCallback } from '../../utils/useEventCallback'
export type PaginationProps = {
manual?: boolean
diff --git a/fabric/src/index.ts b/fabric/src/index.ts
index d8811ce946..303a1a3029 100644
--- a/fabric/src/index.ts
+++ b/fabric/src/index.ts
@@ -46,4 +46,5 @@ export * from './components/Tooltip'
export * from './icon/index'
export * from './theme'
export { mapResponsive, toPx } from './utils/styled'
-export { default as useControlledState } from './utils/useControlledState'
+export { useControlledState } from './utils/useControlledState'
+export { useEventCallback } from './utils/useEventCallback'
diff --git a/fabric/src/utils/useControlledState.ts b/fabric/src/utils/useControlledState.ts
index 6903a82590..fee802bcfa 100644
--- a/fabric/src/utils/useControlledState.ts
+++ b/fabric/src/utils/useControlledState.ts
@@ -1,7 +1,7 @@
import { Dispatch, SetStateAction, useState } from 'react'
-import useEventCallback from './useEventCallback'
+import { useEventCallback } from './useEventCallback'
-function useControlledState | Dispatch>>(
+export function useControlledState | Dispatch>>(
initialUncontrolledValue: T,
externalValue?: T,
setExternalValue?: D
@@ -17,5 +17,3 @@ function useControlledState | Dispatch>>(
return [value, setValue as any]
}
-
-export default useControlledState
diff --git a/fabric/src/utils/useEventCallback.ts b/fabric/src/utils/useEventCallback.ts
index 12904fff7c..e6a1344768 100644
--- a/fabric/src/utils/useEventCallback.ts
+++ b/fabric/src/utils/useEventCallback.ts
@@ -2,7 +2,7 @@ import { useCallback, useLayoutEffect, useRef } from 'react'
type Calback = (...args: any[]) => any
-function useEventCallback(callback: T) {
+export function useEventCallback(callback: T) {
const ref = useRef((() => {
throw new Error('Cannot call an event handler while rendering.')
}) as any)
@@ -16,5 +16,3 @@ function useEventCallback(callback: T) {
return fn(...args)
}, [])
}
-
-export default useEventCallback