diff --git a/src/modals/borrow/borrow-modal.tsx b/src/modals/borrow/borrow-modal.tsx index 1b337ed4..871bff7d 100644 --- a/src/modals/borrow/borrow-modal.tsx +++ b/src/modals/borrow/borrow-modal.tsx @@ -132,6 +132,7 @@ export function BorrowModal({ isOpen onOpenChange={onOpenChange} size="lg" + className="!max-h-[92dvh]" > My Position

+ {priceLabel} + ({priceGapLabel}) + + ); +} + export function BorrowPositionRiskCard({ market, + oraclePrice, currentCollateral, currentBorrow, projectedCollateral, @@ -58,15 +82,138 @@ export function BorrowPositionRiskCard({ const projectedCollateralValue = projectedCollateral ?? currentCollateral; const projectedBorrowValue = projectedBorrow ?? currentBorrow; + const metricLabelClassName = 'mb-1 font-zen text-xs opacity-50'; + const metricValueClassName = 'font-zen text-sm'; const showProjectedCollateral = hasChanges && projectedCollateralValue !== currentCollateral; const showProjectedBorrow = hasChanges && projectedBorrowValue !== currentBorrow; + const showProjectedLtv = hasChanges && currentLtv !== projectedLtv; + const currentPriceDisplay = useMemo( + () => + formatMarketOraclePrice({ + oraclePrice, + collateralDecimals: market.collateralAsset.decimals, + loanDecimals: market.loanAsset.decimals, + }), + [oraclePrice, market.collateralAsset.decimals, market.loanAsset.decimals], + ); + const currentLiquidationOraclePrice = useMemo( + () => + currentLtv <= 0n || isInfiniteLtv(currentLtv) + ? null + : computeLiquidationOraclePrice({ + oraclePrice, + ltv: currentLtv, + lltv, + }), + [currentLtv, lltv, oraclePrice], + ); + const projectedLiquidationOraclePrice = useMemo( + () => + projectedLtv <= 0n || isInfiniteLtv(projectedLtv) + ? null + : computeLiquidationOraclePrice({ + oraclePrice, + ltv: projectedLtv, + lltv, + }), + [projectedLtv, lltv, oraclePrice], + ); + + const currentLiquidationPriceDisplay = useMemo(() => { + if (currentLtv <= 0n) return '-'; + if (isInfiniteLtv(currentLtv)) return '∞'; + if (currentLiquidationOraclePrice == null) return '-'; + + return formatMarketOraclePrice({ + oraclePrice: currentLiquidationOraclePrice, + collateralDecimals: market.collateralAsset.decimals, + loanDecimals: market.loanAsset.decimals, + }); + }, [currentLtv, currentLiquidationOraclePrice, market.collateralAsset.decimals, market.loanAsset.decimals]); + + const projectedLiquidationPriceDisplay = useMemo(() => { + if (projectedLtv <= 0n) return '-'; + if (isInfiniteLtv(projectedLtv)) return '∞'; + if (projectedLiquidationOraclePrice == null) return '-'; + + return formatMarketOraclePrice({ + oraclePrice: projectedLiquidationOraclePrice, + collateralDecimals: market.collateralAsset.decimals, + loanDecimals: market.loanAsset.decimals, + }); + }, [projectedLtv, projectedLiquidationOraclePrice, market.collateralAsset.decimals, market.loanAsset.decimals]); + + const showProjectedLiquidationPrice = hasChanges && currentLiquidationPriceDisplay !== projectedLiquidationPriceDisplay; + + const formatPriceLabel = (value: string): string => + value === '-' || value === '∞' ? value : `${value} ${market.loanAsset.symbol}`; + const formatPercentLabel = (value: number): string => `${value.toFixed(2).replace(/\.?0+$/u, '')}%`; + const currentLiquidationPriceChangePercent = useMemo( + () => + currentLiquidationOraclePrice == null + ? null + : computeOraclePriceChangePercent({ + currentOraclePrice: oraclePrice, + targetOraclePrice: currentLiquidationOraclePrice, + }), + [currentLiquidationOraclePrice, oraclePrice], + ); + const projectedLiquidationPriceChangePercent = useMemo( + () => + projectedLiquidationOraclePrice == null + ? null + : computeOraclePriceChangePercent({ + currentOraclePrice: oraclePrice, + targetOraclePrice: projectedLiquidationOraclePrice, + }), + [projectedLiquidationOraclePrice, oraclePrice], + ); + const formatPriceGapFromCurrent = (percentChange: number | null): string | null => { + if (percentChange == null || !Number.isFinite(percentChange)) return null; + if (percentChange > 0) return `-${formatPercentLabel(percentChange)}`; + if (percentChange < 0) return `+${formatPercentLabel(Math.abs(percentChange))}`; + return '0%'; + }; + const currentLiquidationPriceValue = renderLiquidationPriceValue( + formatPriceLabel(currentLiquidationPriceDisplay), + formatPriceGapFromCurrent(currentLiquidationPriceChangePercent), + ); + const projectedLiquidationPriceValue = renderLiquidationPriceValue( + formatPriceLabel(projectedLiquidationPriceDisplay), + formatPriceGapFromCurrent(projectedLiquidationPriceChangePercent), + ); + const liquidationPriceTooltipContent = + projectedLiquidationPriceDisplay === '-' + ? null + : ( + +
+ Current Price + {formatPriceLabel(currentPriceDisplay)} +
+
+ Liquidation Price + {formatPriceLabel(projectedLiquidationPriceDisplay)} +
+ + } + secondaryDetail={ + projectedLiquidationPriceChangePercent == null + ? undefined + : `Relative to current: ${formatPriceGapFromCurrent(projectedLiquidationPriceChangePercent)}` + } + /> + ); return (
-

Total Collateral

+

Total Collateral

-

+

{showProjectedCollateral ? ( <> @@ -93,7 +240,7 @@ export function BorrowPositionRiskCard({

-

Debt

+

Debt

-

+

{showProjectedBorrow ? ( <> @@ -135,30 +282,44 @@ export function BorrowPositionRiskCard({ )}

-
-
-

Loan to Value (LTV)

-
- {hasChanges ? ( - <> - {formatLtvPercent(currentLtv)}% - {formatLtvPercent(projectedLtv)}% - - ) : ( - {formatLtvPercent(projectedLtv)}% - )} +
+
+
+

Loan to Value (LTV)

+

+ {showProjectedLtv && {formatLtvPercent(currentLtv)}%} + + {formatLtvPercent(projectedLtv)}% + + / {formatLtvPercent(lltv)}% +

+
+
+
-
-
-
- -
-

Max LTV: {formatLtvPercent(lltv)}%

+
+
+

Liquidation Price

+ {liquidationPriceTooltipContent ? ( + +

+ {showProjectedLiquidationPrice && currentLiquidationPriceDisplay !== '-' && ( + {currentLiquidationPriceValue} + )} + + {projectedLiquidationPriceValue} + +

+
+ ) : ( +

{projectedLiquidationPriceValue}

+ )} +
diff --git a/src/modals/borrow/components/helpers.ts b/src/modals/borrow/components/helpers.ts index d0de202c..bcad1306 100644 --- a/src/modals/borrow/components/helpers.ts +++ b/src/modals/borrow/components/helpers.ts @@ -1,7 +1,12 @@ +import { formatUnits } from 'viem'; + export const LTV_WAD = 10n ** 18n; export const ORACLE_PRICE_SCALE = 10n ** 36n; export const INFINITE_LTV = 10n ** 30n; const TARGET_LTV_MARGIN_WAD = 10n ** 15n; // 0.1 percentage points +const ORACLE_PRICE_DISPLAY_DECIMALS = 36; +const DEFAULT_PRICE_MIN_FRACTION_DIGITS = 2; +const DEFAULT_PRICE_MAX_FRACTION_DIGITS = 6; const EDITABLE_PERCENT_REGEX = /^\d*\.?\d*$/; export const LTV_THRESHOLDS = { @@ -13,12 +18,70 @@ type LTVLevel = 'neutral' | 'safe' | 'warning' | 'danger'; const clampNonNegative = (value: bigint): bigint => (value > 0n ? value : 0n); const divCeil = (numerator: bigint, denominator: bigint): bigint => (denominator > 0n ? (numerator + denominator - 1n) / denominator : 0n); +const getScaleFactor = (decimals: number): bigint => 10n ** BigInt(Math.max(0, decimals)); +const trimTrailingZeros = (value: string): string => value.replace(/(\.\d*?[1-9])0+$/u, '$1').replace(/\.0*$/u, ''); export const getCollateralValueInLoan = (collateralAssets: bigint, oraclePrice: bigint): bigint => { if (collateralAssets <= 0n || oraclePrice <= 0n) return 0n; return (collateralAssets * oraclePrice) / ORACLE_PRICE_SCALE; }; +export const scaleMarketOraclePriceForDisplay = ({ + oraclePrice, + collateralDecimals, + loanDecimals, +}: { + oraclePrice: bigint; + collateralDecimals: number; + loanDecimals: number; +}): bigint => { + if (oraclePrice <= 0n) return 0n; + return (oraclePrice * getScaleFactor(collateralDecimals)) / getScaleFactor(loanDecimals); +}; + +export const formatMarketOraclePrice = ({ + oraclePrice, + collateralDecimals, + loanDecimals, + minimumFractionDigits = DEFAULT_PRICE_MIN_FRACTION_DIGITS, + maximumFractionDigits = DEFAULT_PRICE_MAX_FRACTION_DIGITS, +}: { + oraclePrice: bigint; + collateralDecimals: number; + loanDecimals: number; + minimumFractionDigits?: number; + maximumFractionDigits?: number; +}): string => { + const safeMinimumFractionDigits = Math.max(0, minimumFractionDigits); + const safeMaximumFractionDigits = Math.max(safeMinimumFractionDigits, maximumFractionDigits); + const normalizedPrice = scaleMarketOraclePriceForDisplay({ + oraclePrice, + collateralDecimals, + loanDecimals, + }); + const plainDecimalPrice = trimTrailingZeros(formatUnits(normalizedPrice, ORACLE_PRICE_DISPLAY_DECIMALS)); + const numericPrice = Number(plainDecimalPrice); + + if (Number.isFinite(numericPrice)) { + return new Intl.NumberFormat('en-US', { + minimumFractionDigits: safeMinimumFractionDigits, + maximumFractionDigits: safeMaximumFractionDigits, + }).format(numericPrice); + } + + if (!plainDecimalPrice.includes('.')) return plainDecimalPrice; + + const [integerPart, fractionPart = ''] = plainDecimalPrice.split('.'); + const paddedFraction = fractionPart.padEnd(safeMinimumFractionDigits, '0'); + const cappedFraction = paddedFraction.slice(0, safeMaximumFractionDigits); + const normalizedFraction = + cappedFraction.replace(/0+$/u, '').length >= safeMinimumFractionDigits + ? cappedFraction.replace(/0+$/u, '') + : paddedFraction.slice(0, safeMinimumFractionDigits); + + return normalizedFraction ? `${integerPart}.${normalizedFraction}` : integerPart; +}; + export const computeLtv = ({ borrowAssets, collateralAssets, @@ -69,6 +132,31 @@ export const isInfiniteLtv = (ltv: bigint): boolean => ltv >= INFINITE_LTV; export const formatLtvPercent = (ltv: bigint, fractionDigits = 2): string => isInfiniteLtv(ltv) ? '∞' : ltvWadToPercent(ltv).toFixed(fractionDigits); +export const computeLiquidationOraclePrice = ({ + oraclePrice, + ltv, + lltv, +}: { + oraclePrice: bigint; + ltv: bigint; + lltv: bigint; +}): bigint | null => { + if (oraclePrice <= 0n || ltv <= 0n || lltv <= 0n) return null; + return (oraclePrice * ltv) / lltv; +}; + +export const computeOraclePriceChangePercent = ({ + currentOraclePrice, + targetOraclePrice, +}: { + currentOraclePrice: bigint; + targetOraclePrice: bigint; +}): number | null => { + if (currentOraclePrice <= 0n || targetOraclePrice < 0n) return null; + const percentChangeBps = ((currentOraclePrice - targetOraclePrice) * 10_000n) / currentOraclePrice; + return Number(percentChangeBps) / 100; +}; + export const computeRequiredCollateralAssets = ({ borrowAssets, oraclePrice, diff --git a/src/modals/borrow/components/withdraw-collateral-and-repay.tsx b/src/modals/borrow/components/withdraw-collateral-and-repay.tsx index ca1518a8..24ef94c6 100644 --- a/src/modals/borrow/components/withdraw-collateral-and-repay.tsx +++ b/src/modals/borrow/components/withdraw-collateral-and-repay.tsx @@ -210,6 +210,7 @@ export function WithdrawCollateralAndRepay({

My Position

Leverage Preview

Deleverage Preview