From a774b0891ef88bdb00515bd239abf82dc658ed73 Mon Sep 17 00:00:00 2001 From: 0xodia <0xodia@solend.fi> Date: Wed, 12 Jul 2023 18:35:17 -0400 Subject: [PATCH 1/3] v2 changes --- solend-lite/package.json | 2 + .../AccountMetrics/AccountMetrics.module.scss | 3 + .../AccountMetrics/AccountMetrics.tsx | 185 +++++--- .../Breakdown/Breakdown.module.scss | 27 ++ .../src/components/Breakdown/Breakdown.tsx | 447 ++++++++++++++++++ .../src/components/Metric/Metric.module.scss | 12 +- solend-lite/src/components/Metric/Metric.tsx | 30 +- solend-lite/src/components/Pool/Pool.tsx | 57 ++- .../src/components/Pool/PoolList/PoolList.tsx | 24 +- .../components/Pool/PoolTable/PoolTable.tsx | 15 +- .../RefreshDataButton/RefreshDataButton.tsx | 5 +- .../TransactionTakeover/BigInput/BigInput.tsx | 2 +- .../ReserveStats/ReserveStats.tsx | 133 +++++- .../TransactionTakeover.tsx | 8 +- .../TransactionTakeover/configs.tsx | 132 ++++-- .../UtilizationBar/UtilizationBar.module.scss | 17 + .../UtilizationBar/UtilizationBar.tsx | 187 ++++++-- solend-lite/src/stores/obligations.ts | 12 +- solend-lite/src/stores/pools.ts | 32 ++ solend-lite/src/utils/numberFormatter.tsx | 43 +- solend-lite/src/utils/utils.ts | 4 +- solend-sdk/src/core/utils/obligations.ts | 51 +- solend-sdk/src/core/utils/pools.ts | 24 +- solend-sdk/src/core/utils/prices.ts | 24 +- solend-sdk/src/core/utils/utils.ts | 77 +++ solend-sdk/src/state/rateLimiter.ts | 13 + solend-sdk/src/state/reserve.ts | 2 +- 27 files changed, 1357 insertions(+), 211 deletions(-) create mode 100644 solend-lite/src/components/AccountMetrics/AccountMetrics.module.scss create mode 100644 solend-lite/src/components/Breakdown/Breakdown.module.scss create mode 100644 solend-lite/src/components/Breakdown/Breakdown.tsx diff --git a/solend-lite/package.json b/solend-lite/package.json index b10efff9..406b52cf 100644 --- a/solend-lite/package.json +++ b/solend-lite/package.json @@ -38,6 +38,7 @@ "eslint": "8.29.0", "eslint-config-next": "13.0.7", "framer-motion": "^8.4.3", + "humanize-duration": "^3.29.0", "jotai": "^1.12.1", "jotai-optics": "^0.2.0", "next": "13.0.7", @@ -53,6 +54,7 @@ }, "devDependencies": { "@next/bundle-analyzer": "^13.2.1", + "@types/humanize-duration": "^3.27.1", "@typescript-eslint/eslint-plugin": "^5.51.0", "eslint-config-airbnb-typescript": "^17.0.0", "eslint-config-prettier": "^8.6.0", diff --git a/solend-lite/src/components/AccountMetrics/AccountMetrics.module.scss b/solend-lite/src/components/AccountMetrics/AccountMetrics.module.scss new file mode 100644 index 00000000..2667cade --- /dev/null +++ b/solend-lite/src/components/AccountMetrics/AccountMetrics.module.scss @@ -0,0 +1,3 @@ +.collapseButton { + cursor: pointer; +} diff --git a/solend-lite/src/components/AccountMetrics/AccountMetrics.tsx b/solend-lite/src/components/AccountMetrics/AccountMetrics.tsx index 1e928192..82275e8f 100644 --- a/solend-lite/src/components/AccountMetrics/AccountMetrics.tsx +++ b/solend-lite/src/components/AccountMetrics/AccountMetrics.tsx @@ -1,17 +1,26 @@ -import React, { ReactElement } from 'react'; +import React, { ReactElement, useState } from 'react'; import { formatPercent, formatUsd } from 'utils/numberFormatter'; import Metric from 'components/Metric/Metric'; import { useAtom } from 'jotai'; -import { Box, Flex, Spacer } from '@chakra-ui/react'; +import { Box, Divider, Flex, Text } from '@chakra-ui/react'; import UtilizationBar from 'components/UtilizationBar/UtilizationBar'; import { selectedObligationAtom } from 'stores/obligations'; +import styles from './AccountMetrics.module.scss'; +import Breakdown from 'components/Breakdown/Breakdown'; + function AccountMetrics(): ReactElement { const [obligation] = useAtom(selectedObligationAtom); + const [showBreakdown, setShowBreakdown] = useState(false); + const [showBorrowLimitTooltip, setShowBorrowLimitTooltip] = useState(false); + const [showWeightedBorrowTooltip, setShowWeightedBorrowTooltip] = + useState(false); + const [showLiquidationThresholdTooltip, setShowLiquidationThresholdTooltip] = + useState(false); return ( - + - - You have reached your borrow limit and approaching the - liquidation threshold of{' '} - {formatPercent( - obligation.liquidationThresholdFactor.toString(), - )} - . To avoid liquidation, you can repay your positions or supply - more assets - - ) : undefined - } + tooltip='Supply balance is the sum of all assets supplied. Increasing this value increases your borrow limit and liquidation threshold.' /> - - - + + + + Borrow limit is the maximum value you can borrow marked by the + white bar. To increase this limit, you can supply more assets. +
+
+ Each asset supplied increases your borrow limit by a percentage of + its value. +
+
+ (Currently{' '} + {obligation + ? formatPercent(obligation.borrowLimitFactor.toString()) + : '-'}{' '} + of supply balance). + + } />
- - - Borrow limit is the maximum value you can borrow marked by the white - bar. To increase this limit, you can supply more assets. -
-
- Each asset supplied increases your borrow limit by a percentage of - its value. -
-
- (Currently{' '} - {obligation - ? formatPercent(obligation.borrowLimitFactor.toString()) - : '-'}{' '} - of supply balance). - - } + setShowBreakdown(!showBreakdown)} + showBorrowLimitTooltip={showBorrowLimitTooltip} + showWeightedBorrowTooltip={showWeightedBorrowTooltip} + showLiquidationThresholdTooltip={showLiquidationThresholdTooltip} + showBreakdown={showBreakdown} /> - - Liquidation threshold is the limit where your collateral will be - eligible for liquidation. This is marked by the red bar. Lower your - borrow utilization to minimize this risk. -
-
- Each asset supplied increases your borrow limit by a percentage of - its value. -
-
- (Currently{' '} - {obligation - ? formatPercent(obligation.liquidationThresholdFactor.toString()) - : '-'}{' '} - of supply balance) - - } + + + Liquidation threshold is the limit where your collateral will be + eligible for liquidation. This is marked by the red bar. Lower + your borrow utilization to minimize this risk. +
+
+ Each asset supplied increases your borrow limit by a percentage of + its value. +
+
+ (Currently{' '} + {obligation + ? formatPercent( + obligation.liquidationThresholdFactor.toString(), + ) + : '-'}{' '} + of supply balance) + + } + /> +
+ + {obligation?.positions === 0 ? null : ( + setShowBreakdown(!showBreakdown)} + onClick={() => setShowBreakdown(!showBreakdown)} + > + + + + {showBreakdown ? 'Hide' : 'Show'} breakdown + + + + )} +
); diff --git a/solend-lite/src/components/Breakdown/Breakdown.module.scss b/solend-lite/src/components/Breakdown/Breakdown.module.scss new file mode 100644 index 00000000..08863dcd --- /dev/null +++ b/solend-lite/src/components/Breakdown/Breakdown.module.scss @@ -0,0 +1,27 @@ +.tooltipTitle { + cursor: pointer !important; + text-align: center !important; +} + +.params { + background-color: var(--chakra-colors-neutralAlt); + padding: 12px; + margin-left: -16px; + margin-right: -16px; + margin-top: 8px; +} + +.visible { + transition: max-height 0.75s !important; + max-height: 500px; + visibility: visible; +} + +.hidden { + transition: max-height 0.25s !important; + visibility: hidden; + max-height: 0; + padding: 0; + margin-top: 0; + margin-bottom: 0; +} diff --git a/solend-lite/src/components/Breakdown/Breakdown.tsx b/solend-lite/src/components/Breakdown/Breakdown.tsx new file mode 100644 index 00000000..54e37458 --- /dev/null +++ b/solend-lite/src/components/Breakdown/Breakdown.tsx @@ -0,0 +1,447 @@ +import React, { ReactElement } from 'react'; +import { + collapsableUsd, + collapsableToken, + formatToken, +} from 'utils/numberFormatter'; +import classNames from 'classnames'; +import styles from './Breakdown.module.scss'; +import { Box, Flex, Text } from '@chakra-ui/react'; +import { useAtom } from 'jotai'; +import { selectedObligationAtom } from 'stores/obligations'; +import { selectedPoolAtom } from 'stores/pools'; +import { U64_MAX } from '@solendprotocol/solend-sdk'; +import BigNumber from 'bignumber.js'; +import { InfoOutlineIcon } from '@chakra-ui/icons'; + +const LONG_COL_SPAN = 70; +const SHORT_COL_SPAN = 50; + +function BreakdownHeader({ weightLabel }: { weightLabel: string }) { + return ( + + + + Position + + + + + x + + + + + Price + + + + + x + + + + + {weightLabel} + + + + + = + + + + + Total + + + + ); +} + +function Breakdown({ + visible, + setShowBorrowLimitTooltip, + setShowWeightedBorrowTooltip, + setShowLiquidationThresholdTooltip, +}: { + visible: boolean; + setShowBorrowLimitTooltip: (arg: boolean) => void; + setShowWeightedBorrowTooltip: (arg: boolean) => void; + setShowLiquidationThresholdTooltip: (arg: boolean) => void; +}): ReactElement { + const [selectedObligation] = useAtom(selectedObligationAtom); + const [pool] = useAtom(selectedPoolAtom); + + const supplyData = + selectedObligation?.deposits.filter((d) => !d.amount.eq(0)) ?? []; + const borrowData = + selectedObligation?.borrows.filter((b) => !b.amount.eq(0)) ?? []; + + return ( + + + setShowWeightedBorrowTooltip(true)} + onMouseLeave={() => setShowWeightedBorrowTooltip(false)} + > + + ■ + {' '} + + Weighted borrow + + + + + {Boolean(borrowData.length) && + borrowData.map((d) => { + const reserve = pool?.reserves.find( + (r) => r.address === d.reserveAddress, + ); + + return ( + + + + {collapsableToken(d.amount.toString(), 2, 6)} {d.symbol} + + + {collapsableUsd(d.price.toString(), 12)} + + + {reserve?.addedBorrowWeightBPS.toString() !== U64_MAX && + reserve?.borrowWeight + ? formatToken( + reserve?.borrowWeight?.toString(), + 2, + false, + true, + ) + : '∞'} + + + {new BigNumber(d.weightedAmountUsd).isGreaterThanOrEqualTo( + new BigNumber('1000000000'), + ) + ? '∞' + : collapsableUsd(d.weightedAmountUsd.toString(), 10)} + + + + ); + })} + + + + Total weighted borrow: + + + + + {collapsableUsd( + selectedObligation?.weightedTotalBorrowValue?.toString() ?? '0', + 10, + )} + + + + setShowBorrowLimitTooltip(true)} + onMouseLeave={() => setShowBorrowLimitTooltip(false)} + > + + ■ + {' '} + + Borrow limit + + + + {Boolean(supplyData.length) && + supplyData.map((d) => ( + + + + {collapsableToken(d.amount.toString(), 2, 6)} {d.symbol} + + + {collapsableUsd(d.price.toString(), 12)} + + + {formatToken(d.loanToValueRatio, 2, false, true)} + + + + {collapsableUsd( + new BigNumber(d.amountUsd) + .times(new BigNumber(d.loanToValueRatio)) + .toString(), + 10, + )} + + + + + ))} + + + + Total borrow limit: + + + + + {selectedObligation?.weightedTotalBorrowValue?.isGreaterThanOrEqualTo( + new BigNumber('1000000000'), + ) + ? '∞' + : collapsableUsd( + selectedObligation?.borrowLimit?.toString() ?? '0', + 10, + )} + + + + setShowLiquidationThresholdTooltip(true)} + onMouseLeave={() => setShowLiquidationThresholdTooltip(false)} + > + + ■ + {' '} + + Liquidation threshold + + + + {Boolean(supplyData.length) && + supplyData.map((d) => ( + + + + {collapsableToken(d.amount.toString(), 2, 6)} {d.symbol} + + + {collapsableUsd(d.price.toString(), 12)} + + + {formatToken(d.liquidationThreshold, 2, false, true)} + + + + {collapsableUsd( + new BigNumber(d.amountUsd) + .times(new BigNumber(d.liquidationThreshold)) + .toString(), + 10, + )} + + + + + ))} + + + + Total liquidation threshold: + + + + + {collapsableUsd( + selectedObligation?.liquidationThreshold?.toString() ?? '0', + 10, + )} + + + + + ); +} + +export default Breakdown; diff --git a/solend-lite/src/components/Metric/Metric.module.scss b/solend-lite/src/components/Metric/Metric.module.scss index cc560ef6..a6c2f45f 100644 --- a/solend-lite/src/components/Metric/Metric.module.scss +++ b/solend-lite/src/components/Metric/Metric.module.scss @@ -1,16 +1,13 @@ .alignCenter { - .label { - display: block; - margin-bottom: 4px; - } + display: flex; text-align: center; + gap: 4px; } .metric { .label { - display: flex; - margin-bottom: 4px; - justify-content: center; + align-items: center; + gap: 4px; } &:last-of-type { @@ -23,6 +20,7 @@ &:first-of-type { text-align: start; .label { + flex-direction: row; justify-content: start; } } diff --git a/solend-lite/src/components/Metric/Metric.tsx b/solend-lite/src/components/Metric/Metric.tsx index 362805c3..22dbd538 100644 --- a/solend-lite/src/components/Metric/Metric.tsx +++ b/solend-lite/src/components/Metric/Metric.tsx @@ -13,6 +13,8 @@ type MetricPropType = { dangerTooltip?: React.ReactNode; alignCenter?: boolean; row?: boolean; + flex?: number; + style?: any; }; function Metric({ @@ -23,35 +25,37 @@ function Metric({ dangerTooltip, row, alignCenter, + flex, + style, }: MetricPropType): ReactElement { return ( {label && ( - + {label} {tooltip && ( - <> - - - {dangerTooltip ? ( - - ) : ( - - )} - - - + + + {dangerTooltip ? ( + + ) : ( + + )} + + )} )} - {value} + {value} {secondary && ( <> diff --git a/solend-lite/src/components/Pool/Pool.tsx b/solend-lite/src/components/Pool/Pool.tsx index beb5cdd5..0e2f208e 100644 --- a/solend-lite/src/components/Pool/Pool.tsx +++ b/solend-lite/src/components/Pool/Pool.tsx @@ -1,14 +1,21 @@ import { Text, Flex, Box, Divider, useMediaQuery } from '@chakra-ui/react'; import { useAtom } from 'jotai'; import { useState } from 'react'; -import { selectedPoolAtom, selectedPoolStateAtom } from 'stores/pools'; -import { formatCompact } from 'utils/numberFormatter'; +import { + rateLimiterAtom, + selectedPoolAtom, + selectedPoolStateAtom, +} from 'stores/pools'; +import { formatCompact, formatUsd } from 'utils/numberFormatter'; import Metric from 'components/Metric/Metric'; import Loading from 'components/Loading/Loading'; import BigNumber from 'bignumber.js'; +import humanizeDuration from 'humanize-duration'; import { ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons'; import PoolTable from './PoolTable/PoolTable'; import PoolList from './PoolList/PoolList'; +import { SLOT_RATE } from 'utils/utils'; +import { OUTFLOW_BUFFER } from '@solendprotocol/solend-sdk'; export const ASSET_SUPPLY_LIMIT_TOOLTIP = 'Asset deposit limit reached.'; export const ASSET_BORROW_LIMIT_TOOLTIP = 'Asset borrow limit reached.'; @@ -19,6 +26,7 @@ export default function Pool({ selectReserveWithModal: (reserve: string) => void; }) { const [selectedPool] = useAtom(selectedPoolAtom); + const [rateLimiter] = useAtom(rateLimiterAtom); const [selectedPoolState] = useAtom(selectedPoolStateAtom); const [isLargerThan800] = useMediaQuery('(min-width: 800px)'); @@ -73,6 +81,51 @@ export default function Pool({ alignCenter value={`$${formatCompact(totalAvailableUsd)}`} /> + {rateLimiter && + !rateLimiter.config.windowDuration.isEqualTo(new BigNumber(0)) && ( + + {formatUsd( + rateLimiter.config.maxOutflow.toString(), + false, + true, + )}{' '} + per{' '} + {humanizeDuration( + (Number(rateLimiter.config.windowDuration.toString()) / + SLOT_RATE) * + 1000, + )} + + } + tooltip={ + <> + For the safety of the pool, amounts being withdrawn or + borrowed from the pool are limited by this rate.
+ Remaining outflow this window:{' '} + {rateLimiter.remainingOutflow + ? formatUsd( + rateLimiter.remainingOutflow + .dividedBy(new BigNumber(OUTFLOW_BUFFER)) + .toString(), + false, + true, + ) + : 'N/A'} + + } + /> + )}
diff --git a/solend-lite/src/components/Pool/PoolList/PoolList.tsx b/solend-lite/src/components/Pool/PoolList/PoolList.tsx index dbccaebe..b244ce06 100644 --- a/solend-lite/src/components/Pool/PoolList/PoolList.tsx +++ b/solend-lite/src/components/Pool/PoolList/PoolList.tsx @@ -12,6 +12,7 @@ import { ASSET_BORROW_LIMIT_TOOLTIP, ASSET_SUPPLY_LIMIT_TOOLTIP, } from '../Pool'; +import { U64_MAX } from '@solendprotocol/solend-sdk'; export default function PoolList({ selectReserveWithModal, @@ -66,8 +67,27 @@ export default function PoolList({
+ + {formatPercent(reserve.loanToValueRatio, false, 0)} + + + / + + + {reserve.addedBorrowWeightBPS.toString() !== U64_MAX + ? formatToken( + reserve.borrowWeight.toString(), + 2, + false, + true, + ) + : '∞'} + + + } row /> ( - {formatPercent(reserve.loanToValueRatio, false, 0)} + + {formatPercent(reserve.loanToValueRatio, false, 0)} + + / + + + {reserve.addedBorrowWeightBPS.toString() !== U64_MAX + ? formatToken(reserve.borrowWeight.toString(), 2, false, true) + : '∞'} + + ), }), columnHelper.accessor('totalSupplyUsd', { diff --git a/solend-lite/src/components/RefreshDataButton/RefreshDataButton.tsx b/solend-lite/src/components/RefreshDataButton/RefreshDataButton.tsx index 77de67d6..b985f1a8 100644 --- a/solend-lite/src/components/RefreshDataButton/RefreshDataButton.tsx +++ b/solend-lite/src/components/RefreshDataButton/RefreshDataButton.tsx @@ -2,7 +2,7 @@ import React, { ReactElement, useCallback, useEffect, useState } from 'react'; import { Box, Tooltip, useMediaQuery } from '@chakra-ui/react'; import { useTimer } from 'react-timer-hook'; import classNames from 'classnames'; -import { loadPoolsAtom, unqiueAssetsAtom } from 'stores/pools'; +import { currentSlotAtom, loadPoolsAtom, unqiueAssetsAtom } from 'stores/pools'; import { useAtom, useSetAtom } from 'jotai'; import { selectedObligationAddressAtom, @@ -29,6 +29,7 @@ const getNewExpiryTimestamp = (): Date => { function RefreshDataButton(): ReactElement { const [on, setOn] = useState(false); + const [_currentSlot, refreshCurrentSlot] = useAtom(currentSlotAtom); const loadPools = useSetAtom(loadPoolsAtom); const [switchboardProgram] = useAtom(switchboardAtom); const loadObligation = useSetAtom(selectedObligationAtom); @@ -52,6 +53,8 @@ function RefreshDataButton(): ReactElement { if (publicKey) { reloadPromises.push(await refreshWallet()); } + // refreshes rateLimiter + await refreshCurrentSlot(); await Promise.all(reloadPromises); } finally { restart(getNewExpiryTimestamp()); diff --git a/solend-lite/src/components/TransactionTakeover/BigInput/BigInput.tsx b/solend-lite/src/components/TransactionTakeover/BigInput/BigInput.tsx index 348f395b..bfb55836 100644 --- a/solend-lite/src/components/TransactionTakeover/BigInput/BigInput.tsx +++ b/solend-lite/src/components/TransactionTakeover/BigInput/BigInput.tsx @@ -71,7 +71,7 @@ const BigInput = forwardRef( parsedAmount, }; }, - [useUsd], + [useUsd, selectedToken.decimals], ); useEffect(() => { diff --git a/solend-lite/src/components/TransactionTakeover/ReserveStats/ReserveStats.tsx b/solend-lite/src/components/TransactionTakeover/ReserveStats/ReserveStats.tsx index 6d5bb1af..3e6d88d1 100644 --- a/solend-lite/src/components/TransactionTakeover/ReserveStats/ReserveStats.tsx +++ b/solend-lite/src/components/TransactionTakeover/ReserveStats/ReserveStats.tsx @@ -2,12 +2,15 @@ import React, { ReactElement, useState } from 'react'; import { Box, Divider, Flex, Text, Tooltip } from '@chakra-ui/react'; import Metric from 'components/Metric/Metric'; import { formatPercent, formatToken, formatUsd } from 'utils/numberFormatter'; -import { SelectedReserveType } from 'stores/pools'; +import { SelectedReserveType, rateLimiterAtom } from 'stores/pools'; import BigNumber from 'bignumber.js'; import styles from './ReserveStats.module.scss'; import classNames from 'classnames'; import { ChevronDownIcon, ChevronUpIcon, CopyIcon } from '@chakra-ui/icons'; import { computeExtremeRates } from '@solendprotocol/solend-sdk'; +import { useAtom } from 'jotai'; +import humanizeDuration from 'humanize-duration'; +import { SLOT_RATE } from 'utils/utils'; // certain oracles do not match their underlying asset, hence this mapping const PYTH_ORACLE_MAPPING: Record = { @@ -44,6 +47,7 @@ function ReserveStats({ action, calculatedBorrowFee, }: ReserveStatsPropsType): ReactElement { + const [rateLimiter] = useAtom(rateLimiterAtom); const [showParams, setShowParams] = useState(false); let newBorrowLimitDisplay = null; if (newBorrowLimit) { @@ -77,6 +81,22 @@ function ReserveStats({ return ( + {['borrow', 'repay'].includes(action) && ( + + )} + {['supply', 'withdraw'].includes(action) && ( + + )} {formatPercent(reserve.protocolLiquidationFee)}} /> @@ -279,6 +299,60 @@ function ReserveStats({ } /> + + + + + {reserve.liquidityAddress.slice(0, 4)} + ... + {reserve.liquidityAddress.slice(-4)} + + {' '} + { + navigator.clipboard.writeText(reserve.liquidityAddress); + }} + /> + + + } + /> + + + + + {reserve.cTokenLiquidityAddress.slice(0, 4)} + ... + {reserve.cTokenLiquidityAddress.slice(-4)} + + {' '} + { + navigator.clipboard.writeText( + reserve.cTokenLiquidityAddress, + ); + }} + /> + + + } + /> {reserve.feeReceiverAddress && ( @@ -413,6 +487,51 @@ function ReserveStats({ } /> + {rateLimiter && ( + + {formatToken( + new BigNumber( + rateLimiter.config.maxOutflow.toString(), + reserve.decimals, + ).toString(), + )}{' '} + {reserve.symbol} per{' '} + {humanizeDuration( + (rateLimiter.config.windowDuration.toNumber() / SLOT_RATE) * + 1000, + )} + + ) + } + tooltip={ + <> + For the safety of the pool, amounts being withdrawn or borrowed + from the pool are limited by this rate.
+ Remaining outflow this window:{' '} + {formatUsd( + rateLimiter.remainingOutflow?.toString() ?? '0', + false, + true, + )} + + } + /> + )} + {!new BigNumber(reserve.borrowWeight).isEqualTo(new BigNumber(0)) && ( + + )}
); diff --git a/solend-lite/src/components/TransactionTakeover/TransactionTakeover.tsx b/solend-lite/src/components/TransactionTakeover/TransactionTakeover.tsx index 02af8628..77bdadb0 100644 --- a/solend-lite/src/components/TransactionTakeover/TransactionTakeover.tsx +++ b/solend-lite/src/components/TransactionTakeover/TransactionTakeover.tsx @@ -27,7 +27,7 @@ import { publicKeyAtom, walletAssetsAtom } from 'stores/wallet'; import Result, { ResultConfigType } from 'components/Result/Result'; import BigNumber from 'bignumber.js'; import { connectionAtom, refreshPageAtom } from 'stores/settings'; -import { selectedPoolAtom } from 'stores/pools'; +import { rateLimiterAtom, selectedPoolAtom } from 'stores/pools'; import { useWallet } from '@solana/wallet-adapter-react'; import { U64_MAX } from '@solendprotocol/solend-sdk'; import { SKIP_PREFLIGHT } from 'common/config'; @@ -37,6 +37,7 @@ export default function TransactionTakeover() { const { sendTransaction } = useWallet(); const [publicKey] = useAtom(publicKeyAtom); const [connection] = useAtom(connectionAtom); + const [rateLimiter] = useAtom(rateLimiterAtom); const [selectedObligation] = useAtom(selectedObligationAtom); const [walletAssets] = useAtom(walletAssetsAtom); const refresh = useSetAtom(refreshPageAtom); @@ -61,6 +62,7 @@ export default function TransactionTakeover() { selectedReserve, walletAssets, selectedObligation, + rateLimiter ) : BigNumber(0), [selectedReserve, walletAssets, selectedObligation], @@ -72,6 +74,7 @@ export default function TransactionTakeover() { selectedReserve, walletAssets, selectedObligation, + rateLimiter ) : BigNumber(0), [selectedReserve, walletAssets, selectedObligation], @@ -128,7 +131,6 @@ export default function TransactionTakeover() { Repay - @@ -243,6 +246,7 @@ export default function TransactionTakeover() { selectedObligation, selectedReserve, walletAssets, + rateLimiter )} getNewCalculations={withdrawConfigs.getNewCalculations} /> diff --git a/solend-lite/src/components/TransactionTakeover/configs.tsx b/solend-lite/src/components/TransactionTakeover/configs.tsx index b8f213ae..cd14a1e7 100644 --- a/solend-lite/src/components/TransactionTakeover/configs.tsx +++ b/solend-lite/src/components/TransactionTakeover/configs.tsx @@ -16,6 +16,7 @@ import { } from '@solendprotocol/solend-sdk'; import { getAssociatedTokenAddress, NATIVE_MINT } from '@solana/spl-token'; import { ENVIRONMENT, HOST_ATA } from 'common/config'; +import { ParsedRateLimiter } from '@solendprotocol/solend-sdk/src/state/rateLimiter'; const SOL_PADDING_FOR_RENT_AND_FEE = 0.02; @@ -112,20 +113,21 @@ export const supplyConfigs = { } const valueObj = new BigNumber(value); + const newBorrowLimit = !valueObj.isNaN() - ? obligation.borrowLimit.plus( - valueObj.times(reserve.price).times(reserve.loanToValueRatio), + ? obligation.minPriceBorrowLimit.plus( + valueObj.times(reserve.minPrice).times(reserve.loanToValueRatio), ) : null; const newBorrowUtilization = newBorrowLimit && !newBorrowLimit.isZero() - ? obligation.totalBorrowValue.dividedBy(newBorrowLimit) + ? obligation.weightedTotalBorrowValue.dividedBy(newBorrowLimit) : null; return { - borrowLimit: obligation.borrowLimit, + borrowLimit: obligation.minPriceBorrowLimit, newBorrowLimit, - utilization: obligation.borrowUtilization, + utilization: obligation.weightedConservativeBorrowUtilization, newBorrowUtilization, calculatedBorrowFee: null, }; @@ -204,6 +206,7 @@ export const borrowConfigs = { obligation: ObligationType | null, reserve: SelectedReserveType, wallet: WalletType, + rateLimiter: ParsedRateLimiter | null, ) => { if (!obligation) return null; @@ -214,9 +217,10 @@ export const borrowConfigs = { BigNumber(0), ); - const overBorrowLimit = obligation.borrowLimit - .minus(obligation.totalBorrowValue) - .dividedBy(reserve.price); + const overBorrowLimit = obligation.minPriceBorrowLimit + .minus(obligation.maxPriceUserTotalWeightedBorrow) + .dividedBy(reserve.maxPrice) + .dividedBy(reserve.borrowWeight); // Allow action despite position limit, provided user already has a position in this asset const positionLimitReached = @@ -225,6 +229,15 @@ export const borrowConfigs = { .map((d) => d.reserveAddress) .includes(reserve.address); + const reserveRateLimit = + reserve.rateLimiter.remainingOutflow?.dividedBy( + new BigNumber(10 ** reserve.decimals), + ) ?? new BigNumber(U64_MAX); + + const poolRateLimit = + rateLimiter?.remainingOutflow?.dividedBy(reserve.maxPrice) ?? + new BigNumber(U64_MAX); + if (positionLimitReached) { return 'Max number of positions reached'; } @@ -232,7 +245,7 @@ export const borrowConfigs = { return 'Insufficient liquidity to borrow'; } if ( - obligation?.totalBorrowValue + obligation?.maxPriceUserTotalWeightedBorrow .plus(value) .isGreaterThanOrEqualTo(reserve.reserveBorrowCap) ) { @@ -244,6 +257,12 @@ export const borrowConfigs = { if (value.isGreaterThan(overBorrowLimit)) { return 'Exceeds borrow limit'; } + if (value.isGreaterThan(poolRateLimit)) { + return 'Pool outflow rate limit surpassed'; + } + if (value.isGreaterThan(reserveRateLimit)) { + return 'Reserve outflow rate limit surpassed'; + } if (!sufficientSOLForTransaction(wallet)) { return 'Min 0.02 SOL required for transaction and fees'; } @@ -270,16 +289,16 @@ export const borrowConfigs = { const valueObj = new BigNumber(value); const newBorrowUtilization = - !valueObj.isNaN() && !obligation.borrowLimit.isZero() - ? obligation.totalBorrowValue - .plus(valueObj.times(reserve.price)) - .dividedBy(obligation.borrowLimit) + !valueObj.isNaN() && !obligation.minPriceBorrowLimit.isZero() + ? obligation.maxPriceUserTotalWeightedBorrow + .plus(valueObj.times(reserve.maxPrice).times(reserve.borrowWeight)) + .dividedBy(obligation.minPriceBorrowLimit) : null; return { - borrowLimit: obligation.borrowLimit, + borrowLimit: obligation.minPriceBorrowLimit, newBorrowLimit: null, - utilization: obligation.borrowUtilization, + utilization: obligation.weightedConservativeBorrowUtilization, newBorrowUtilization, calculatedBorrowFee: valueObj.isNaN() ? null @@ -290,6 +309,7 @@ export const borrowConfigs = { reserve: SelectedReserveType, _wallet: WalletType, obligation: ObligationType | null, + rateLimiter: ParsedRateLimiter | null, ) => { if (!obligation) { return new BigNumber(0); @@ -303,13 +323,25 @@ export const borrowConfigs = { reserve.availableAmount, ).minus(new BigNumber(reserve.totalSupply).times(new BigNumber(0.05))); + const reserveRateLimit = + reserve.rateLimiter.remainingOutflow?.dividedBy( + new BigNumber(10 ** reserve.decimals), + ) ?? new BigNumber(U64_MAX); + + const poolRateLimit = + rateLimiter?.remainingOutflow?.dividedBy(reserve.maxPrice) ?? + new BigNumber(U64_MAX); + return BigNumber.max( BigNumber.min( - obligation.borrowLimit - .minus(obligation.totalBorrowValue) - .dividedBy(reserve.price), + obligation.minPriceBorrowLimit + .minus(obligation.maxPriceUserTotalWeightedBorrow) + .dividedBy(reserve.maxPrice) + .dividedBy(reserve.borrowWeight), borrowableAmountUntil95utilization, borrowCapRemaining, + poolRateLimit, + reserveRateLimit, ), BigNumber(0), ).decimalPlaces(reserve.decimals); @@ -352,6 +384,7 @@ export const withdrawConfigs = { obligation: ObligationType | null, reserve: SelectedReserveType, wallet: WalletType, + rateLimiter: ParsedRateLimiter | null, ) => { if (!obligation) return null; @@ -360,9 +393,9 @@ export const withdrawConfigs = { )?.amount; if (!reserveDepositedAmount) return null; - const constantBorrowLimit = obligation.borrowLimit.minus( + const constantBorrowLimit = obligation.minPriceBorrowLimit.minus( reserveDepositedAmount - .times(reserve.price) + .times(reserve.minPrice) .times(reserve.loanToValueRatio), ); @@ -375,13 +408,22 @@ export const withdrawConfigs = { BigNumber(0), obligation?.totalBorrowValue .minus(constantBorrowLimit) - .dividedBy(reserve.price.times(reserve.loanToValueRatio)), + .dividedBy(reserve.minPrice.times(reserve.loanToValueRatio)), ), ), 0, ) : new BigNumber(U64_MAX); + const reserveRateLimit = + reserve.rateLimiter.remainingOutflow?.dividedBy( + new BigNumber(10 ** reserve.decimals), + ) ?? new BigNumber(U64_MAX); + + const poolRateLimit = + rateLimiter?.remainingOutflow?.dividedBy(reserve.maxPrice) ?? + new BigNumber(U64_MAX); + if (value.isGreaterThan(reserve.availableAmount)) { return 'Insufficient liquidity to withdraw'; } @@ -394,6 +436,12 @@ export const withdrawConfigs = { if (!sufficientSOLForTransaction(wallet)) { return 'Min 0.02 SOL required for transaction and fees'; } + if (value.isGreaterThan(poolRateLimit)) { + return 'Pool outflow rate limit surpassed'; + } + if (value.isGreaterThan(reserveRateLimit)) { + return 'Reserve outflow rate limit surpassed'; + } return null; }, getNewCalculations: ( @@ -414,8 +462,8 @@ export const withdrawConfigs = { const valueObj = new BigNumber(value); const newBorrowLimit = !valueObj.isNaN() - ? obligation.borrowLimit.minus( - valueObj.times(reserve.price).times(reserve.loanToValueRatio), + ? obligation.minPriceBorrowLimit.minus( + valueObj.times(reserve.minPrice).times(reserve.loanToValueRatio), ) : null; @@ -427,7 +475,7 @@ export const withdrawConfigs = { return { borrowLimit: obligation.borrowLimit, newBorrowLimit: newBorrowLimit, - utilization: obligation.borrowUtilization, + utilization: obligation.weightedConservativeBorrowUtilization, newBorrowUtilization, calculatedBorrowFee: null, }; @@ -436,6 +484,7 @@ export const withdrawConfigs = { reserve: SelectedReserveType, _wallet: WalletType, obligation: ObligationType | null, + rateLimiter: ParsedRateLimiter | null, ) => { if (!obligation) { return new BigNumber(0); @@ -444,29 +493,44 @@ export const withdrawConfigs = { const reserveDepositedAmount = obligation.deposits.find( (d) => d.reserveAddress === reserve.address, )?.amount; + if (!reserveDepositedAmount) return BigNumber(0); - const constantBorrowLimit = obligation.borrowLimit.minus( + const constantBorrowLimit = obligation.minPriceBorrowLimit.minus( reserveDepositedAmount - .times(reserve.price) + .times(reserve.minPrice) .times(reserve.loanToValueRatio), ); const collateralWithdrawLimit = !( - reserve.price.isZero() || !reserve.loanToValueRatio + reserve.minPrice.isZero() || !reserve.loanToValueRatio ) ? reserveDepositedAmount.minus( BigNumber.max( BigNumber(0), obligation.totalBorrowValue .minus(constantBorrowLimit) - .dividedBy(reserve.price.times(reserve.loanToValueRatio)), + .dividedBy(reserve.minPrice.times(reserve.loanToValueRatio)), ), ) : new BigNumber(U64_MAX); + const reserveRateLimit = + reserve.rateLimiter.remainingOutflow?.dividedBy( + new BigNumber(10 ** reserve.decimals), + ) ?? new BigNumber(U64_MAX); + + const poolRateLimit = + rateLimiter?.remainingOutflow?.dividedBy(reserve.maxPrice) ?? + new BigNumber(U64_MAX); + return BigNumber.max( - BigNumber.min(collateralWithdrawLimit, reserve.availableAmount), + BigNumber.min( + collateralWithdrawLimit, + reserve.availableAmount, + reserveRateLimit, + poolRateLimit, + ), new BigNumber(0), ).decimalPlaces(reserve.decimals); }, @@ -549,16 +613,16 @@ export const repayConfigs = { const valueObj = new BigNumber(value); const newBorrowUtilization = - !valueObj.isNaN() && !obligation.borrowLimit.isZero() + !valueObj.isNaN() && !obligation.minPriceBorrowLimit.isZero() ? obligation.totalBorrowValue - .minus(valueObj.times(reserve.price)) - .dividedBy(obligation.borrowLimit) + .minus(valueObj.times(reserve.maxPrice).times(reserve.borrowWeight)) + .dividedBy(obligation.minPriceBorrowLimit) : null; return { - borrowLimit: obligation.borrowLimit, + borrowLimit: obligation.minPriceBorrowLimit, newBorrowLimit: null, - utilization: obligation.borrowUtilization, + utilization: obligation.weightedConservativeBorrowUtilization, newBorrowUtilization, calculatedBorrowFee: null, }; diff --git a/solend-lite/src/components/UtilizationBar/UtilizationBar.module.scss b/solend-lite/src/components/UtilizationBar/UtilizationBar.module.scss index 54b6203c..c57cf341 100644 --- a/solend-lite/src/components/UtilizationBar/UtilizationBar.module.scss +++ b/solend-lite/src/components/UtilizationBar/UtilizationBar.module.scss @@ -29,3 +29,20 @@ .overBorrowed { background-color: var(--chakra-colors-brand); } + +.open { + box-shadow: rgba(0, 0, 0, 0.35) 0px 5px 15px; + transform: scale(1.01, 1.25); +} + +.borrowed2 { + background-color: var(--brandAlt); + opacity: 0.5; +} + +.showBreakdownText { + visibility: hidden; + &:hover { + visibility: visible; + } +} diff --git a/solend-lite/src/components/UtilizationBar/UtilizationBar.tsx b/solend-lite/src/components/UtilizationBar/UtilizationBar.tsx index ea2da11c..e399c5be 100644 --- a/solend-lite/src/components/UtilizationBar/UtilizationBar.tsx +++ b/solend-lite/src/components/UtilizationBar/UtilizationBar.tsx @@ -1,4 +1,4 @@ -import React, { ReactElement } from 'react'; +import React, { ReactElement, useEffect, useState } from 'react'; import classNames from 'classnames'; import { Tooltip } from '@chakra-ui/react'; import { formatPercent, formatUsd } from 'utils/numberFormatter'; @@ -18,13 +18,33 @@ function Section({ width = 1.5, extraClassName, tooltip, + open, + stateOpen, }: { width?: number; extraClassName?: string; tooltip?: string; + open?: boolean; + stateOpen?: boolean; }) { + const [openState, setOpenState] = useState(open); + useEffect(() => { + if (openState === false) { + setOpenState(undefined); + } + }, [openState]); + + useEffect(() => { + setOpenState(open); + }, [open]); + return ( - +
void; + showBorrowLimitTooltip: boolean; + showWeightedBorrowTooltip: boolean; + showLiquidationThresholdTooltip: boolean; + showBreakdown: boolean; + stateOpen: boolean; +}): ReactElement { const [obligation] = useAtom(selectedObligationAtom); - if (!obligation) return
; + const usedObligation = obligation ?? { + totalSupplyValue: new BigNumber(0), + totalBorrowValue: new BigNumber(0), + borrowLimit: new BigNumber(0), + liquidationThreshold: new BigNumber(0), + borrowOverSupply: new BigNumber(0), + borrowLimitOverSupply: new BigNumber(0), + liquidationThresholdFactor: new BigNumber(0), + weightedTotalBorrowValue: new BigNumber(0), + weightedBorrowUtilization: new BigNumber(0), + }; + + const borrowLimitOverSupply = usedObligation.totalSupplyValue.isZero() + ? new BigNumber(0) + : usedObligation.borrowLimit.dividedBy(usedObligation.totalSupplyValue); + + const weightedBorrowOverSupply = usedObligation.totalSupplyValue.isZero() + ? new BigNumber(0) + : usedObligation.weightedTotalBorrowValue.dividedBy( + usedObligation.totalSupplyValue, + ); const passedLimit = - obligation.totalSupplyValue.isZero() || - (!obligation.totalBorrowValue.isZero() && - obligation.totalBorrowValue.isGreaterThanOrEqualTo( - obligation.borrowLimit, + usedObligation.totalSupplyValue.isZero() || + (!usedObligation.weightedTotalBorrowValue.isZero() && + usedObligation.weightedTotalBorrowValue.isGreaterThanOrEqualTo( + usedObligation.borrowLimit, )); const passedThreshold = - obligation.totalSupplyValue.isZero() || - (!obligation.totalBorrowValue.isZero() && - obligation.totalBorrowValue.isGreaterThanOrEqualTo( - obligation.liquidationThreshold, + usedObligation.totalSupplyValue.isZero() || + (!usedObligation.weightedTotalBorrowValue.isZero() && + usedObligation.weightedTotalBorrowValue.isGreaterThanOrEqualTo( + usedObligation.liquidationThreshold, )); - // 2% reserved for the bars - const denominator = 97 + (passedLimit ? 1 : 0) + (passedThreshold ? 1 : 0); + // 3% reserved for the bars + const denominator = + 97 + (passedLimit ? 1.5 : 0) + (passedThreshold ? 1.5 : 0); const borrowWidth = Math.min( 100, - Number(Number(obligation.borrowOverSupply.toString()).toFixed(4)) * + Number(Number(usedObligation.borrowOverSupply.toString()).toFixed(4)) * denominator, ); + + const liquidationThresholdFactor = usedObligation.totalSupplyValue.isZero() + ? BigNumber(0) + : usedObligation.liquidationThreshold.dividedBy( + usedObligation.totalSupplyValue, + ); + + const weightedBorrowWidth = + Math.min( + 100, + Number(Number(weightedBorrowOverSupply.toString()).toFixed(4)) * + denominator, + ) - borrowWidth; + + const totalBorrowWidth = borrowWidth + weightedBorrowWidth; + const unborrowedWidth = Number( Number( - obligation.totalSupplyValue.isZero() + usedObligation.totalSupplyValue.isZero() ? BigNumber(0) : BigNumber.max( - obligation.borrowLimit.minus(obligation.totalBorrowValue), + usedObligation.borrowLimit.minus( + usedObligation.weightedTotalBorrowValue, + ), BigNumber(0), ) - .dividedBy(obligation.totalSupplyValue) + .dividedBy(usedObligation.totalSupplyValue) .toString(), ).toFixed(4), ) * denominator; const unliquidatedWidth = Number( Number( - obligation.totalSupplyValue.isZero() + usedObligation.totalSupplyValue.isZero() ? BigNumber(0) : BigNumber.max( - obligation.liquidationThreshold.minus( + usedObligation.liquidationThreshold.minus( BigNumber.max( - obligation.borrowLimit, - obligation.totalBorrowValue, + usedObligation.borrowLimit, + usedObligation.weightedTotalBorrowValue, ), ), BigNumber(0), ) - .dividedBy(obligation.totalSupplyValue) + .dividedBy(usedObligation.totalSupplyValue) .toString(), ).toFixed(4), ) * denominator; const unusedSupply = - denominator - borrowWidth - unborrowedWidth - unliquidatedWidth; + denominator - totalBorrowWidth - unborrowedWidth - unliquidatedWidth; - let borrowToolTip = `You are borrowing ${formatPercent( - obligation.borrowOverSupply.toString(), + let borrowToolTip = `Your weighted borrow balance is ${formatPercent( + weightedBorrowOverSupply.toString(), )} of your total supply, or ${formatPercent( - obligation.borrowUtilization.toString(), + usedObligation.weightedBorrowUtilization.toString(), )} of your borrow limit.`; + if (passedLimit) { borrowToolTip = - 'Your borrow balance is past the borrow limit and could be at risk of liquidation. Please repay your borrow balance or supply more assets.'; + 'Your weighted borrow balance is past the borrow limit and could be at risk of liquidation. Please repay your borrows or supply more assets.'; } + if (passedThreshold) { borrowToolTip = - 'Your borrow balance is past the liquidation threshold and could be liquidated.'; + 'Your weighted borrow balance is past the liquidation threshold and could be liquidated.'; } + const unweightedBorrowTooltip = `This portion represents the actual value of your borrows. However, certain assets have a borrow weight that changes their value during liquidation or borrow limit calculations.`; + return ( -
-
+