mirror of
https://github.com/cerc-io/mars-interface.git
synced 2025-07-04 23:58:00 +00:00
600 lines
18 KiB
TypeScript
600 lines
18 KiB
TypeScript
import 'chart.js/auto'
|
|
|
|
import BigNumber from 'bignumber.js'
|
|
import classNames from 'classnames'
|
|
import {
|
|
BorrowCapacity,
|
|
Button,
|
|
Card,
|
|
ConnectButton,
|
|
DisplayCurrency,
|
|
ErrorMessage,
|
|
InputSection,
|
|
} from 'components/common'
|
|
import { DEFAULT_SLIPPAGE } from 'constants/appConstants'
|
|
import { findByDenom } from 'functions'
|
|
import { maxBorrowableAmount } from 'functions/redbank/maxBorrowableAmount'
|
|
import { produceBarChartConfig } from 'functions/redbank/produceBarChartConfig'
|
|
import { produceUpdatedAssetData } from 'functions/redbank/produceUpdatedAssetData'
|
|
import { useUserBalance } from 'hooks/queries'
|
|
import {
|
|
balanceSum,
|
|
ltvWeightedDepositValue,
|
|
maintainanceMarginWeightedDepositValue,
|
|
producePercentData,
|
|
} from 'libs/assetInfo'
|
|
import { formatValue, lookup, lookupSymbol } from 'libs/parse'
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
import { Bar } from 'react-chartjs-2'
|
|
import { useTranslation } from 'react-i18next'
|
|
import useStore from 'store'
|
|
import colors from 'styles/_assets.module.scss'
|
|
import { ViewType } from 'types/enums'
|
|
|
|
import styles from './Action.module.scss'
|
|
|
|
interface Props {
|
|
amount: number
|
|
redBankAssets: RedBankAsset[]
|
|
depositAssets: RedBankAsset[]
|
|
borrowAssets: RedBankAsset[]
|
|
setIsMax: (isMax: boolean) => void
|
|
setCapHit: (capHit: boolean) => void
|
|
setAmountCallback: (amount: number) => void
|
|
mmScaledDepositAmount: number
|
|
ltvScaledDepositAmount: number
|
|
totalBorrowBaseCurrencyAmount: number
|
|
actionButtonSpec: ModalActionButton
|
|
submitted: boolean
|
|
feeError?: string
|
|
activeView: ViewType
|
|
denom: string
|
|
decimals: number
|
|
handleClose: () => void
|
|
}
|
|
|
|
export const Action = ({
|
|
amount,
|
|
redBankAssets,
|
|
depositAssets,
|
|
borrowAssets,
|
|
setIsMax,
|
|
setCapHit,
|
|
setAmountCallback,
|
|
mmScaledDepositAmount,
|
|
ltvScaledDepositAmount,
|
|
totalBorrowBaseCurrencyAmount,
|
|
actionButtonSpec,
|
|
submitted,
|
|
feeError,
|
|
activeView,
|
|
denom,
|
|
decimals,
|
|
handleClose,
|
|
}: Props) => {
|
|
const { t } = useTranslation()
|
|
// ------------------
|
|
// STORE STATE
|
|
// ------------------
|
|
const baseCurrency = useStore((s) => s.baseCurrency)
|
|
const marketInfo = useStore((s) => s.marketInfo)
|
|
const marketAssetLiquidity = useStore((s) => s.marketAssetLiquidity)
|
|
const userCollateral = useStore((s) => s.userCollateral)
|
|
const userWalletAddress = useStore((s) => s.userWalletAddress)
|
|
const whitelistedAssets = useStore((s) => s.whitelistedAssets)
|
|
const convertToBaseCurrency = useStore((s) => s.convertToBaseCurrency)
|
|
const findUserDebt = useStore((s) => s.findUserDebt)
|
|
const enableAnimations = useStore((s) => s.enableAnimations)
|
|
const baseCurrencyDecimals = useStore((s) => s.baseCurrency.decimals)
|
|
|
|
// ------------------
|
|
// LOCAL STATE
|
|
// ------------------
|
|
const [currentAssetPrice, setCurrentAssetPrice] = useState(0)
|
|
const [portfolioVisible, setPortfolioVisible] = useState(false)
|
|
const [chartsDataLoaded, setChartsDataLoaded] = useState(false)
|
|
|
|
const { data: userBalances } = useUserBalance()
|
|
|
|
/// ------------------
|
|
// VARIABLES
|
|
// ------------------
|
|
const walletBalance = Number(findByDenom(userBalances || [], denom)?.amount.toString()) || 0
|
|
const assetBorrowBalance = findUserDebt(denom)
|
|
const availableBalanceBaseCurrency = Math.max(
|
|
ltvScaledDepositAmount - totalBorrowBaseCurrencyAmount,
|
|
0,
|
|
)
|
|
const currentAsset = redBankAssets.find((asset) => asset.denom === denom)
|
|
|
|
// -------------------------
|
|
// calculate
|
|
// -------------------------
|
|
const relevantAssetData = useMemo(
|
|
() =>
|
|
activeView === ViewType.Deposit || activeView === ViewType.Withdraw
|
|
? depositAssets
|
|
: borrowAssets,
|
|
[depositAssets, borrowAssets, activeView],
|
|
)
|
|
|
|
const relevantBalanceKey = useMemo(
|
|
() =>
|
|
activeView === ViewType.Deposit || activeView === ViewType.Withdraw
|
|
? 'depositBalanceBaseCurrency'
|
|
: 'borrowBalanceBaseCurrency',
|
|
[activeView],
|
|
)
|
|
|
|
const amountAdjustedAssetData = useMemo(
|
|
() =>
|
|
produceUpdatedAssetData(
|
|
redBankAssets,
|
|
[...relevantAssetData],
|
|
denom,
|
|
amount * currentAssetPrice, // amount in display currency
|
|
activeView,
|
|
relevantBalanceKey,
|
|
baseCurrencyDecimals,
|
|
),
|
|
[
|
|
activeView,
|
|
amount,
|
|
relevantAssetData,
|
|
currentAssetPrice,
|
|
denom,
|
|
redBankAssets,
|
|
relevantBalanceKey,
|
|
baseCurrencyDecimals,
|
|
],
|
|
)
|
|
|
|
const percentData = producePercentData(
|
|
produceUpdatedAssetData(
|
|
redBankAssets,
|
|
[...relevantAssetData],
|
|
denom,
|
|
0.0,
|
|
activeView,
|
|
relevantBalanceKey,
|
|
baseCurrencyDecimals,
|
|
),
|
|
relevantBalanceKey,
|
|
)
|
|
const updatedData = producePercentData(amountAdjustedAssetData, relevantBalanceKey)
|
|
|
|
// ---------------------
|
|
// logic
|
|
// ---------------------
|
|
const newTotalMMScaledSupplyBalance = useMemo(
|
|
() =>
|
|
// For deposits and withdraws, we need to recalculate the loan limit
|
|
{
|
|
if (!userCollateral) return 0
|
|
// On first deposit of asset, SC does not hold state of collateral.enabled
|
|
// Therefore, we need to emulate this state
|
|
const isFirstDeposit =
|
|
!relevantAssetData.find((asset) => asset.denom === denom) &&
|
|
activeView === ViewType.Deposit
|
|
|
|
return activeView === ViewType.Deposit || activeView === ViewType.Withdraw
|
|
? maintainanceMarginWeightedDepositValue(
|
|
amountAdjustedAssetData,
|
|
marketInfo,
|
|
userCollateral,
|
|
relevantBalanceKey,
|
|
isFirstDeposit ? denom : '',
|
|
)
|
|
: mmScaledDepositAmount
|
|
},
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
[activeView, amountAdjustedAssetData, mmScaledDepositAmount],
|
|
)
|
|
|
|
const newTotalLTVScaledSupplyBalance = useMemo(
|
|
() =>
|
|
// For deposits and withdraws, we need to recalculate the loan limit
|
|
{
|
|
if (!userCollateral) return 0
|
|
// On first deposit of asset, SC does not hold state of collateral.enabled
|
|
// Therefore, we need to emulate this state
|
|
const isFirstDeposit =
|
|
!relevantAssetData.find((asset) => asset.denom === denom) &&
|
|
activeView === ViewType.Deposit
|
|
|
|
return activeView === ViewType.Deposit || activeView === ViewType.Withdraw
|
|
? ltvWeightedDepositValue(
|
|
amountAdjustedAssetData,
|
|
marketInfo,
|
|
userCollateral,
|
|
relevantBalanceKey,
|
|
isFirstDeposit ? denom : '',
|
|
)
|
|
: ltvScaledDepositAmount
|
|
},
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
[activeView, amountAdjustedAssetData, ltvScaledDepositAmount],
|
|
)
|
|
|
|
const debtValue =
|
|
activeView === ViewType.Borrow || activeView === ViewType.Repay
|
|
? balanceSum(amountAdjustedAssetData, relevantBalanceKey)
|
|
: totalBorrowBaseCurrencyAmount
|
|
|
|
const calculateMaxBorrowableAmount = useMemo((): number => {
|
|
const assetLiquidity = Number(findByDenom(marketAssetLiquidity, denom)?.amount || 0)
|
|
|
|
return maxBorrowableAmount(
|
|
assetLiquidity,
|
|
availableBalanceBaseCurrency,
|
|
new BigNumber(currentAssetPrice)
|
|
.shiftedBy(baseCurrency.decimals - (currentAsset?.decimals || 0))
|
|
.toNumber(),
|
|
)
|
|
}, [
|
|
denom,
|
|
availableBalanceBaseCurrency,
|
|
currentAssetPrice,
|
|
marketAssetLiquidity,
|
|
baseCurrency.decimals,
|
|
currentAsset?.decimals,
|
|
])
|
|
|
|
const repayMax = Math.min(assetBorrowBalance, walletBalance)
|
|
|
|
const maxWithdrawableAmount = useMemo((): number => {
|
|
const assetLtvRatio = findByDenom(marketInfo, denom)?.max_loan_to_value || 0
|
|
const assetLiquidity = Number(findByDenom(marketAssetLiquidity, denom)?.amount || 0)
|
|
const asset = depositAssets.find((asset) => asset.denom === denom)
|
|
const assetBalanceOrAvailableLiquidity = Math.min(Number(asset?.depositBalance), assetLiquidity)
|
|
|
|
if (totalBorrowBaseCurrencyAmount === 0) {
|
|
return assetBalanceOrAvailableLiquidity
|
|
}
|
|
|
|
// If we did not receive a usable asset there is nothing more to do.
|
|
if (!asset || !asset.depositBalance || !asset.denom) return 0
|
|
|
|
// When withdrawing, we have to remove the slippage, otherwise we can't actually hit the borrow limit.
|
|
const withdrawableAmountOfAsset = new BigNumber(
|
|
availableBalanceBaseCurrency / (1 - DEFAULT_SLIPPAGE) / (currentAssetPrice * assetLtvRatio),
|
|
)
|
|
.shiftedBy(asset.decimals - baseCurrency.decimals)
|
|
.toNumber()
|
|
|
|
return withdrawableAmountOfAsset < assetBalanceOrAvailableLiquidity
|
|
? withdrawableAmountOfAsset
|
|
: assetBalanceOrAvailableLiquidity
|
|
}, [
|
|
denom,
|
|
currentAssetPrice,
|
|
depositAssets,
|
|
availableBalanceBaseCurrency,
|
|
totalBorrowBaseCurrencyAmount,
|
|
marketInfo,
|
|
marketAssetLiquidity,
|
|
baseCurrency.decimals,
|
|
])
|
|
|
|
const maxUsableAmount = useMemo(() => {
|
|
if (!currentAsset) return 0
|
|
return activeView === ViewType.Deposit
|
|
? walletBalance
|
|
: activeView === ViewType.Withdraw
|
|
? maxWithdrawableAmount
|
|
: activeView === ViewType.Borrow
|
|
? calculateMaxBorrowableAmount
|
|
: repayMax
|
|
}, [
|
|
walletBalance,
|
|
maxWithdrawableAmount,
|
|
calculateMaxBorrowableAmount,
|
|
repayMax,
|
|
activeView,
|
|
currentAsset,
|
|
])
|
|
|
|
useEffect(() => {
|
|
setCurrentAssetPrice(convertToBaseCurrency({ denom: denom || '', amount: '1' }))
|
|
}, [denom, convertToBaseCurrency])
|
|
|
|
useEffect(() => {
|
|
if (!chartsDataLoaded && percentData[0] != 0) {
|
|
setChartsDataLoaded(true)
|
|
}
|
|
}, [percentData, chartsDataLoaded])
|
|
|
|
const chartRefBefore = useRef(null)
|
|
const chartRefAfter = useRef(null)
|
|
|
|
// -----------
|
|
// callbacks
|
|
// -----------
|
|
const handleInputAmount = useCallback(
|
|
(inputAmount: number) => {
|
|
if (inputAmount >= maxUsableAmount * 0.99) {
|
|
setIsMax(true)
|
|
}
|
|
|
|
setAmountCallback(Number(formatValue(inputAmount, 0, 0, false, false, false, false, false)))
|
|
},
|
|
[maxUsableAmount, setIsMax, setAmountCallback],
|
|
)
|
|
|
|
if (!currentAsset) return <></>
|
|
|
|
const amountUntilDepositCap = currentAsset.depositCap - Number(currentAsset.depositLiquidity)
|
|
|
|
const onValueEntered = (microValue: number) => {
|
|
if (microValue >= maxUsableAmount) microValue = maxUsableAmount
|
|
setAmountCallback(Number(formatValue(microValue, 0, 0, false, false, false, false, false)))
|
|
setCapHit(amount > amountUntilDepositCap && activeView === ViewType.Deposit)
|
|
}
|
|
|
|
const produceTabActionButton = () => {
|
|
return (
|
|
<>
|
|
<Button
|
|
color='primary'
|
|
className={styles.submitButton}
|
|
disabled={actionButtonSpec.disabled}
|
|
onClick={() => actionButtonSpec.clickHandler()}
|
|
showProgressIndicator={actionButtonSpec.fetching}
|
|
text={actionButtonSpec.text}
|
|
/>
|
|
<ErrorMessage message={feeError} alignment='center' />
|
|
</>
|
|
)
|
|
}
|
|
|
|
const onEnterAction = () => {
|
|
if (!actionButtonSpec.disabled) actionButtonSpec.clickHandler()
|
|
}
|
|
|
|
const produceAvailableText = () => {
|
|
switch (activeView) {
|
|
case ViewType.Borrow:
|
|
return t('common.maxLimitAmountSymbol', {
|
|
amount: formatValue(
|
|
lookup(maxUsableAmount, denom, decimals),
|
|
0,
|
|
decimals,
|
|
true,
|
|
'',
|
|
'',
|
|
false,
|
|
false,
|
|
),
|
|
symbol: lookupSymbol(denom, whitelistedAssets || []),
|
|
})
|
|
|
|
case ViewType.Deposit:
|
|
return t('common.inWalletAmountSymbol', {
|
|
amount: formatValue(
|
|
lookup(walletBalance, denom, decimals),
|
|
0,
|
|
decimals,
|
|
true,
|
|
'',
|
|
'',
|
|
false,
|
|
false,
|
|
),
|
|
symbol: lookupSymbol(denom, whitelistedAssets || []),
|
|
})
|
|
|
|
case ViewType.Withdraw:
|
|
// Find amount of asset deposited
|
|
const asset: RedBankAsset | undefined = depositAssets.find((asset) => asset.denom === denom)
|
|
return t('common.depositedAmountSymbol', {
|
|
amount: formatValue(
|
|
lookup(Number(asset?.depositBalance), denom, decimals),
|
|
0,
|
|
decimals,
|
|
true,
|
|
'',
|
|
'',
|
|
false,
|
|
false,
|
|
),
|
|
symbol: lookupSymbol(denom, whitelistedAssets || []),
|
|
})
|
|
|
|
case ViewType.Repay:
|
|
return t('redbank.borrowedAmountSymbol', {
|
|
amount: formatValue(
|
|
lookup(findUserDebt(denom), denom, decimals),
|
|
0,
|
|
decimals,
|
|
true,
|
|
'',
|
|
'',
|
|
false,
|
|
false,
|
|
),
|
|
symbol: lookupSymbol(denom, whitelistedAssets || []),
|
|
})
|
|
}
|
|
|
|
return ''
|
|
}
|
|
|
|
// -------------
|
|
// Presentation
|
|
// -------------
|
|
|
|
const produceBarChartData = (percentData: Array<number>, labels: string[]) => {
|
|
const barColors: string[] = []
|
|
labels.forEach((label) => {
|
|
barColors.push(colors[label.split('.')[0].toLowerCase()])
|
|
})
|
|
return {
|
|
labels: labels,
|
|
datasets: [
|
|
{
|
|
axis: 'x',
|
|
barPercentage: 0.8,
|
|
maxBarThickness: 50,
|
|
data: percentData,
|
|
fill: true,
|
|
backgroundColor: barColors,
|
|
borderWidth: 1,
|
|
animation: {
|
|
duration: enableAnimations ? 800 : 0,
|
|
},
|
|
},
|
|
],
|
|
}
|
|
}
|
|
|
|
const barChartHeight = 40 * percentData.length + 10
|
|
|
|
const actionButton = !userWalletAddress ? (
|
|
<ConnectButton color={'secondary'} />
|
|
) : (
|
|
produceTabActionButton()
|
|
)
|
|
|
|
const adjustedLabels = amountAdjustedAssetData.map((asset) =>
|
|
lookupSymbol(asset.denom || '', whitelistedAssets || []),
|
|
)
|
|
|
|
const getTooltip = (): string | undefined => {
|
|
switch (activeView) {
|
|
case ViewType.Borrow:
|
|
return t('redbank.tooltips.borrow.action')
|
|
case ViewType.Deposit:
|
|
return t('redbank.tooltips.deposit.action')
|
|
case ViewType.Withdraw:
|
|
return t('redbank.tooltips.withdraw.action')
|
|
case ViewType.Repay:
|
|
return t('redbank.tooltips.repay.action')
|
|
}
|
|
}
|
|
|
|
const collapsableStyles = classNames(styles.collapsable, !portfolioVisible && styles.collapsed)
|
|
|
|
return (
|
|
<Card onClick={handleClose} title={activeView} tooltip={getTooltip()}>
|
|
<InputSection
|
|
actionButton={actionButton}
|
|
amount={amount}
|
|
availableText={produceAvailableText()}
|
|
checkForMaxValue={activeView === ViewType.Deposit || activeView === ViewType.Repay}
|
|
asset={currentAsset}
|
|
disabled={
|
|
submitted ||
|
|
(amountUntilDepositCap <= 0 && activeView === ViewType.Deposit) ||
|
|
maxUsableAmount < 1
|
|
}
|
|
inputCallback={onValueEntered}
|
|
maxUsableAmount={maxUsableAmount}
|
|
onEnterHandler={onEnterAction}
|
|
setAmountCallback={handleInputAmount}
|
|
amountUntilDepositCap={amountUntilDepositCap}
|
|
activeView={activeView}
|
|
walletBalance={walletBalance}
|
|
/>
|
|
|
|
{/* SITUATION COMPARISON */}
|
|
<div className={styles.newSituation}>
|
|
<div className={styles.borrowCapacityContainer}>
|
|
<div className={styles.borrowCapacity}>
|
|
<div className={styles.borrowCapacityTitle}>
|
|
<span className={`overline ${styles.title}`}>
|
|
{activeView === ViewType.Withdraw || activeView === ViewType.Deposit
|
|
? t('common.currentDepositBalance')
|
|
: t('common.currentBorrowBalance')}
|
|
</span>
|
|
<DisplayCurrency
|
|
className={styles.value}
|
|
coin={{
|
|
denom: baseCurrency.denom,
|
|
amount: balanceSum(relevantAssetData, relevantBalanceKey).toString(),
|
|
}}
|
|
prefixClass='sub2'
|
|
valueClass='h4'
|
|
/>
|
|
</div>
|
|
<BorrowCapacity
|
|
balance={totalBorrowBaseCurrencyAmount}
|
|
barHeight={'17px'}
|
|
limit={ltvScaledDepositAmount}
|
|
max={mmScaledDepositAmount}
|
|
showPercentageText
|
|
fadeTitle
|
|
/>
|
|
</div>
|
|
<div className={styles.borrowCapacity}>
|
|
<div className={styles.borrowCapacityTitle}>
|
|
<span className={`overline ${styles.title}`}>
|
|
{activeView === ViewType.Withdraw || activeView === ViewType.Deposit
|
|
? t('common.newDepositBalance')
|
|
: t('common.newBorrowBalance')}
|
|
</span>
|
|
<DisplayCurrency
|
|
className={styles.value}
|
|
coin={{
|
|
denom: baseCurrency.denom,
|
|
amount: balanceSum(amountAdjustedAssetData, relevantBalanceKey).toString(),
|
|
}}
|
|
prefixClass='sub2'
|
|
valueClass='h4'
|
|
/>
|
|
</div>
|
|
<BorrowCapacity
|
|
balance={debtValue}
|
|
barHeight={'17px'}
|
|
limit={newTotalLTVScaledSupplyBalance}
|
|
max={newTotalMMScaledSupplyBalance}
|
|
showPercentageText
|
|
fadeTitle
|
|
/>
|
|
</div>
|
|
</div>
|
|
{chartsDataLoaded && (
|
|
<div className={collapsableStyles}>
|
|
<div className={styles.portfolio}>
|
|
<div className={styles.portfolioWrapper}>
|
|
<span className={`overline ${styles.title}`}>
|
|
{t('redbank.currentComposition')}
|
|
</span>
|
|
<div className={styles.chartWrapper}>
|
|
<Bar
|
|
data={produceBarChartData(percentData, adjustedLabels)}
|
|
height={barChartHeight}
|
|
options={produceBarChartConfig(percentData)}
|
|
ref={chartRefBefore}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className={styles.portfolioWrapper}>
|
|
<span className={`overline ${styles.title}`}>{t('redbank.newComposition')}</span>
|
|
<div className={styles.chartWrapper}>
|
|
<Bar
|
|
data={produceBarChartData(updatedData, adjustedLabels)}
|
|
height={barChartHeight}
|
|
options={produceBarChartConfig(updatedData)}
|
|
ref={chartRefAfter}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{chartsDataLoaded && (
|
|
<div className={styles.showPortfolio}>
|
|
<Button
|
|
onClick={() => setPortfolioVisible(!portfolioVisible)}
|
|
size='medium'
|
|
text={!portfolioVisible ? t('common.showComposition') : t('common.closeComposition')}
|
|
variant='transparent'
|
|
/>
|
|
</div>
|
|
)}
|
|
</Card>
|
|
)
|
|
}
|