feat: lending page and tables (#248)

This commit is contained in:
Yusuf Seyrek 2023-06-15 15:34:12 +03:00 committed by GitHub
parent 7b5d4c3255
commit 878434dfec
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 642 additions and 9 deletions

View File

@ -0,0 +1,37 @@
import { render } from '@testing-library/react'
import LendingDetails from 'components/Earn/Lend/LendingDetails'
import { ASSETS } from 'constants/assets'
import { BN } from 'utils/helpers'
const data: LendingMarketTableData = {
asset: ASSETS[0],
marketDepositAmount: BN('890546916'),
accountDepositValue: BN('0.5498406009348686811'),
marketLiquidityAmount: BN('629396551'),
marketDepositCap: BN('2500000000000'),
marketLiquidityRate: 0.017,
marketLiquidationThreshold: 0.61,
marketMaxLtv: 0.59,
}
jest.mock('hooks/useDisplayCurrencyPrice', () => () => {
const { BN } = require('utils/helpers')
return {
getConversionRate: () => BN(1),
convertAmount: () => BN(1),
symbol: 'MARS',
}
})
describe('<LendingDetails />', () => {
afterAll(() => {
jest.unmock('hooks/usePrices')
})
it('should render', () => {
const { container } = render(<LendingDetails data={data} />)
expect(container).toBeInTheDocument()
})
})

View File

@ -1,18 +1,18 @@
import { BN } from 'utils/helpers'
import getPrices from 'api/prices/getPrices'
import getMarkets from 'api/markets/getMarkets'
import getMarketLiquidity from 'api/markets/getMarketLiquidity'
import { getEnabledMarketAssets } from 'utils/assets'
import getMarketLiquidities from 'api/markets/getMarketLiquidities'
export default async function getMarketBorrowings(): Promise<BorrowAsset[]> {
const liquidity = await getMarketLiquidity()
const liquidities = await getMarketLiquidities()
const enabledAssets = getEnabledMarketAssets()
const borrowEnabledMarkets = (await getMarkets()).filter((market: Market) => market.borrowEnabled)
const prices = await getPrices()
const borrow: BorrowAsset[] = borrowEnabledMarkets.map((market) => {
const price = prices.find((coin) => coin.denom === market.denom)?.amount ?? '1'
const amount = liquidity.find((coin) => coin.denom === market.denom)?.amount ?? '0'
const amount = liquidities.find((coin) => coin.denom === market.denom)?.amount ?? '0'
const asset = enabledAssets.find((asset) => asset.denom === market.denom)!
return {

View File

@ -2,7 +2,7 @@ import { BN } from 'utils/helpers'
import getMarketDeposits from 'api/markets/getMarketDeposits'
import getMarketDebts from 'api/markets/getMarketDebts'
export default async function getMarketLiquidity(): Promise<Coin[]> {
export default async function getMarketLiquidities(): Promise<Coin[]> {
const deposits = await getMarketDeposits()
const debts = await getMarketDebts()

View File

@ -0,0 +1,63 @@
import Button from 'components/Button'
import { ArrowDownLine, ArrowUpLine } from 'components/Icons'
import Text from 'components/Text'
import { Tooltip } from 'components/Tooltip'
import ConditionalWrapper from 'hocs/ConditionalWrapper'
import useCurrentAccountDeposits from 'hooks/useCurrentAccountDeposits'
import { byDenom } from 'utils/array'
interface Props {
data: LendingMarketTableData
}
const buttonClassnames = 'm-0 flex w-40 text-lg'
const iconClassnames = 'ml-0 mr-1 w-4 h-4'
function LendingActionButtons(props: Props) {
const { asset, accountDepositValue } = props.data
const accountDeposits = useCurrentAccountDeposits()
const assetDepositAmount = accountDeposits.find(byDenom(asset.denom))?.amount
return (
<div className='flex flex-row space-x-2'>
{accountDepositValue && (
<Button
leftIcon={<ArrowDownLine />}
iconClassName={iconClassnames}
color='secondary'
onClick={() => alert('hello!')}
className={buttonClassnames}
>
Withdraw
</Button>
)}
<ConditionalWrapper
condition={!assetDepositAmount}
wrapper={(children) => (
<Tooltip
type='warning'
content={
<Text size='sm'>{`You dont have any ${asset.symbol}. Please first deposit ${asset.symbol} into your credit account before lending.`}</Text>
}
>
{children}
</Tooltip>
)}
>
<Button
leftIcon={<ArrowUpLine />}
iconClassName={iconClassnames}
disabled={!assetDepositAmount}
color='secondary'
onClick={() => alert('hello!')}
className={buttonClassnames}
>
Lend
</Button>
</ConditionalWrapper>
</div>
)
}
export default LendingActionButtons

View File

@ -0,0 +1,62 @@
import TitleAndSubCell from 'components/TitleAndSubCell'
import { formatPercent, formatValue } from 'utils/formatters'
import useDisplayCurrencyPrice from 'hooks/useDisplayCurrencyPrice'
interface Props {
data: LendingMarketTableData
}
function LendingDetails({ data }: Props) {
const {
convertAmount,
getConversionRate,
symbol: displayCurrencySymbol,
} = useDisplayCurrencyPrice()
const {
asset,
marketMaxLtv,
marketDepositAmount,
marketLiquidityAmount,
marketLiquidationThreshold,
} = data
const formattedTotalSuppliedValue = formatValue(
convertAmount(asset, marketDepositAmount).toNumber(),
{
abbreviated: true,
suffix: ` ${displayCurrencySymbol}`,
},
)
const formattedPrice = formatValue(getConversionRate(asset.denom).toNumber(), {
maxDecimals: 2,
suffix: ` ${displayCurrencySymbol}`,
})
const totalBorrowed = marketDepositAmount.minus(marketLiquidityAmount)
const utilizationRatePercent = formatPercent(
totalBorrowed.dividedBy(marketDepositAmount).toNumber(),
2,
)
const details = [
{ info: formattedTotalSuppliedValue, title: 'Total Supplied' },
{ info: formatPercent(marketMaxLtv, 2), title: 'Max LTV' },
{ info: formatPercent(marketLiquidationThreshold, 2), title: 'Liquidation Threshold' },
{ info: formattedPrice, title: 'Oracle Price' },
{ info: utilizationRatePercent, title: 'Utilization Rate' },
]
return (
<div className='flex flex-1 justify-center rounded-md bg-white bg-opacity-5'>
{details.map((detail, index) => (
<TitleAndSubCell
key={index}
className='text-md text-center'
containerClassName='m-5 ml-10 mr-10 space-y-2'
title={detail.info}
sub={detail.title}
/>
))}
</div>
)
}
export default LendingDetails

View File

@ -0,0 +1,136 @@
import { ColumnDef, Row, Table } from '@tanstack/react-table'
import { useMemo } from 'react'
import Image from 'next/image'
import classNames from 'classnames'
import Text from 'components/Text'
import AssetListTable from 'components/MarketAssetTable'
import TitleAndSubCell from 'components/TitleAndSubCell'
import { ChevronDown, ChevronRight } from 'components/Icons'
import { convertLiquidityRateToAPR, demagnify, formatValue } from 'utils/formatters'
import MarketAssetTableRow from 'components/MarketAssetTable/MarketAssetTableRow'
import LendingActionButtons from 'components/Earn/Lend/LendingActionButtons'
import LendingDetails from 'components/Earn/Lend/LendingDetails'
import useDisplayCurrencyPrice from 'hooks/useDisplayCurrencyPrice'
import { FormattedNumber } from 'components/FormattedNumber'
interface Props {
title: string
data: LendingMarketTableData[]
}
function LendingMarketsTable(props: Props) {
const { title, data } = props
const { symbol: displayCurrencySymbol } = useDisplayCurrencyPrice()
const shouldShowAccountDeposit = !!data[0]?.accountDepositValue
const rowRenderer = (row: Row<LendingMarketTableData>, table: Table<LendingMarketTableData>) => {
return (
<MarketAssetTableRow
key={`lend-asset-${row.id}`}
isExpanded={row.getIsExpanded()}
resetExpanded={table.resetExpanded}
rowData={row}
expandedActionButtons={<LendingActionButtons data={row.original} />}
expandedDetails={<LendingDetails data={row.original} />}
/>
)
}
const columns = useMemo<ColumnDef<LendingMarketTableData>[]>(
() => [
{
accessorKey: 'asset.name',
header: 'Asset',
id: 'symbol',
cell: ({ row }) => {
const asset = row.original.asset
return (
<div className='flex flex-1 items-center gap-3'>
<Image src={asset.logo} alt={asset.symbol} width={32} height={32} />
<TitleAndSubCell
title={asset.symbol}
sub={asset.name}
className='min-w-15 text-left'
/>
</div>
)
},
},
...(shouldShowAccountDeposit
? [
{
accessorKey: 'accountDepositValue',
header: 'Deposited',
cell: ({ row }) => {
const accountDepositValue = (
row.original.accountDepositValue as BigNumber
).toNumber()
return (
<FormattedNumber
className='text-xs'
animate={true}
amount={accountDepositValue}
options={{ suffix: ` ${displayCurrencySymbol}` }}
/>
)
},
} as ColumnDef<LendingMarketTableData>,
]
: []),
{
accessorKey: 'marketLiquidityRate',
header: 'APR',
cell: ({ row }) => {
const apr = convertLiquidityRateToAPR(row.original.marketLiquidityRate)
return <Text size='xs'>{apr.toFixed(2)}%</Text>
},
},
{
accessorKey: 'marketDepositCap',
header: 'Depo. Cap',
cell: ({ row }) => {
const { marketDepositCap, marketDepositAmount, asset } = row.original
const remainingCap = row.original.marketDepositCap.minus(
demagnify(marketDepositAmount, asset),
)
const [formattedRemainingCap, formattedDepositCap] = [remainingCap, marketDepositCap].map(
(value) =>
formatValue(value.toNumber(), {
decimals: asset.decimals,
abbreviated: true,
}),
)
return (
<TitleAndSubCell
className='text-xs'
title={formattedDepositCap}
sub={`${formattedRemainingCap} left`}
/>
)
},
},
{
accessorKey: 'manage',
enableSorting: false,
header: 'Manage',
cell: ({ row }) => (
<div className='flex items-center justify-end'>
<div className={classNames('w-4')}>
{row.getIsExpanded() ? <ChevronDown /> : <ChevronRight />}
</div>
</div>
),
},
],
[displayCurrencySymbol, shouldShowAccountDeposit],
)
return <AssetListTable title={title} rowRenderer={rowRenderer} columns={columns} data={data} />
}
export default LendingMarketsTable

View File

@ -0,0 +1,73 @@
import { flexRender, Row } from '@tanstack/react-table'
import classNames from 'classnames'
import Text from 'components/Text'
type Props<TData> = {
rowData: Row<TData>
resetExpanded: (defaultState?: boolean | undefined) => void
isExpanded: boolean
expandedActionButtons?: JSX.Element
expandedDetails?: JSX.Element
}
function AssetListTableRow<TData>(props: Props<TData>) {
const renderFullRow = (key: string, content: JSX.Element) => (
<tr key={key} className='bg-black/20'>
<td
className='border-b border-white border-opacity-10 p-4'
colSpan={props.rowData.getAllCells().length}
>
{content}
</td>
</tr>
)
const renderExpanded = () => {
return (
<>
{props.expandedActionButtons &&
renderFullRow(
`${props.rowData.id}-expanded-actions`,
<div className='flex flex-1 flex-row justify-between'>
<Text className='mt-1 flex p-0 font-bold' size='base'>
Details
</Text>
<div>{props.expandedActionButtons}</div>
</div>,
)}
{props.expandedDetails &&
renderFullRow(`${props.rowData.id}-expanded-details`, props.expandedDetails)}
</>
)
}
return (
<>
<tr
key={props.rowData.id}
className={classNames(
'cursor-pointer transition-colors',
'border-b border-white border-opacity-10',
props.rowData.getIsExpanded() ? 'bg-black/20' : 'bg-white/0 hover:bg-white/5',
)}
onClick={() => {
const isExpanded = props.rowData.getIsExpanded()
props.resetExpanded()
!isExpanded && props.rowData.toggleExpanded()
}}
>
{props.rowData.getVisibleCells().map((cell) => {
return (
<td key={cell.id} className={'p-4 text-right'}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
)
})}
</tr>
{props.isExpanded && renderExpanded()}
</>
)
}
export default AssetListTableRow

View File

@ -0,0 +1,101 @@
import {
ColumnDef,
flexRender,
getCoreRowModel,
getSortedRowModel,
Row,
SortingState,
Table,
useReactTable,
} from '@tanstack/react-table'
import classNames from 'classnames'
import React from 'react'
import { SortAsc, SortDesc, SortNone } from 'components/Icons'
import Text from 'components/Text'
import Card from 'components/Card'
interface Props<TData> {
title: string
data: TData[]
columns: ColumnDef<TData>[]
rowRenderer: (row: Row<TData>, table: Table<TData>) => JSX.Element
}
function AssetListTable<TData>(props: Props<TData>) {
const { title, data, columns } = props
const [sorting, setSorting] = React.useState<SortingState>([])
const table = useReactTable({
data,
columns,
state: {
sorting,
},
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
})
const _rowRenderer = (row: Row<TData>) => props.rowRenderer(row, table)
if (!data.length) return null
return (
<Card className='mb-4 h-fit w-full bg-white/5' title={title}>
<table className='w-full'>
<thead className='border-b border-white border-opacity-10 bg-black/20'>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<th
key={header.id}
onClick={header.column.getToggleSortingHandler()}
className={classNames(
'px-4 py-3',
header.column.getCanSort() && 'cursor-pointer',
header.id === 'symbol' ? 'text-left' : 'text-right',
{
'w-32': header.id === 'manage',
'w-48': header.id === 'depositCap',
},
)}
>
<div
className={classNames(
'flex',
header.id === 'symbol' ? 'justify-start' : 'justify-end',
'align-center',
)}
>
<span className='h-6 w-6 text-white'>
{header.column.getCanSort()
? {
asc: <SortAsc />,
desc: <SortDesc />,
false: <SortNone />,
}[header.column.getIsSorted() as string] ?? null
: null}
</span>
<Text
tag='span'
size='xs'
className='flex items-center font-normal text-white/40'
>
{flexRender(header.column.columnDef.header, header.getContext())}
</Text>
</div>
</th>
)
})}
</tr>
))}
</thead>
<tbody>{table.getRowModel().rows.map(_rowRenderer)}</tbody>
</table>
</Card>
)
}
export default AssetListTable

View File

@ -6,11 +6,12 @@ interface Props {
title: string | React.ReactNode
sub: string | React.ReactNode
className?: string
containerClassName?: string
}
export default function TitleAndSubCell(props: Props) {
return (
<div className='flex flex-col gap-[0.5]'>
<div className={classNames('flex flex-col gap-[0.5]', props.containerClassName)}>
<Text size='sm' className={props.className}>
{props.title}
</Text>

View File

@ -0,0 +1,10 @@
interface Props {
condition: boolean
wrapper: (children: JSX.Element) => JSX.Element
children: JSX.Element
}
const ConditionalWrapper = ({ condition, wrapper, children }: Props) =>
condition ? wrapper(children) : children
export default ConditionalWrapper

View File

@ -0,0 +1,6 @@
import useCurrentAccount from 'hooks/useCurrentAccount'
export default function useCurrentAccountDeposits() {
const account = useCurrentAccount()
return account?.deposits ?? []
}

View File

@ -0,0 +1,6 @@
import useMarketAssets from 'hooks/useMarketAssets'
export default function useDepositEnabledMarkets() {
const { data: markets } = useMarketAssets()
return markets.filter((market) => market.depositEnabled)
}

View File

@ -0,0 +1,39 @@
import { useCallback } from 'react'
import useStore from 'store'
import { BN } from 'utils/helpers'
import { byDenom } from 'utils/array'
import usePrices from 'hooks/usePrices'
function useDisplayCurrencyPrice() {
const { data: prices } = usePrices()
const displayCurrency = useStore((s) => s.displayCurrency)
const getConversionRate = useCallback(
(denom: string) => {
const assetPrice = prices.find(byDenom(denom))
const displayCurrencyPrice = prices.find(byDenom(displayCurrency.denom))
if (assetPrice && displayCurrencyPrice) {
return BN(assetPrice.amount).div(displayCurrencyPrice.amount)
} else {
throw 'Given denom or display currency price has not found'
}
},
[prices, displayCurrency],
)
const convertAmount = useCallback(
(asset: Asset, amount: string | number | BigNumber) =>
getConversionRate(asset.denom).multipliedBy(BN(amount).shiftedBy(-asset.decimals)),
[getConversionRate],
)
return {
getConversionRate,
convertAmount,
symbol: displayCurrency.symbol,
}
}
export default useDisplayCurrencyPrice

View File

@ -0,0 +1,56 @@
import { useMemo } from 'react'
import { BN } from 'utils/helpers'
import { byDenom } from 'utils/array'
import { getAssetByDenom } from 'utils/assets'
import useMarketDeposits from 'hooks/useMarketDeposits'
import useMarketLiquidities from 'hooks/useMarketLiquidities'
import useDisplayCurrencyPrice from 'hooks/useDisplayCurrencyPrice'
import useDepositEnabledMarkets from 'hooks/useDepositEnabledMarkets'
import useCurrentAccountDeposits from 'hooks/useCurrentAccountDeposits'
function useLendingMarketAssetsTableData(): {
lentAssets: LendingMarketTableData[]
availableAssets: LendingMarketTableData[]
} {
const markets = useDepositEnabledMarkets()
const accountDeposits = useCurrentAccountDeposits()
// TODO: replace market deposits with account.lends when credit manager contract has lend feature
const { data: marketDeposits } = useMarketDeposits()
const { data: marketLiquidities } = useMarketLiquidities()
const { convertAmount } = useDisplayCurrencyPrice()
return useMemo(() => {
const lentAssets: LendingMarketTableData[] = [],
availableAssets: LendingMarketTableData[] = []
markets.forEach(({ denom, depositCap, liquidityRate, liquidationThreshold, maxLtv }) => {
const asset = getAssetByDenom(denom) as Asset
const marketDepositAmount = BN(marketDeposits.find(byDenom(denom))?.amount ?? 0)
const marketLiquidityAmount = BN(marketLiquidities.find(byDenom(denom))?.amount ?? 0)
const accountDepositAmount = accountDeposits.find(byDenom(denom))?.amount
const accountDepositValue = accountDepositAmount
? convertAmount(asset, accountDepositAmount)
: undefined
const lendingMarketAsset: LendingMarketTableData = {
asset,
marketDepositAmount,
accountDepositValue,
marketLiquidityAmount,
marketDepositCap: BN(depositCap),
marketLiquidityRate: liquidityRate,
marketLiquidationThreshold: liquidationThreshold,
marketMaxLtv: maxLtv,
}
;(lendingMarketAsset.accountDepositValue ? lentAssets : availableAssets).push(
lendingMarketAsset,
)
})
return { lentAssets, availableAssets }
}, [markets, marketDeposits, marketLiquidities, accountDeposits, convertAmount])
}
export default useLendingMarketAssetsTableData

View File

@ -0,0 +1,10 @@
import useSWR from 'swr'
import getMarketDeposits from 'api/markets/getMarketDeposits'
export default function useMarketDeposits() {
return useSWR(`marketDeposits`, getMarketDeposits, {
suspense: true,
fallbackData: [],
})
}

View File

@ -0,0 +1,10 @@
import useSWR from 'swr'
import getMarketLiquidities from 'api/markets/getMarketLiquidities'
export default function useMarketLiquidities() {
return useSWR(`marketLiquidities`, getMarketLiquidities, {
suspense: true,
fallbackData: [],
})
}

View File

@ -1,13 +1,15 @@
import Card from 'components/Card'
import Tab from 'components/Earn/Tab'
import LendingMarketsTable from 'components/Earn/Lend/LendingMarketsTable'
import useLendingMarketAssetsTableData from 'hooks/useLendingMarketAssetsTableData'
export default function LendPage() {
const { lentAssets, availableAssets } = useLendingMarketAssetsTableData()
return (
<>
<Tab />
<Card title='Lend'>
<></>
</Card>
<LendingMarketsTable data={lentAssets} title='Lent Assets' />
<LendingMarketsTable data={availableAssets} title='Available Markets' />
</>
)
}

View File

@ -36,3 +36,14 @@ interface BigNumberCoin {
denom: string
amount: BigNumber
}
interface LendingMarketTableData {
asset: Asset
marketMaxLtv: number
marketLiquidityRate: number
marketDepositCap: BigNumber
marketDepositAmount: BigNumber
accountDepositValue?: BigNumber
marketLiquidityAmount: BigNumber
marketLiquidationThreshold: number
}

View File

@ -7,4 +7,6 @@ interface Market {
borrowEnabled: boolean
depositCap: string
maxLtv: number
liquidityRate: number
liquidationThreshold: number
}

1
src/utils/array.ts Normal file
View File

@ -0,0 +1 @@
export const byDenom = (denom: string) => (entity: any) => entity.denom === denom

View File

@ -168,3 +168,8 @@ export function convertToDisplayAmount(coin: Coin, displayCurrency: Asset, price
.div(displayPrice.amount)
.toNumber()
}
export function convertLiquidityRateToAPR(rate: number) {
const rateMulHundred = rate * 100
return rateMulHundred >= 0.01 ? rateMulHundred : 0.0
}

View File

@ -22,5 +22,7 @@ export function resolveMarketResponses(responses: MarketResponse[]): Market[] {
borrowEnabled: response.borrow_enabled,
depositCap: response.deposit_cap,
maxLtv: Number(response.max_loan_to_value),
liquidityRate: Number(response.liquidity_rate),
liquidationThreshold: Number(response.liquidation_threshold),
}))
}