diff --git a/__tests__/components/Earn/Lend/LendingDetails.test.tsx b/__tests__/components/Earn/Lend/LendingDetails.test.tsx new file mode 100644 index 00000000..fd62701e --- /dev/null +++ b/__tests__/components/Earn/Lend/LendingDetails.test.tsx @@ -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('', () => { + afterAll(() => { + jest.unmock('hooks/usePrices') + }) + + it('should render', () => { + const { container } = render() + expect(container).toBeInTheDocument() + }) +}) diff --git a/src/api/markets/getMarketBorrowings.ts b/src/api/markets/getMarketBorrowings.ts index 1d9946ef..ea5a0ed6 100644 --- a/src/api/markets/getMarketBorrowings.ts +++ b/src/api/markets/getMarketBorrowings.ts @@ -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 { - 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 { diff --git a/src/api/markets/getMarketLiquidity.ts b/src/api/markets/getMarketLiquidities.ts similarity index 90% rename from src/api/markets/getMarketLiquidity.ts rename to src/api/markets/getMarketLiquidities.ts index b2ed1fcd..eeae346d 100644 --- a/src/api/markets/getMarketLiquidity.ts +++ b/src/api/markets/getMarketLiquidities.ts @@ -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 { +export default async function getMarketLiquidities(): Promise { const deposits = await getMarketDeposits() const debts = await getMarketDebts() diff --git a/src/components/Earn/Lend/LendingActionButtons.tsx b/src/components/Earn/Lend/LendingActionButtons.tsx new file mode 100644 index 00000000..59d4cfa9 --- /dev/null +++ b/src/components/Earn/Lend/LendingActionButtons.tsx @@ -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 ( +
+ {accountDepositValue && ( + + )} + + ( + {`You don’t have any ${asset.symbol}. Please first deposit ${asset.symbol} into your credit account before lending.`} + } + > + {children} + + )} + > + + +
+ ) +} + +export default LendingActionButtons diff --git a/src/components/Earn/Lend/LendingDetails.tsx b/src/components/Earn/Lend/LendingDetails.tsx new file mode 100644 index 00000000..35ccbcbd --- /dev/null +++ b/src/components/Earn/Lend/LendingDetails.tsx @@ -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 ( +
+ {details.map((detail, index) => ( + + ))} +
+ ) +} + +export default LendingDetails diff --git a/src/components/Earn/Lend/LendingMarketsTable.tsx b/src/components/Earn/Lend/LendingMarketsTable.tsx new file mode 100644 index 00000000..a15118b2 --- /dev/null +++ b/src/components/Earn/Lend/LendingMarketsTable.tsx @@ -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, table: Table) => { + return ( + } + expandedDetails={} + /> + ) + } + + const columns = useMemo[]>( + () => [ + { + accessorKey: 'asset.name', + header: 'Asset', + id: 'symbol', + cell: ({ row }) => { + const asset = row.original.asset + + return ( +
+ {asset.symbol} + +
+ ) + }, + }, + ...(shouldShowAccountDeposit + ? [ + { + accessorKey: 'accountDepositValue', + header: 'Deposited', + cell: ({ row }) => { + const accountDepositValue = ( + row.original.accountDepositValue as BigNumber + ).toNumber() + + return ( + + ) + }, + } as ColumnDef, + ] + : []), + { + accessorKey: 'marketLiquidityRate', + header: 'APR', + cell: ({ row }) => { + const apr = convertLiquidityRateToAPR(row.original.marketLiquidityRate) + return {apr.toFixed(2)}% + }, + }, + { + 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 ( + + ) + }, + }, + { + accessorKey: 'manage', + enableSorting: false, + header: 'Manage', + cell: ({ row }) => ( +
+
+ {row.getIsExpanded() ? : } +
+
+ ), + }, + ], + [displayCurrencySymbol, shouldShowAccountDeposit], + ) + + return +} + +export default LendingMarketsTable diff --git a/src/components/MarketAssetTable/MarketAssetTableRow.tsx b/src/components/MarketAssetTable/MarketAssetTableRow.tsx new file mode 100644 index 00000000..192634e9 --- /dev/null +++ b/src/components/MarketAssetTable/MarketAssetTableRow.tsx @@ -0,0 +1,73 @@ +import { flexRender, Row } from '@tanstack/react-table' +import classNames from 'classnames' + +import Text from 'components/Text' + +type Props = { + rowData: Row + resetExpanded: (defaultState?: boolean | undefined) => void + isExpanded: boolean + expandedActionButtons?: JSX.Element + expandedDetails?: JSX.Element +} + +function AssetListTableRow(props: Props) { + const renderFullRow = (key: string, content: JSX.Element) => ( + + + {content} + + + ) + + const renderExpanded = () => { + return ( + <> + {props.expandedActionButtons && + renderFullRow( + `${props.rowData.id}-expanded-actions`, +
+ + Details + +
{props.expandedActionButtons}
+
, + )} + {props.expandedDetails && + renderFullRow(`${props.rowData.id}-expanded-details`, props.expandedDetails)} + + ) + } + + return ( + <> + { + const isExpanded = props.rowData.getIsExpanded() + props.resetExpanded() + !isExpanded && props.rowData.toggleExpanded() + }} + > + {props.rowData.getVisibleCells().map((cell) => { + return ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ) + })} + + {props.isExpanded && renderExpanded()} + + ) +} + +export default AssetListTableRow diff --git a/src/components/MarketAssetTable/index.tsx b/src/components/MarketAssetTable/index.tsx new file mode 100644 index 00000000..9549d0a8 --- /dev/null +++ b/src/components/MarketAssetTable/index.tsx @@ -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 { + title: string + data: TData[] + columns: ColumnDef[] + rowRenderer: (row: Row, table: Table) => JSX.Element +} + +function AssetListTable(props: Props) { + const { title, data, columns } = props + const [sorting, setSorting] = React.useState([]) + + const table = useReactTable({ + data, + columns, + state: { + sorting, + }, + onSortingChange: setSorting, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + }) + + const _rowRenderer = (row: Row) => props.rowRenderer(row, table) + + if (!data.length) return null + + return ( + + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + ) + })} + + ))} + + {table.getRowModel().rows.map(_rowRenderer)} +
+
+ + {header.column.getCanSort() + ? { + asc: , + desc: , + false: , + }[header.column.getIsSorted() as string] ?? null + : null} + + + {flexRender(header.column.columnDef.header, header.getContext())} + +
+
+
+ ) +} + +export default AssetListTable diff --git a/src/components/TitleAndSubCell.tsx b/src/components/TitleAndSubCell.tsx index c5e4e37d..0ce5d78d 100644 --- a/src/components/TitleAndSubCell.tsx +++ b/src/components/TitleAndSubCell.tsx @@ -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 ( -
+
{props.title} diff --git a/src/hocs/ConditionalWrapper.tsx b/src/hocs/ConditionalWrapper.tsx new file mode 100644 index 00000000..a80edad4 --- /dev/null +++ b/src/hocs/ConditionalWrapper.tsx @@ -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 diff --git a/src/hooks/useCurrentAccountDeposits.ts b/src/hooks/useCurrentAccountDeposits.ts new file mode 100644 index 00000000..1b8c5c24 --- /dev/null +++ b/src/hooks/useCurrentAccountDeposits.ts @@ -0,0 +1,6 @@ +import useCurrentAccount from 'hooks/useCurrentAccount' + +export default function useCurrentAccountDeposits() { + const account = useCurrentAccount() + return account?.deposits ?? [] +} diff --git a/src/hooks/useDepositEnabledMarkets.ts b/src/hooks/useDepositEnabledMarkets.ts new file mode 100644 index 00000000..6a6a7522 --- /dev/null +++ b/src/hooks/useDepositEnabledMarkets.ts @@ -0,0 +1,6 @@ +import useMarketAssets from 'hooks/useMarketAssets' + +export default function useDepositEnabledMarkets() { + const { data: markets } = useMarketAssets() + return markets.filter((market) => market.depositEnabled) +} diff --git a/src/hooks/useDisplayCurrencyPrice.ts b/src/hooks/useDisplayCurrencyPrice.ts new file mode 100644 index 00000000..e18465d6 --- /dev/null +++ b/src/hooks/useDisplayCurrencyPrice.ts @@ -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 diff --git a/src/hooks/useLendingMarketAssetsTableData.ts b/src/hooks/useLendingMarketAssetsTableData.ts new file mode 100644 index 00000000..400e23ef --- /dev/null +++ b/src/hooks/useLendingMarketAssetsTableData.ts @@ -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 diff --git a/src/hooks/useMarketAssets.tsx b/src/hooks/useMarketAssets.ts similarity index 100% rename from src/hooks/useMarketAssets.tsx rename to src/hooks/useMarketAssets.ts diff --git a/src/hooks/useMarketDeposits.ts b/src/hooks/useMarketDeposits.ts new file mode 100644 index 00000000..1d4a021d --- /dev/null +++ b/src/hooks/useMarketDeposits.ts @@ -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: [], + }) +} diff --git a/src/hooks/useMarketLiquidities.ts b/src/hooks/useMarketLiquidities.ts new file mode 100644 index 00000000..d6d2bdca --- /dev/null +++ b/src/hooks/useMarketLiquidities.ts @@ -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: [], + }) +} diff --git a/src/pages/LendPage.tsx b/src/pages/LendPage.tsx index 95f7a6da..08b87294 100644 --- a/src/pages/LendPage.tsx +++ b/src/pages/LendPage.tsx @@ -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 ( <> - - <> - + + ) } diff --git a/src/types/interfaces/asset.d.ts b/src/types/interfaces/asset.d.ts index 1040863e..1a8085a6 100644 --- a/src/types/interfaces/asset.d.ts +++ b/src/types/interfaces/asset.d.ts @@ -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 +} diff --git a/src/types/interfaces/market.d.ts b/src/types/interfaces/market.d.ts index 0907b921..e158f412 100644 --- a/src/types/interfaces/market.d.ts +++ b/src/types/interfaces/market.d.ts @@ -7,4 +7,6 @@ interface Market { borrowEnabled: boolean depositCap: string maxLtv: number + liquidityRate: number + liquidationThreshold: number } diff --git a/src/utils/array.ts b/src/utils/array.ts new file mode 100644 index 00000000..2b45107a --- /dev/null +++ b/src/utils/array.ts @@ -0,0 +1 @@ +export const byDenom = (denom: string) => (entity: any) => entity.denom === denom diff --git a/src/utils/formatters.ts b/src/utils/formatters.ts index ffd2bee4..7b64f450 100644 --- a/src/utils/formatters.ts +++ b/src/utils/formatters.ts @@ -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 +} diff --git a/src/utils/resolvers.ts b/src/utils/resolvers.ts index 0f791eda..e99be0e9 100644 --- a/src/utils/resolvers.ts +++ b/src/utils/resolvers.ts @@ -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), })) }