From bbbdca6950e5c7f1b10ec29fd800be715036d270 Mon Sep 17 00:00:00 2001 From: Gustavo Mauricio Date: Thu, 20 Oct 2022 16:39:21 +0100 Subject: [PATCH] MP-1227: Borrow Page (#24) * added icon for atom and tokenInfo data update * borrow page initial commit * feat: borrow funds to ca and wallet * close borrow module on tx success * feat: repay funds initial setup * repay funds action hook * repay slider. module state on borrow page component * styling: minor tweak to text colors * limit manual input on repay to max value * borrow funds component slider initial * style: max button typography * AssetRow extracted to separate file. organize imports * ContainerSecondary component added * loading indicator for pending actions * style: progress bar colors * tanstack table added * tanstack react-table dependency missing * table cleanup and layout adjustments * fix account stats formula and update market data to match spreadsheet * calculate max borrow amount hook * reset borrow and repay components on account change * max borrow amount decimals. memorized return * hook tanstack data with real data * redefine borrowedAssetsMap to map * update max borrow amount formulas * remove unnecessary table component. refactor borrow table --- components/Borrow/AssetRow.tsx | 89 ++++++++ components/Borrow/BorrowFunds.tsx | 188 +++++++++++++++++ components/Borrow/BorrowTable.tsx | 197 +++++++++++++++++ components/Borrow/RepayFunds.tsx | 133 ++++++++++++ components/Borrow/index.tsx | 3 + components/Button.tsx | 2 +- components/ContainerSecondary.tsx | 17 ++ .../CreditManager/CreditManagerContainer.tsx | 14 -- components/CreditManager/FundAccount.tsx | 14 +- components/CreditManager/index.tsx | 18 +- components/ProgressBar.tsx | 9 +- components/SemiCircleProgress.tsx | 10 +- components/Tooltip.tsx | 23 ++ config/tokenInfo.ts | 5 +- hooks/useAccountStats.tsx | 2 +- hooks/useBorrowFunds.tsx | 108 ++++++++++ hooks/useCalculateMaxBorrowAmount.tsx | 63 ++++++ hooks/useMarkets.tsx | 8 +- hooks/useRepayFunds.tsx | 77 +++++++ package.json | 2 + pages/borrow.tsx | 199 +++++++++++++----- public/tokens/atom.svg | 44 ++++ utils/tokens.ts | 8 + yarn.lock | 31 +++ 24 files changed, 1167 insertions(+), 97 deletions(-) create mode 100644 components/Borrow/AssetRow.tsx create mode 100644 components/Borrow/BorrowFunds.tsx create mode 100644 components/Borrow/BorrowTable.tsx create mode 100644 components/Borrow/RepayFunds.tsx create mode 100644 components/Borrow/index.tsx create mode 100644 components/ContainerSecondary.tsx delete mode 100644 components/CreditManager/CreditManagerContainer.tsx create mode 100644 components/Tooltip.tsx create mode 100644 hooks/useBorrowFunds.tsx create mode 100644 hooks/useCalculateMaxBorrowAmount.tsx create mode 100644 hooks/useRepayFunds.tsx create mode 100644 public/tokens/atom.svg diff --git a/components/Borrow/AssetRow.tsx b/components/Borrow/AssetRow.tsx new file mode 100644 index 00000000..09c12104 --- /dev/null +++ b/components/Borrow/AssetRow.tsx @@ -0,0 +1,89 @@ +import React, { useState } from 'react' +import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/24/solid' +import Image from 'next/image' + +import Button from 'components/Button' +import { formatCurrency } from 'utils/formatters' + +type AssetRowProps = { + data: { + denom: string + symbol: string + icon: string + chain: string + borrowed: { + amount: number + value: number + } | null + borrowRate: number + marketLiquidity: number + } + onBorrowClick: () => void + onRepayClick: (value: number) => void +} + +const AssetRow = ({ data, onBorrowClick, onRepayClick }: AssetRowProps) => { + const [isExpanded, setIsExpanded] = useState(false) + + return ( +
setIsExpanded((current) => !current)} + > +
+
+ token +
+
{data.symbol}
+
{data.chain}
+
+
+
+ {data.borrowRate ? `${(data.borrowRate * 100).toFixed(2)}%` : '-'} +
+
+ {data.borrowed ? ( +
+
{data.borrowed.amount}
+
{formatCurrency(data.borrowed.value)}
+
+ ) : ( + '-' + )} +
+
{data.marketLiquidity}
+
+ {isExpanded ? : } +
+
+ {isExpanded && ( +
+
Additional Stuff Placeholder
+
+ + +
+
+ )} +
+ ) +} + +export default AssetRow diff --git a/components/Borrow/BorrowFunds.tsx b/components/Borrow/BorrowFunds.tsx new file mode 100644 index 00000000..34fc749d --- /dev/null +++ b/components/Borrow/BorrowFunds.tsx @@ -0,0 +1,188 @@ +import React, { useMemo, useState } from 'react' +import { XMarkIcon } from '@heroicons/react/24/solid' +import { toast } from 'react-toastify' +import * as Slider from '@radix-ui/react-slider' + +import Button from 'components/Button' +import Container from 'components/Container' +import { getTokenDecimals, getTokenSymbol } from 'utils/tokens' +import useBorrowFunds from 'hooks/useBorrowFunds' +import useTokenPrices from 'hooks/useTokenPrices' +import { formatCurrency } from 'utils/formatters' +import { Switch } from '@headlessui/react' +import BigNumber from 'bignumber.js' +import useAllBalances from 'hooks/useAllBalances' +import useMarkets from 'hooks/useMarkets' +import Tooltip from 'components/Tooltip' +import ContainerSecondary from 'components/ContainerSecondary' +import Spinner from 'components/Spinner' +import useCalculateMaxBorrowAmount from 'hooks/useCalculateMaxBorrowAmount' + +const BorrowFunds = ({ tokenDenom, onClose }: any) => { + const [amount, setAmount] = useState(0) + const [borrowToCreditAccount, setBorrowToCreditAccount] = useState(false) + + const tokenSymbol = getTokenSymbol(tokenDenom) + + const { mutate, isLoading } = useBorrowFunds(amount, tokenDenom, !borrowToCreditAccount, { + onSuccess: () => { + onClose() + toast.success(`${amount} ${tokenSymbol} successfully Borrowed`) + }, + }) + + const { data: tokenPrices } = useTokenPrices() + const { data: balancesData } = useAllBalances() + const { data: marketsData } = useMarkets() + + const handleSubmit = () => { + mutate() + } + + const walletAmount = useMemo(() => { + return BigNumber(balancesData?.find((balance) => balance.denom === tokenDenom)?.amount ?? 0) + .div(10 ** getTokenDecimals(tokenDenom)) + .toNumber() + }, [balancesData, tokenDenom]) + + const tokenPrice = tokenPrices?.[tokenDenom] ?? 0 + const borrowRate = Number(marketsData?.[tokenDenom].borrow_rate) + + const maxValue = useCalculateMaxBorrowAmount(tokenDenom, borrowToCreditAccount) + + const percentageValue = useMemo(() => { + if (isNaN(amount) || maxValue === 0) return 0 + + return (amount * 100) / maxValue + }, [amount, maxValue]) + + const isSubmitDisabled = !amount || amount < 0 + + const handleValueChange = (value: number) => { + if (value > maxValue) { + setAmount(maxValue) + return + } + + setAmount(value) + } + + const handleBorrowTargetChange = () => { + setBorrowToCreditAccount((c) => !c) + // reset amount due to max value calculations changing depending on borrow target + setAmount(0) + } + + return ( + + {isLoading && ( +
+ +
+ )} +
+

Borrow {tokenSymbol}

+ +
+
+ +

+ In wallet:{' '} + + {walletAmount.toLocaleString()} {tokenSymbol} + +

+

+ Borrow Rate: {(borrowRate * 100).toFixed(2)}% +

+
+
Amount
+ handleValueChange(e.target.valueAsNumber)} + /> +
+
+
+ 1 {tokenSymbol} ={' '} + {formatCurrency(tokenPrice)} +
+
{formatCurrency(tokenPrice * amount)}
+
+
+ +
+ { + const decimal = value[0] / 100 + const tokenDecimals = getTokenDecimals(tokenDenom) + // limit decimal precision based on token contract decimals + const newAmount = Number((decimal * maxValue).toFixed(tokenDecimals)) + + setAmount(newAmount) + }} + > + + + + +
{percentageValue.toFixed(0)}%
+
+
+ +
+
+ +
+ Borrow to Credit Account{' '} + +

+ OFF = Borrow directly into your wallet by using your account Assets as + collateral. The borrowed asset will become a liability in your account. +

+

+ ON = Borrow into your Account. The borrowed asset will be available in the + account as an Asset and appear also as a liability in your account. +

+ + } + /> +
+ + + +
+
+ +
+ ) +} + +export default BorrowFunds diff --git a/components/Borrow/BorrowTable.tsx b/components/Borrow/BorrowTable.tsx new file mode 100644 index 00000000..d2bb1821 --- /dev/null +++ b/components/Borrow/BorrowTable.tsx @@ -0,0 +1,197 @@ +import React from 'react' +import Image from 'next/image' +import { + ColumnDef, + flexRender, + getCoreRowModel, + getSortedRowModel, + SortingState, + useReactTable, +} from '@tanstack/react-table' +import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/24/solid' + +import { formatCurrency } from 'utils/formatters' +import AssetRow from './AssetRow' + +interface Market { + denom: string + symbol: string + icon: string + chain: string + borrowed: { + amount: number + value: number + } | null + borrowRate: number + marketLiquidity: number +} + +// const data = [ +// { +// denom: 'uosmo', +// symbol: 'OSMO', +// icon: '/tokens/osmo.svg', +// chain: 'Osmosis', +// borrowed: { +// amount: 2.005494, +// value: 2.2060434000000004, +// }, +// borrowRate: 0.1, +// marketLiquidity: 1000000, +// }, +// { +// denom: 'ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA622B25F41E5EB2', +// symbol: 'ATOM', +// icon: '/tokens/atom.svg', +// chain: 'Cosmos', +// borrowed: null, +// borrowRate: 0.25, +// marketLiquidity: 1000, +// }, +// { +// denom: 'uusdc', +// symbol: 'USDC', +// icon: '/tokens/atom.svg', +// chain: 'Ethereum', +// borrowed: { +// amount: 100, +// value: 99.9776, +// }, +// borrowRate: 0.35, +// marketLiquidity: 333, +// }, +// ] + +type Props = { + data: Market[] + onBorrowClick: (denom: string) => void + onRepayClick: (denom: string, repayAmount: number) => void +} + +const BorrowTable = ({ data, onBorrowClick, onRepayClick }: Props) => { + const [sorting, setSorting] = React.useState([]) + + const columns = React.useMemo[]>( + () => [ + { + header: 'Asset', + id: 'symbol', + accessorFn: (row) => ( +
+ token +
+
{row.symbol}
+
{row.chain}
+
+
+ ), + cell: (info) => info.getValue(), + }, + { + accessorKey: 'borrowRate', + header: 'Borrow Rate', + accessorFn: (row) => ( +
+ {row.borrowRate ? `${(row.borrowRate * 100).toFixed(2)}%` : '-'} +
+ ), + cell: (info) => info.getValue(), + }, + { + accessorKey: 'age', + header: 'Borrowed', + accessorFn: (row) => ( +
+ {row.borrowed ? ( +
+
{row.borrowed.amount}
+
{formatCurrency(row.borrowed.value)}
+
+ ) : ( + '-' + )} +
+ ), + cell: (info) => info.getValue(), + }, + { + accessorKey: 'marketLiquidity', + header: 'Liquidity Available', + }, + { + accessorKey: 'status', + enableSorting: false, + header: 'Manage', + width: 150, + cell: ({ row }) => ( +
+ {row.getIsExpanded() ? ( + + ) : ( + + )} +
+ ), + }, + ], + [] + ) + + const table = useReactTable({ + data, + columns, + state: { + sorting, + }, + onSortingChange: setSorting, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + debugTable: true, + }) + + return ( +
+ {table.getHeaderGroups().map((headerGroup) => ( +
+ {headerGroup.headers.map((header) => { + return ( +
+ {header.isPlaceholder ? null : ( +
+ {flexRender(header.column.columnDef.header, header.getContext())} + {{ + asc: ' 🔼', + desc: ' 🔽', + }[header.column.getIsSorted() as string] ?? null} +
+ )} +
+ ) + })} +
+ ))} +
+ {table.getRowModel().rows.map((row) => { + return ( + onBorrowClick(row.original.denom)} + onRepayClick={(repayAmount: number) => onRepayClick(row.original.denom, repayAmount)} + /> + ) + })} +
+
+ ) +} + +export default BorrowTable diff --git a/components/Borrow/RepayFunds.tsx b/components/Borrow/RepayFunds.tsx new file mode 100644 index 00000000..82f2cdb4 --- /dev/null +++ b/components/Borrow/RepayFunds.tsx @@ -0,0 +1,133 @@ +import React, { useMemo, useState } from 'react' +import { XMarkIcon } from '@heroicons/react/24/solid' +import { toast } from 'react-toastify' +import * as Slider from '@radix-ui/react-slider' + +import Button from 'components/Button' +import Container from 'components/Container' +import { getTokenDecimals, getTokenSymbol } from 'utils/tokens' +import useRepayFunds from 'hooks/useRepayFunds' +import useTokenPrices from 'hooks/useTokenPrices' +import { formatCurrency } from 'utils/formatters' +import BigNumber from 'bignumber.js' +import useAllBalances from 'hooks/useAllBalances' +import ContainerSecondary from 'components/ContainerSecondary' +import Spinner from 'components/Spinner' + +const RepayFunds = ({ tokenDenom, amount: repayAmount, onClose }: any) => { + const [amount, setAmount] = useState(0) + + const tokenSymbol = getTokenSymbol(tokenDenom) + + const { mutate, isLoading } = useRepayFunds(amount, tokenDenom, { + onSuccess: () => { + onClose() + toast.success(`${amount} ${tokenSymbol} successfully repaid`) + }, + }) + + const { data: tokenPrices } = useTokenPrices() + const { data: balancesData } = useAllBalances() + + const handleSubmit = () => { + mutate() + } + + const walletAmount = useMemo(() => { + return BigNumber(balancesData?.find((balance) => balance.denom === tokenDenom)?.amount ?? 0) + .div(10 ** getTokenDecimals(tokenDenom)) + .toNumber() + }, [balancesData, tokenDenom]) + + const tokenPrice = tokenPrices?.[tokenDenom] ?? 0 + + const maxValue = walletAmount > repayAmount ? repayAmount : walletAmount + const percentageValue = isNaN(amount) ? 0 : (amount * 100) / maxValue + const isSubmitDisabled = !amount || amount < 0 + + const handleValueChange = (value: number) => { + if (value > maxValue) { + setAmount(maxValue) + return + } + + setAmount(value) + } + + return ( + + {isLoading && ( +
+ +
+ )} +
+

Repay {tokenSymbol}

+ +
+
+ +

+ In wallet:{' '} + + {walletAmount.toLocaleString()} {tokenSymbol} + +

+
+
Amount
+ handleValueChange(e.target.valueAsNumber)} + /> +
+
+
+ 1 {tokenSymbol} ={' '} + {formatCurrency(tokenPrice)} +
+
{formatCurrency(tokenPrice * amount)}
+
+
+ +
+ { + const decimal = value[0] / 100 + const tokenDecimals = getTokenDecimals(tokenDenom) + // limit decimal precision based on token contract decimals + const newAmount = Number((decimal * maxValue).toFixed(tokenDecimals)) + + setAmount(newAmount) + }} + > + + + + +
{percentageValue.toFixed(0)}%
+
+
+ +
+
+
+ +
+ ) +} + +export default RepayFunds diff --git a/components/Borrow/index.tsx b/components/Borrow/index.tsx new file mode 100644 index 00000000..fc79f751 --- /dev/null +++ b/components/Borrow/index.tsx @@ -0,0 +1,3 @@ +export { default as AssetRow } from './AssetRow' +export { default as BorrowFunds } from './BorrowFunds' +export { default as RepayFunds } from './RepayFunds' diff --git a/components/Button.tsx b/components/Button.tsx index f4073308..ccca567a 100644 --- a/components/Button.tsx +++ b/components/Button.tsx @@ -3,7 +3,7 @@ import React from 'react' type Props = { children: string className?: string - onClick: () => void + onClick: (e: React.MouseEvent) => void disabled?: boolean } diff --git a/components/ContainerSecondary.tsx b/components/ContainerSecondary.tsx new file mode 100644 index 00000000..af8df300 --- /dev/null +++ b/components/ContainerSecondary.tsx @@ -0,0 +1,17 @@ +import React from 'react' + +const ContainerSecondary = ({ + children, + className, +}: { + children: React.ReactNode + className?: string +}) => { + return ( +
+ {children} +
+ ) +} + +export default ContainerSecondary diff --git a/components/CreditManager/CreditManagerContainer.tsx b/components/CreditManager/CreditManagerContainer.tsx deleted file mode 100644 index 4330ba78..00000000 --- a/components/CreditManager/CreditManagerContainer.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import React from 'react' - -// move this component outside and probably adapt generic container component to different UI variants -export const CreditManagerContainer = ({ - children, - className, -}: { - children: React.ReactNode - className?: string -}) => { - return
{children}
-} - -export default CreditManagerContainer diff --git a/components/CreditManager/FundAccount.tsx b/components/CreditManager/FundAccount.tsx index 169cfa74..acc88fdc 100644 --- a/components/CreditManager/FundAccount.tsx +++ b/components/CreditManager/FundAccount.tsx @@ -10,7 +10,7 @@ import useDepositCreditAccount from 'hooks/useDepositCreditAccount' import useCreditManagerStore from 'stores/useCreditManagerStore' import useAllBalances from 'hooks/useAllBalances' import { getTokenDecimals, getTokenSymbol } from 'utils/tokens' -import CreditManagerContainer from './CreditManagerContainer' +import ContainerSecondary from 'components/ContainerSecondary' const FundAccount = () => { const [amount, setAmount] = useState(0) @@ -61,7 +61,7 @@ const FundAccount = () => { return ( <> - +

Transfer assets from your injective wallet to your Mars credit account. If you don’t have any assets in your injective wallet use the injective bridge to transfer funds to your @@ -126,7 +126,7 @@ const FundAccount = () => { )} - + {isFund ? ( ) : ( <> - +

Total Position:
@@ -82,8 +82,8 @@ const CreditManager = () => {
Total Liabilities:
{formatCurrency(accountStats?.totalDebt ?? 0)}
- - + +

Balances

{isLoadingPositions ? (
Loading...
@@ -106,7 +106,7 @@ const CreditManager = () => { .div(10 ** getTokenDecimals(coin.denom)) .toNumber() .toLocaleString(undefined, { - maximumFractionDigits: 6, + maximumFractionDigits: getTokenDecimals(coin.denom), })}
-
@@ -134,7 +134,7 @@ const CreditManager = () => { ))} )} -
+ )} diff --git a/components/ProgressBar.tsx b/components/ProgressBar.tsx index a4ec4a93..e2662f25 100644 --- a/components/ProgressBar.tsx +++ b/components/ProgressBar.tsx @@ -7,10 +7,17 @@ type Props = { const ProgressBar = ({ value }: Props) => { const percentageValue = `${(value * 100).toFixed(0)}%` + let bgColorClass = 'bg-green-500' + if (value < 1 / 3) { + bgColorClass = 'bg-red-500' + } else if (value < 2 / 3) { + bgColorClass = 'bg-yellow-500' + } + return (
diff --git a/components/SemiCircleProgress.tsx b/components/SemiCircleProgress.tsx index 99da20f9..dc9c7b23 100644 --- a/components/SemiCircleProgress.tsx +++ b/components/SemiCircleProgress.tsx @@ -50,6 +50,13 @@ const SemiCircleProgress = ({ } } + let strokeColorClass = 'stroke-green-500' + if (value > 2 / 3) { + strokeColorClass = 'stroke-red-500' + } else if (value > 1 / 3) { + strokeColorClass = 'stroke-yellow-500' + } + return (
{ + return ( + document.body} + className="rounded-md bg-[#ED512F] p-2 text-xs" + content={{content}} + interactive={true} + > + + + ) +} + +export default Tooltip diff --git a/config/tokenInfo.ts b/config/tokenInfo.ts index 1ef9c285..16f38119 100644 --- a/config/tokenInfo.ts +++ b/config/tokenInfo.ts @@ -2,6 +2,7 @@ type Token = { symbol: string decimals: number icon: string + chain: string } const tokenInfo: { [key in string]: Token } = { @@ -9,11 +10,13 @@ const tokenInfo: { [key in string]: Token } = { symbol: 'OSMO', decimals: 6, icon: '/tokens/osmo.svg', + chain: 'Osmosis', }, 'ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA622B25F41E5EB2': { symbol: 'ATOM', - icon: '', + icon: '/tokens/atom.svg', decimals: 6, + chain: 'Cosmos', }, } diff --git a/hooks/useAccountStats.tsx b/hooks/useAccountStats.tsx index 1731fd8c..bbe6ae75 100644 --- a/hooks/useAccountStats.tsx +++ b/hooks/useAccountStats.tsx @@ -49,7 +49,7 @@ const useAccountStats = () => { const totalWeightedPositions = positionsData.coins.reduce((acc, coin) => { const tokenWeightedValue = BigNumber(getTokenTotalUSDValue(coin.amount, coin.denom)).times( - Number(marketsData[coin.denom].max_loan_to_value) + Number(marketsData[coin.denom].liquidation_threshold) ) return tokenWeightedValue.plus(acc).toNumber() diff --git a/hooks/useBorrowFunds.tsx b/hooks/useBorrowFunds.tsx new file mode 100644 index 00000000..e873e9d2 --- /dev/null +++ b/hooks/useBorrowFunds.tsx @@ -0,0 +1,108 @@ +import { SigningCosmWasmClient } from '@cosmjs/cosmwasm-stargate' +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { useEffect, useMemo, useState } from 'react' +import { toast } from 'react-toastify' +import BigNumber from 'bignumber.js' + +import useWalletStore from 'stores/useWalletStore' +import { chain } from 'utils/chains' +import { contractAddresses } from 'config/contracts' +import { hardcodedFee } from 'utils/contants' +import useCreditManagerStore from 'stores/useCreditManagerStore' +import { queryKeys } from 'types/query-keys-factory' + +const useBorrowFunds = ( + amount: string | number, + denom: string, + withdraw = false, + options: Omit +) => { + const [signingClient, setSigningClient] = useState() + + const selectedAccount = useCreditManagerStore((s) => s.selectedAccount) + const address = useWalletStore((s) => s.address) + + const queryClient = useQueryClient() + + useEffect(() => { + ;(async () => { + if (!window.keplr) return + + const offlineSigner = window.keplr.getOfflineSigner(chain.chainId) + const clientInstance = await SigningCosmWasmClient.connectWithSigner(chain.rpc, offlineSigner) + + setSigningClient(clientInstance) + })() + }, [address]) + + const executeMsg = useMemo(() => { + if (!withdraw) { + return { + update_credit_account: { + account_id: selectedAccount, + actions: [ + { + borrow: { + denom: denom, + amount: BigNumber(amount) + .times(10 ** 6) + .toString(), + }, + }, + ], + }, + } + } + + return { + update_credit_account: { + account_id: selectedAccount, + actions: [ + { + borrow: { + denom: denom, + amount: BigNumber(amount) + .times(10 ** 6) + .toString(), + }, + }, + { + withdraw: { + denom: denom, + amount: BigNumber(amount) + .times(10 ** 6) + .toString(), + }, + }, + ], + }, + } + }, [amount, denom, withdraw, selectedAccount]) + + return useMutation( + async () => + await signingClient?.execute( + address, + contractAddresses.creditManager, + executeMsg, + hardcodedFee + ), + { + onSettled: () => { + queryClient.invalidateQueries(queryKeys.creditAccountsPositions(selectedAccount ?? '')) + + // if withdrawing to wallet, need to explicility invalidate balances queries + if (withdraw) { + queryClient.invalidateQueries(queryKeys.tokenBalance(address, denom)) + queryClient.invalidateQueries(queryKeys.allBalances(address)) + } + }, + onError: (err: Error) => { + toast.error(err.message) + }, + ...options, + } + ) +} + +export default useBorrowFunds diff --git a/hooks/useCalculateMaxBorrowAmount.tsx b/hooks/useCalculateMaxBorrowAmount.tsx new file mode 100644 index 00000000..6d3b0c4c --- /dev/null +++ b/hooks/useCalculateMaxBorrowAmount.tsx @@ -0,0 +1,63 @@ +import { useMemo } from 'react' +import BigNumber from 'bignumber.js' + +import useCreditManagerStore from 'stores/useCreditManagerStore' +import { getTokenDecimals } from 'utils/tokens' +import useCreditAccountPositions from './useCreditAccountPositions' +import useMarkets from './useMarkets' +import useTokenPrices from './useTokenPrices' + +const useCalculateMaxBorrowAmount = (denom: string, isUnderCollateralized: boolean) => { + const selectedAccount = useCreditManagerStore((s) => s.selectedAccount) + + const { data: positionsData } = useCreditAccountPositions(selectedAccount ?? '') + const { data: marketsData } = useMarkets() + const { data: tokenPrices } = useTokenPrices() + + return useMemo(() => { + if (!marketsData || !tokenPrices || !positionsData) return 0 + + const getTokenTotalUSDValue = (amount: string, denom: string) => { + // early return if prices are not fetched yet + if (!tokenPrices) return 0 + + return BigNumber(amount) + .div(10 ** getTokenDecimals(denom)) + .times(tokenPrices[denom]) + .toNumber() + } + + const totalWeightedPositions = positionsData?.coins.reduce((acc, coin) => { + const tokenWeightedValue = BigNumber(getTokenTotalUSDValue(coin.amount, coin.denom)).times( + Number(marketsData[coin.denom].max_loan_to_value) + ) + + return tokenWeightedValue.plus(acc).toNumber() + }, 0) + + const totalLiabilitiesValue = positionsData?.debts.reduce((acc, coin) => { + const tokenUSDValue = BigNumber(getTokenTotalUSDValue(coin.amount, coin.denom)) + + return tokenUSDValue.plus(acc).toNumber() + }, 0) + + const borrowTokenPrice = tokenPrices[denom] + const tokenDecimals = getTokenDecimals(denom) + + if (isUnderCollateralized) { + return BigNumber(totalLiabilitiesValue) + .minus(totalWeightedPositions) + .div(borrowTokenPrice * Number(marketsData[denom].max_loan_to_value) - borrowTokenPrice) + .decimalPlaces(tokenDecimals) + .toNumber() + } else { + return BigNumber(totalWeightedPositions) + .minus(totalLiabilitiesValue) + .div(borrowTokenPrice) + .decimalPlaces(tokenDecimals) + .toNumber() + } + }, [denom, isUnderCollateralized, marketsData, positionsData, tokenPrices]) +} + +export default useCalculateMaxBorrowAmount diff --git a/hooks/useMarkets.tsx b/hooks/useMarkets.tsx index 894da5e1..164c1a8b 100644 --- a/hooks/useMarkets.tsx +++ b/hooks/useMarkets.tsx @@ -38,8 +38,8 @@ const useMarkets = () => { wasm: { uosmo: { denom: 'uosmo', - max_loan_to_value: '0.7', - liquidation_threshold: '0.65', + max_loan_to_value: '0.65', + liquidation_threshold: '0.7', liquidation_bonus: '0.1', reserve_factor: '0.2', interest_rate_model: { @@ -61,8 +61,8 @@ const useMarkets = () => { }, 'ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA622B25F41E5EB2': { denom: 'ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA622B25F41E5EB2', - max_loan_to_value: '0.8', - liquidation_threshold: '0.7', + max_loan_to_value: '0.77', + liquidation_threshold: '0.8', liquidation_bonus: '0.1', reserve_factor: '0.2', interest_rate_model: { diff --git a/hooks/useRepayFunds.tsx b/hooks/useRepayFunds.tsx new file mode 100644 index 00000000..6ffee088 --- /dev/null +++ b/hooks/useRepayFunds.tsx @@ -0,0 +1,77 @@ +import { SigningCosmWasmClient } from '@cosmjs/cosmwasm-stargate' +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { useEffect, useMemo, useState } from 'react' +import { toast } from 'react-toastify' +import BigNumber from 'bignumber.js' + +import useWalletStore from 'stores/useWalletStore' +import { chain } from 'utils/chains' +import { contractAddresses } from 'config/contracts' +import { hardcodedFee } from 'utils/contants' +import useCreditManagerStore from 'stores/useCreditManagerStore' +import { queryKeys } from 'types/query-keys-factory' + +const useRepayFunds = ( + amount: string | number, + denom: string, + options: Omit +) => { + const [signingClient, setSigningClient] = useState() + + const selectedAccount = useCreditManagerStore((s) => s.selectedAccount) + const address = useWalletStore((s) => s.address) + + const queryClient = useQueryClient() + + useEffect(() => { + ;(async () => { + if (!window.keplr) return + + const offlineSigner = window.keplr.getOfflineSigner(chain.chainId) + const clientInstance = await SigningCosmWasmClient.connectWithSigner(chain.rpc, offlineSigner) + + setSigningClient(clientInstance) + })() + }, [address]) + + const executeMsg = useMemo(() => { + return { + update_credit_account: { + account_id: selectedAccount, + actions: [ + { + repay: { + denom: denom, + amount: BigNumber(amount) + .times(10 ** 6) + .toString(), + }, + }, + ], + }, + } + }, [amount, denom, selectedAccount]) + + return useMutation( + async () => + await signingClient?.execute( + address, + contractAddresses.creditManager, + executeMsg, + hardcodedFee + ), + { + onSettled: () => { + queryClient.invalidateQueries(queryKeys.creditAccountsPositions(selectedAccount ?? '')) + queryClient.invalidateQueries(queryKeys.tokenBalance(address, denom)) + queryClient.invalidateQueries(queryKeys.allBalances(address)) + }, + onError: (err: Error) => { + toast.error(err.message) + }, + ...options, + } + ) +} + +export default useRepayFunds diff --git a/package.json b/package.json index ba4baa4e..5afa8f8a 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,8 @@ "@radix-ui/react-slider": "^1.0.0", "@sentry/nextjs": "^7.12.1", "@tanstack/react-query": "^4.3.4", + "@tanstack/react-table": "^8.5.15", + "@tippyjs/react": "^4.2.6", "bech32": "^2.0.0", "bignumber.js": "^9.1.0", "ethereumjs-util": "^7.1.5", diff --git a/pages/borrow.tsx b/pages/borrow.tsx index 1fcb7466..66dae478 100644 --- a/pages/borrow.tsx +++ b/pages/borrow.tsx @@ -1,69 +1,152 @@ -import React from 'react' -import Image from 'next/image' +import React, { useMemo, useState } from 'react' +import BigNumber from 'bignumber.js' import Container from 'components/Container' +import useAllowedCoins from 'hooks/useAllowedCoins' +import { getTokenDecimals, getTokenInfo } from 'utils/tokens' +import useCreditAccountPositions from 'hooks/useCreditAccountPositions' +import useCreditManagerStore from 'stores/useCreditManagerStore' +import useMarkets from 'hooks/useMarkets' +import useTokenPrices from 'hooks/useTokenPrices' +import { BorrowFunds, RepayFunds } from 'components/Borrow' +import BorrowTable from 'components/Borrow/BorrowTable' -const AssetRow = () => { - return ( -
-
- token -
-
DENOM
-
Name
-
-
-
10.00%
-
-
Amount
-
Value
-
-
-
Amount
-
Value
-
-
ACTION
-
- ) -} +type ModuleState = + | { + show: 'borrow' + data: { + tokenDenom: string + } + } + | { + show: 'repay' + data: { + tokenDenom: string + amount: number + } + } const Borrow = () => { + const [moduleState, setModuleState] = useState(null) + + const selectedAccount = useCreditManagerStore((s) => s.selectedAccount) + + const { data: allowedCoinsData } = useAllowedCoins() + const { data: positionsData } = useCreditAccountPositions(selectedAccount ?? '') + const { data: marketsData } = useMarkets() + const { data: tokenPrices } = useTokenPrices() + + const borrowedAssetsMap = useMemo(() => { + let borrowedAssetsMap: Map = new Map() + + positionsData?.debts.forEach((coin) => { + borrowedAssetsMap.set(coin.denom, coin.amount) + }) + + return borrowedAssetsMap + }, [positionsData]) + + const { borrowedAssets, notBorrowedAssets } = useMemo(() => { + return { + borrowedAssets: + allowedCoinsData + ?.filter((denom) => borrowedAssetsMap.has(denom)) + .map((denom) => { + const { symbol, chain, icon } = getTokenInfo(denom) + const borrowRate = Number(marketsData?.[denom].borrow_rate) || 0 + const marketLiquidity = BigNumber(marketsData?.[denom].deposit_cap ?? '') + .div(10 ** getTokenDecimals(denom)) + .toNumber() + + const borrowAmount = BigNumber(borrowedAssetsMap.get(denom) as string) + .div(10 ** getTokenDecimals(denom)) + .toNumber() + const borrowValue = borrowAmount * (tokenPrices?.[denom] ?? 0) + + const rowData = { + denom, + symbol, + icon, + chain, + borrowed: { + amount: borrowAmount, + value: borrowValue, + }, + borrowRate, + marketLiquidity, + } + + return rowData + }) ?? [], + notBorrowedAssets: + allowedCoinsData + ?.filter((denom) => !borrowedAssetsMap.has(denom)) + .map((denom) => { + const { symbol, chain, icon } = getTokenInfo(denom) + const borrowRate = Number(marketsData?.[denom].borrow_rate) || 0 + const marketLiquidity = BigNumber(marketsData?.[denom].deposit_cap ?? '') + .div(10 ** getTokenDecimals(denom)) + .toNumber() + + const rowData = { + denom, + symbol, + icon, + chain, + borrowed: null, + borrowRate, + marketLiquidity, + } + + return rowData + }) ?? [], + } + }, [allowedCoinsData, borrowedAssetsMap, marketsData, tokenPrices]) + + const handleBorrowClick = (denom: string) => { + setModuleState({ show: 'borrow', data: { tokenDenom: denom } }) + } + + const handleRepayClick = (denom: string, repayAmount: number) => { + setModuleState({ show: 'repay', data: { tokenDenom: denom, amount: repayAmount } }) + } + return ( -
- -
-

Borrowed

-
-
Asset
-
Borrow Rate
-
Borrowed
-
Liquidity Available
-
Manage
+
+
+ +
+

Borrowed

+
-
- {Array.from(Array(3).keys()).map(() => ( - // eslint-disable-next-line react/jsx-key - - ))} +
+

Not Borrowed Yet

+
-
-
-

Not Borrowed Yet

-
-
Asset
-
Borrow Rate
-
Borrowed
-
Liquidity Available
-
Manage
-
-
- {Array.from(Array(5).keys()).map(() => ( - // eslint-disable-next-line react/jsx-key - - ))} -
-
-
+ +
+ {moduleState?.show === 'borrow' && ( + setModuleState(null)} + /> + )} + {moduleState?.show === 'repay' && ( + setModuleState(null)} + /> + )}
) } diff --git a/public/tokens/atom.svg b/public/tokens/atom.svg new file mode 100644 index 00000000..ed377e8b --- /dev/null +++ b/public/tokens/atom.svg @@ -0,0 +1,44 @@ + + cosmos-atom-logo + + + + + + + + + + diff --git a/utils/tokens.ts b/utils/tokens.ts index 340c9752..3ddb5728 100644 --- a/utils/tokens.ts +++ b/utils/tokens.ts @@ -7,3 +7,11 @@ export const getTokenSymbol = (denom: string) => { export const getTokenDecimals = (denom: string) => { return tokenInfo[denom]?.decimals ?? 6 } + +export const getTokenIcon = (denom: string) => { + return tokenInfo[denom].icon +} + +export const getTokenInfo = (denom: string) => { + return tokenInfo[denom] +} diff --git a/yarn.lock b/yarn.lock index 78f03687..ceb5c649 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1704,6 +1704,11 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" +"@popperjs/core@^2.9.0": + version "2.11.6" + resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.6.tgz#cee20bd55e68a1720bdab363ecf0c821ded4cd45" + integrity sha512-50/17A98tWUfQ176raKiOGXuYpLyyVMkxxG6oylzL3BPOlA6ADGdK7EYunSa4I064xerltq9TGXs8HmOk5E+vw== + "@protobufjs/aspromise@^1.1.1", "@protobufjs/aspromise@^1.1.2": version "1.1.2" resolved "https://registry.yarnpkg.com/@protobufjs/aspromise/-/aspromise-1.1.2.tgz#9b8b0cc663d669a7d8f6f5d0893a14d348f30fbf" @@ -2148,6 +2153,25 @@ "@tanstack/query-core" "4.3.4" use-sync-external-store "^1.2.0" +"@tanstack/react-table@^8.5.15": + version "8.5.15" + resolved "https://registry.yarnpkg.com/@tanstack/react-table/-/react-table-8.5.15.tgz#8179d24d7fdf909799a517e8897501c44e51284d" + integrity sha512-9rSvhIFeMpfXksFgQNTWnVoJbkae/U8CkHnHYGWAIB/O0Ca51IKap0Rjp5WkIUVBWxJ7Wfl2y13oY+aWcyM6Rg== + dependencies: + "@tanstack/table-core" "8.5.15" + +"@tanstack/table-core@8.5.15": + version "8.5.15" + resolved "https://registry.yarnpkg.com/@tanstack/table-core/-/table-core-8.5.15.tgz#e1e674135cd6c36f29a1562a2b846f824861149b" + integrity sha512-k+BcCOAYD610Cij6p1BPyEqjMQjZIdAnVDoIUKVnA/tfHbF4JlDP7pKAftXPBxyyX5Z1yQPurPnOdEY007Snyg== + +"@tippyjs/react@^4.2.6": + version "4.2.6" + resolved "https://registry.yarnpkg.com/@tippyjs/react/-/react-4.2.6.tgz#971677a599bf663f20bb1c60a62b9555b749cc71" + integrity sha512-91RicDR+H7oDSyPycI13q3b7o4O60wa2oRbjlz2fyRLmHImc4vyDwuUP8NtZaN0VARJY5hybvDYrFzhY9+Lbyw== + dependencies: + tippy.js "^6.3.1" + "@trysound/sax@0.2.0": version "0.2.0" resolved "https://registry.yarnpkg.com/@trysound/sax/-/sax-0.2.0.tgz#cccaab758af56761eb7bf37af6f03f326dd798ad" @@ -6037,6 +6061,13 @@ tiny-secp256k1@^1.1.3: elliptic "^6.4.0" nan "^2.13.2" +tippy.js@^6.3.1: + version "6.3.7" + resolved "https://registry.yarnpkg.com/tippy.js/-/tippy.js-6.3.7.tgz#8ccfb651d642010ed9a32ff29b0e9e19c5b8c61c" + integrity sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ== + dependencies: + "@popperjs/core" "^2.9.0" + to-fast-properties@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz"