diff --git a/src/features/positions/components/borrowed-morpho-blue-table.tsx b/src/features/positions/components/borrowed-morpho-blue-table.tsx index 568ec734..896b6e77 100644 --- a/src/features/positions/components/borrowed-morpho-blue-table.tsx +++ b/src/features/positions/components/borrowed-morpho-blue-table.tsx @@ -17,7 +17,7 @@ import { MarketIdentity, MarketIdentityFocus, MarketIdentityMode } from '@/featu import { useModal } from '@/hooks/useModal'; import { useRateLabel } from '@/hooks/useRateLabel'; import { usePositionsPreferences } from '@/stores/usePositionsPreferences'; -import { formatReadable } from '@/utils/balance'; +import { formatReadableTokenAmount } from '@/utils/balance'; import { buildBorrowPositionRows } from '@/utils/positions'; import { computeHealthScoreFromLtv, formatHealthScore } from '@/modals/borrow/components/helpers'; import type { MarketPositionWithEarnings } from '@/utils/types'; @@ -177,7 +177,7 @@ export function BorrowedMorphoBlueTable({ account, positions, onRefetch, isRefet
{row.isActiveDebt ? ( <> - {formatReadable(row.borrowAmount)} + {formatReadableTokenAmount(row.borrowAmount)} {row.market.loanAsset.symbol} {row.collateralAmount > 0 ? ( <> - {formatReadable(row.collateralAmount)} + {formatReadableTokenAmount(row.collateralAmount)} {row.market.collateralAsset.symbol}
- {formatReadable(groupedPosition.totalSupply)} + {formatReadableTokenAmount(groupedPosition.totalSupply)} {groupedPosition.loanAsset} +
+ Calculated from {moment(Number(startTimestamp)).format('MMM D, YYYY HH:mm')} to{' '} + {moment(Number(endTimestamp)).format('MMM D, YYYY HH:mm')} +
+
Exact amount: {exactTokenAmount}
+
+ } /> ); })()} >
{(() => { - const earningsReadable = Number(formatBalance(earnings, groupedPosition.loanAssetDecimals)); const priceKey = getTokenPriceKey(groupedPosition.loanAssetAddress, groupedPosition.chainId); const price = prices.get(priceKey); - const tokenAmount = `${formatReadable(earningsReadable)} ${groupedPosition.loanAsset}`; - const usdValue = price ? earningsReadable * price : null; + const earningsPreview = formatTokenAmountPreview(earnings, groupedPosition.loanAssetDecimals); + const tokenAmount = `${earningsPreview.compact} ${groupedPosition.loanAsset}`; + const usdValue = computeAssetUsdValue(earnings, groupedPosition.loanAssetDecimals, price ?? null); + const usdDisplay = usdValue == null ? null : formatUsdValueDisplay(usdValue); - if (showEarningsInUsd && usdValue !== null) { + if (showEarningsInUsd && usdDisplay !== null) { return (
- ${formatReadable(usdValue, usdValue < 1 ? 4 : 2)} + {usdDisplay.display} {tokenAmount}
); diff --git a/src/hooks/deleverage/deleverageWithErc4626Redeem.ts b/src/hooks/deleverage/deleverageWithErc4626Redeem.ts index 5042d86b..854b3433 100644 --- a/src/hooks/deleverage/deleverageWithErc4626Redeem.ts +++ b/src/hooks/deleverage/deleverageWithErc4626Redeem.ts @@ -16,8 +16,9 @@ type DeleverageWithErc4626RedeemParams = { autoWithdrawCollateralAmount: bigint; bundlerAddress: Address; /** - * Exact market collateral-share amount to route through the repay/redeem leg. - * On full-close-by-shares this must come from the quote-derived close bound, not the raw input field. + * ERC4626 share amount routed through the unwind leg. + * - partial route: exact shares to redeem + * - full-close route: maximum shares the bundler may spend to withdraw the exact repayment assets */ collateralToRedeem: bigint; ensureBundlerAuthorization: EnsureBundlerAuthorization; @@ -82,11 +83,22 @@ export const deleverageWithErc4626Redeem = async ({ useRepayByShares: useCloseRoute, }); - // No swap slippage exists on the ERC4626 redeem path. - // This redeem leg must return at least the flash-loan settlement amount or the whole bundle - // would revert later during flash-loan repayment. Extra loan assets from the buffered close - // amount are swept back to the user, while any remaining collateral is withdrawn separately below. - const minLoanAssetsOut = flashLoanAmount; + const unwindVaultTx = useCloseRoute + ? encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'erc4626Withdraw', + // Full close is the reverse of leverage's exact-asset deposit path: ask the vault for the + // exact buffered loan-asset amount needed to settle the flash loan, with a max-shares ceiling. + args: [route.collateralVault, flashLoanAmount, collateralToRedeem, bundlerAddress, bundlerAddress], + }) + : encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'erc4626Redeem', + // Partial unwind keeps the user-selected share amount fixed and applies the slippage buffer as + // a minimum loan-asset floor derived from the redeem preview. + args: [route.collateralVault, collateralToRedeem, flashLoanAmount, bundlerAddress, bundlerAddress], + }); + const callbackTxs: `0x${string}`[] = [ encodeFunctionData({ abi: morphoBundlerAbi, @@ -106,11 +118,7 @@ export const deleverageWithErc4626Redeem = async ({ // Withdraw ERC4626 shares onto the bundler because the same bundler multicall redeems them immediately. args: [marketParams, collateralToRedeem, bundlerAddress], }), - encodeFunctionData({ - abi: morphoBundlerAbi, - functionName: 'erc4626Redeem', - args: [route.collateralVault, collateralToRedeem, minLoanAssetsOut, bundlerAddress, bundlerAddress], - }), + unwindVaultTx, ]; if (autoWithdrawCollateralAmount > 0n) { diff --git a/src/hooks/leverage/leverageWithErc4626Deposit.ts b/src/hooks/leverage/leverageWithErc4626Deposit.ts index 113782dc..8e429b93 100644 --- a/src/hooks/leverage/leverageWithErc4626Deposit.ts +++ b/src/hooks/leverage/leverageWithErc4626Deposit.ts @@ -22,11 +22,12 @@ type LeverageWithErc4626DepositParams = { route: Erc4626LeverageRoute; /** Exact user-entered starting capital, denominated by `isLoanAssetInput`. */ initialCapitalInputAmount: bigint; - /** Market collateral-token amount sourced from the initial capital before the flash leg. */ + /** Minimum collateral shares accepted from depositing the initial capital before the flash leg. */ initialCapitalCollateralTokenAmount: bigint; initialCapitalInputTokenAddress: Address; initialCapitalTransferAmount: bigint; isLoanAssetInput: boolean; + /** Minimum collateral shares accepted from depositing the flash-loaned assets. */ flashLegCollateralTokenAmount: bigint; flashLoanAssetAmount: bigint; // amount to flashloan in loan asset leverageFeeAmount: bigint; @@ -136,8 +137,7 @@ export const leverageWithErc4626Deposit = async ({ if (isLoanAssetInput) { // WHY: the user provided an exact amount of loan-token assets, so this leg should deposit that - // exact asset amount into the vault. The share floor is the exact quote returned by previewDeposit, - // not a swap-style slippage floor. + // exact asset amount into the vault with a share floor derived from the ERC4626 preview. txs.push( encodeFunctionData({ abi: morphoBundlerAbi, @@ -150,11 +150,11 @@ export const leverageWithErc4626Deposit = async ({ const callbackTxs: `0x${string}`[] = [ encodeFunctionData({ abi: morphoBundlerAbi, - functionName: 'erc4626Mint', - // Mint the exact flash-leg collateral shares quoted off-chain. If the vault now requires - // more than flashLoanAssetAmount assets, revert early instead of minting fewer shares and drifting - // above the previewed leverage target. If it requires fewer, residual loan assets are swept later. - args: [route.collateralVault, flashLegCollateralTokenAmount, flashLoanAssetAmount, bundlerAddress], + functionName: 'erc4626Deposit', + // Deposit the exact flash-loaned assets and enforce a minimum share floor. This keeps the + // flash leg executable even if the vault's share price moved enough that an exact-share mint + // would require more assets than the flash loan supplied. + args: [route.collateralVault, flashLoanAssetAmount, flashLegCollateralTokenAmount, bundlerAddress], }), ]; diff --git a/src/hooks/useDeleverageQuote.ts b/src/hooks/useDeleverageQuote.ts index 323fbee6..ffad9898 100644 --- a/src/hooks/useDeleverageQuote.ts +++ b/src/hooks/useDeleverageQuote.ts @@ -1,7 +1,7 @@ import { useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; import { zeroAddress } from 'viem'; -import { useReadContracts } from 'wagmi'; +import { useReadContract } from 'wagmi'; import { erc4626Abi } from '@/abis/erc4626'; import { fetchVeloraPriceRoute, type VeloraPriceRoute } from '@/features/swap/api/velora'; import { withSlippageCeil, withSlippageFloor } from './leverage/math'; @@ -37,7 +37,7 @@ export type DeleverageQuote = { * Quotes how much debt can be repaid when unwinding a given collateral amount. * * Routes: - * - ERC4626: `withdrawCollateralAmount -> previewRedeem` and `previewWithdraw(currentDebt)` + * - ERC4626: `withdrawCollateralAmount -> previewRedeem`, then apply the slippage buffer to that conversion * - Swap: Velora SELL quote for repay preview and Velora BUY quote for max collateral to close debt */ export function useDeleverageQuote({ @@ -58,39 +58,21 @@ export function useDeleverageQuote({ const erc4626VaultAddress = route?.kind === 'erc4626' ? route.collateralVault : zeroAddress; const { - data: erc4626PreviewData, - isLoading: isLoadingErc4626Previews, - error: erc4626PreviewError, - } = useReadContracts({ - contracts: [ - { - address: erc4626VaultAddress, - abi: erc4626Abi, - functionName: 'previewRedeem', - // `previewRedeem(collateral shares)` -> loan-token assets returned by redeeming that exact share amount. - args: [withdrawCollateralAmount], - chainId, - }, - { - address: erc4626VaultAddress, - abi: erc4626Abi, - functionName: 'previewWithdraw', - // `previewWithdraw(buffered loan assets)` -> collateral shares needed to withdraw that exact asset amount. - args: [bufferedBorrowAssets], - chainId, - }, - ], - allowFailure: false, + data: previewRedeemLoanAssetsFromCollateralShares, + isLoading: isLoadingErc4626PreviewRedeem, + error: erc4626PreviewRedeemError, + } = useReadContract({ + address: erc4626VaultAddress, + abi: erc4626Abi, + functionName: 'previewRedeem', + // `previewRedeem(collateral shares)` -> loan-token assets returned by redeeming that exact share amount. + args: [withdrawCollateralAmount], + chainId, query: { - enabled: route?.kind === 'erc4626' && (withdrawCollateralAmount > 0n || bufferedBorrowAssets > 0n), + enabled: route?.kind === 'erc4626' && withdrawCollateralAmount > 0n, }, }); - const [previewRedeemLoanAssetsFromCollateralShares, previewWithdrawCollateralSharesForBufferedDebtAssets] = useMemo( - () => (erc4626PreviewData as readonly [bigint, bigint] | undefined) ?? [0n, 0n], - [erc4626PreviewData], - ); - const swapRepayQuoteQuery = useQuery({ queryKey: [ 'deleverage-swap-repay-quote', @@ -184,8 +166,8 @@ export function useDeleverageQuote({ const rawRouteRepayAmount = useMemo(() => { if (!route || withdrawCollateralAmount <= 0n) return 0n; if (route.kind === 'swap') return swapRepayQuote.rawRouteRepayAmount; - return previewRedeemLoanAssetsFromCollateralShares; - }, [route, withdrawCollateralAmount, swapRepayQuote.rawRouteRepayAmount, previewRedeemLoanAssetsFromCollateralShares]); + return withSlippageFloor((previewRedeemLoanAssetsFromCollateralShares as bigint | undefined) ?? 0n, slippageBps); + }, [route, withdrawCollateralAmount, swapRepayQuote.rawRouteRepayAmount, previewRedeemLoanAssetsFromCollateralShares, slippageBps]); const repayAmount = useMemo(() => { if (rawRouteRepayAmount <= 0n) return 0n; @@ -203,14 +185,16 @@ export function useDeleverageQuote({ if (!userAddress || swapMaxCollateralForDebtQuery.error) return 0n; return swapMaxCollateralForDebtQuery.data?.maxCollateralForDebtRepay ?? 0n; } - return previewWithdrawCollateralSharesForBufferedDebtAssets; + return rawRouteRepayAmount >= bufferedBorrowAssets ? withdrawCollateralAmount : 0n; }, [ route, currentBorrowAssets, swapMaxCollateralForDebtQuery.data, swapMaxCollateralForDebtQuery.error, userAddress, - previewWithdrawCollateralSharesForBufferedDebtAssets, + rawRouteRepayAmount, + bufferedBorrowAssets, + withdrawCollateralAmount, ]); const closeRouteRequiresResolution = useMemo(() => { @@ -283,7 +267,7 @@ export function useDeleverageQuote({ if (!routeError) return null; return routeError instanceof Error ? routeError.message : 'Failed to quote Velora swap route for deleverage.'; } - const routeError = erc4626PreviewError; + const routeError = erc4626PreviewRedeemError; if (!routeError) return null; return routeError instanceof Error ? routeError.message : 'Failed to quote deleverage route'; }, [ @@ -295,14 +279,14 @@ export function useDeleverageQuote({ currentBorrowShares, swapMaxCollateralForDebtQuery.error, swapRepayQuoteQuery.error, - erc4626PreviewError, + erc4626PreviewRedeemError, ]); const isLoading = !!route && (route.kind === 'swap' ? swapRepayQuoteQuery.isLoading || swapRepayQuoteQuery.isFetching || closeRouteRequiresResolution - : isLoadingErc4626Previews); + : isLoadingErc4626PreviewRedeem); return { repayAmount, diff --git a/src/hooks/useLeverageQuote.ts b/src/hooks/useLeverageQuote.ts index 58ecf223..894b112c 100644 --- a/src/hooks/useLeverageQuote.ts +++ b/src/hooks/useLeverageQuote.ts @@ -30,14 +30,14 @@ export type LeverageQuote = { * Market collateral-token amount sourced directly from the user's starting capital before the flash leg. * * - collateral-input mode: equals `initialCapitalInputAmount` - * - ERC4626 loan-input mode: `previewDeposit(initialCapitalInputAmount)` + * - ERC4626 loan-input mode: minimum shares accepted for `deposit(initialCapitalInputAmount)` * - swap loan-input mode: `0n` because the user loan input is sold together with the flash leg */ initialCapitalCollateralTokenAmount: bigint; /** * Additional market collateral-token amount sourced by the flash leg. * - * - ERC4626 route: exact vault share amount minted in the callback + * - ERC4626 route: minimum shares accepted for depositing the flash-loaned loan assets * - swap route: minimum collateral output expected from selling the flash-borrowed loan asset */ flashLegCollateralTokenAmount: bigint; @@ -89,7 +89,7 @@ export function useLeverageQuote({ }, }); - const initialCapitalCollateralTokenAmount = useMemo(() => { + const quotedInitialCapitalCollateralTokenAmount = useMemo(() => { if (!route) return 0n; if (isSwapLoanAssetInput) return 0n; if (!isLoanAssetInput) return initialCapitalInputAmount; @@ -99,13 +99,18 @@ export function useLeverageQuote({ return 0n; }, [route, isSwapLoanAssetInput, isLoanAssetInput, initialCapitalInputAmount, previewDepositCollateralSharesFromUserLoanAssets]); + const initialCapitalCollateralTokenAmount = useMemo(() => { + if (route?.kind !== 'erc4626' || !isLoanAssetInput) return quotedInitialCapitalCollateralTokenAmount; + return withSlippageFloor(quotedInitialCapitalCollateralTokenAmount, slippageBps); + }, [route, isLoanAssetInput, quotedInitialCapitalCollateralTokenAmount, slippageBps]); + const targetFlashCollateralTokenAmount = useMemo( - () => (isSwapLoanAssetInput ? 0n : computeFlashCollateralAmount(initialCapitalCollateralTokenAmount, multiplierBps)), - [isSwapLoanAssetInput, initialCapitalCollateralTokenAmount, multiplierBps], + () => (isSwapLoanAssetInput ? 0n : computeFlashCollateralAmount(quotedInitialCapitalCollateralTokenAmount, multiplierBps)), + [isSwapLoanAssetInput, quotedInitialCapitalCollateralTokenAmount, multiplierBps], ); const { - data: previewMintRequiredLoanAssetsForFlashCollateralShares, + data: previewMintableLoanAssets, isLoading: isLoadingErc4626Mint, error: erc4626MintError, } = useReadContract({ @@ -235,11 +240,12 @@ export function useLeverageQuote({ if (isLoanAssetInput) return swapLoanInputCombinedQuoteQuery.data?.flashLegCollateralTokenAmount ?? 0n; return swapCollateralInputQuoteQuery.data?.flashLegCollateralTokenAmount ?? 0n; } - return targetFlashCollateralTokenAmount; + return withSlippageFloor(targetFlashCollateralTokenAmount, slippageBps); }, [ route, isLoanAssetInput, targetFlashCollateralTokenAmount, + slippageBps, swapLoanInputCombinedQuoteQuery.data?.flashLegCollateralTokenAmount, swapCollateralInputQuoteQuery.data?.flashLegCollateralTokenAmount, ]); @@ -252,13 +258,13 @@ export function useLeverageQuote({ } // `previewMint(targetFlashCollateralTokenAmount)` returns how many loan-token assets the flash leg // must source to mint that exact collateral-share amount. - return (previewMintRequiredLoanAssetsForFlashCollateralShares as bigint | undefined) ?? 0n; + return previewMintableLoanAssets ?? 0n; }, [ route, isLoanAssetInput, swapLoanInputCombinedQuoteQuery.data?.flashLoanAssetAmount, swapCollateralInputQuoteQuery.data?.flashLoanAssetAmount, - previewMintRequiredLoanAssetsForFlashCollateralShares, + previewMintableLoanAssets, ]); const totalCollateralTokenAmountAdded = useMemo(() => { diff --git a/src/modals/borrow/components/borrow-position-risk-card.tsx b/src/modals/borrow/components/borrow-position-risk-card.tsx index 782a8486..7e18ae56 100644 --- a/src/modals/borrow/components/borrow-position-risk-card.tsx +++ b/src/modals/borrow/components/borrow-position-risk-card.tsx @@ -39,6 +39,19 @@ type PreviewIndicatorProps = { children: ReactNode; }; +type TokenMetricValueProps = { + isPreview: boolean; + title: ReactNode; + detail: ReactNode; + secondaryDetail?: ReactNode; + displayAmount: string; + fullAmount: string; + symbol: string; + valueClassName: string; +}; + +const INLINE_VALUE_TOOLTIP_CLASS_NAME = 'px-4 py-3 text-xs'; + function formatSignedNumberDelta(value: number, suffix = ''): string { if (!Number.isFinite(value)) return '0'; if (value > 0) return `+${value.toFixed(2)}${suffix}`; @@ -80,6 +93,50 @@ function PreviewIndicator({ isPreview, title, detail, secondaryDetail, children ); } +function TokenMetricValue({ + isPreview, + title, + detail, + secondaryDetail, + displayAmount, + fullAmount, + symbol, + valueClassName, +}: TokenMetricValueProps): JSX.Element { + const content = ( +
+ {displayAmount} + {symbol} +
+ ); + + if (isPreview) { + return ( + + {content} + + ); + } + + if (displayAmount === fullAmount) { + return content; + } + + return ( + + {content} + + ); +} + function renderLiquidationPriceValue(priceLabel: string, priceGapLabel: string | null): ReactNode { if (priceGapLabel == null || priceLabel === '-' || priceLabel === '∞') { return priceLabel; @@ -104,7 +161,7 @@ export function BorrowPositionRiskCard({ projectedLtv, lltv, hasChanges = false, - useCompactAmountDisplay = false, + useCompactAmountDisplay = true, }: BorrowPositionRiskCardProps): JSX.Element { const projectedLtvWidth = useMemo(() => { if (lltv <= 0n) return 0; @@ -114,7 +171,7 @@ 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 tabular-nums whitespace-nowrap'; + const metricValueClassName = 'font-zen text-sm tabular-nums'; const showProjectedCollateral = hasChanges && projectedCollateralValue !== currentCollateral; const showProjectedBorrow = hasChanges && projectedBorrowValue !== currentBorrow; @@ -248,9 +305,9 @@ export function BorrowPositionRiskCard({ return (
-
+

Collateral

-
+
-
- - {collateralDisplay} - - {market.collateralAsset.symbol} -
+
-
+

Debt (Loan)

-
+
-
- - {borrowDisplay} - - {market.loanAsset.symbol} -
+
diff --git a/src/modals/leverage/components/add-collateral-and-leverage.tsx b/src/modals/leverage/components/add-collateral-and-leverage.tsx index 48f764be..636e331c 100644 --- a/src/modals/leverage/components/add-collateral-and-leverage.tsx +++ b/src/modals/leverage/components/add-collateral-and-leverage.tsx @@ -351,7 +351,9 @@ export function AddCollateralAndLeverage({ ? useLoanAssetInput ? 'Total Collateral Added (Min.)' : 'Collateral From Swap (Min.)' - : 'Total Collateral Added'; + : isErc4626Route + ? 'Total Collateral Added (Min.)' + : 'Total Collateral Added'; const hasExecutableInitialCapitalConversion = useMemo(() => { if (!useLoanAssetInput) return true; if (isSwapRoute) return quote.totalCollateralTokenAmountAdded > 0n; @@ -387,6 +389,7 @@ export function AddCollateralAndLeverage({ market.loanAsset.symbol, ]); const shouldShowSwapPreviewDetails = isSwapRoute && quote.swapPriceRoute != null && swapRatePreviewText != null; + const shouldShowSlippageControl = isSwapRoute || isErc4626Route; const toDisplayRate = useCallback( (apy: number | null): number | null => { if (apy == null || !Number.isFinite(apy)) return null; @@ -786,19 +789,19 @@ export function AddCollateralAndLeverage({
)} {shouldShowSwapPreviewDetails && ( - <> -
- Swap Quote - {swapRatePreviewText} -
-
- Max Slippage - -
- +
+ Swap Quote + {swapRatePreviewText} +
+ )} + {shouldShowSlippageControl && ( +
+ Max Slippage + +
)}
{borrowRatePreviewLabel} diff --git a/src/utils/balance.ts b/src/utils/balance.ts index c0b6261e..99a4ac2f 100644 --- a/src/utils/balance.ts +++ b/src/utils/balance.ts @@ -48,6 +48,43 @@ export function formatReadable(_num: number | string, precision = 2): string { } } +type FormatReadableTokenAmountOptions = { + precision?: number; + minDisplayDecimals?: number; +}; + +export function formatReadableTokenAmount( + value: number | string, + { precision = 4, minDisplayDecimals = 4 }: FormatReadableTokenAmountOptions = {}, +): string { + const numericValue = typeof value === 'string' ? Number.parseFloat(value) : value; + if (!Number.isFinite(numericValue)) return typeof value === 'string' ? value : String(value); + + const absoluteValue = Math.abs(numericValue); + const minDisplayThreshold = 10 ** -minDisplayDecimals; + + if (absoluteValue > 0 && absoluteValue < minDisplayThreshold) { + return `< ${minDisplayThreshold.toFixed(minDisplayDecimals)}`; + } + + if (absoluteValue >= 1000) { + return new Intl.NumberFormat('en-US', { + notation: 'compact', + maximumFractionDigits: 2, + }).format(numericValue); + } + + if (absoluteValue >= 1) { + return numericValue.toLocaleString('en-US', { + maximumFractionDigits: precision, + }); + } + + return numericValue.toLocaleString('en-US', { + maximumSignificantDigits: precision, + }); +} + export function formatSimple(num: number | bigint) { return new Intl.NumberFormat('en-us', { minimumFractionDigits: 2,