From c14172f0c9873476ab31b266e2725d5906d34161 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Wed, 25 Feb 2026 21:52:02 +0800 Subject: [PATCH 01/10] feat: ERC4626 looping and stETH looping --- src/abis/erc4626.ts | 43 ++ src/abis/wsteth.ts | 28 + .../components/market-header.tsx | 30 + src/features/market-detail/market-view.tsx | 10 + .../components/market-actions-dropdown.tsx | 4 +- src/hooks/leverage/math.ts | 111 ++++ src/hooks/leverage/types.ts | 32 + src/hooks/useDeleverageQuote.ts | 137 +++++ src/hooks/useDeleverageTransaction.ts | 325 ++++++++++ src/hooks/useLeverageQuote.ts | 119 ++++ src/hooks/useLeverageSupport.ts | 151 +++++ src/hooks/useLeverageTransaction.ts | 551 +++++++++++++++++ .../components/borrow-position-risk-card.tsx | 63 +- .../withdraw-collateral-and-repay.tsx | 1 - .../add-collateral-and-leverage.tsx | 557 ++++++++++++++++++ .../remove-collateral-and-deleverage.tsx | 240 ++++++++ src/modals/leverage/leverage-modal-global.tsx | 55 ++ src/modals/leverage/leverage-modal.tsx | 159 +++++ src/modals/registry.tsx | 2 + src/stores/useModalStore.ts | 8 + src/types/token.ts | 41 +- src/utils/token-amount-format.ts | 49 ++ 22 files changed, 2707 insertions(+), 9 deletions(-) create mode 100644 src/abis/erc4626.ts create mode 100644 src/abis/wsteth.ts create mode 100644 src/hooks/leverage/math.ts create mode 100644 src/hooks/leverage/types.ts create mode 100644 src/hooks/useDeleverageQuote.ts create mode 100644 src/hooks/useDeleverageTransaction.ts create mode 100644 src/hooks/useLeverageQuote.ts create mode 100644 src/hooks/useLeverageSupport.ts create mode 100644 src/hooks/useLeverageTransaction.ts create mode 100644 src/modals/leverage/components/add-collateral-and-leverage.tsx create mode 100644 src/modals/leverage/components/remove-collateral-and-deleverage.tsx create mode 100644 src/modals/leverage/leverage-modal-global.tsx create mode 100644 src/modals/leverage/leverage-modal.tsx create mode 100644 src/utils/token-amount-format.ts diff --git a/src/abis/erc4626.ts b/src/abis/erc4626.ts new file mode 100644 index 00000000..02012a44 --- /dev/null +++ b/src/abis/erc4626.ts @@ -0,0 +1,43 @@ +import type { Abi } from 'viem'; + +/** + * Minimal ERC4626 ABI used by leverage/deleverage routing. + * Keep this small on purpose to avoid importing the giant vault ABI. + */ +export const erc4626Abi = [ + { + inputs: [], + name: 'asset', + outputs: [{ internalType: 'address', name: '', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [{ internalType: 'uint256', name: 'shares', type: 'uint256' }], + name: 'previewMint', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [{ internalType: 'uint256', name: 'assets', type: 'uint256' }], + name: 'previewDeposit', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [{ internalType: 'uint256', name: 'shares', type: 'uint256' }], + name: 'previewRedeem', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [{ internalType: 'uint256', name: 'assets', type: 'uint256' }], + name: 'previewWithdraw', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, +] as const satisfies Abi; diff --git a/src/abis/wsteth.ts b/src/abis/wsteth.ts new file mode 100644 index 00000000..38dc3468 --- /dev/null +++ b/src/abis/wsteth.ts @@ -0,0 +1,28 @@ +import type { Abi } from 'viem'; + +/** + * Minimal wstETH ABI surface required for V2 stETH leverage/deleverage routing. + */ +export const wstEthAbi = [ + { + inputs: [], + name: 'stETH', + outputs: [{ internalType: 'address', name: '', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [{ internalType: 'uint256', name: '_wstETHAmount', type: 'uint256' }], + name: 'getStETHByWstETH', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [{ internalType: 'uint256', name: '_stETHAmount', type: 'uint256' }], + name: 'getWstETHByStETH', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, +] as const satisfies Abi; diff --git a/src/features/market-detail/components/market-header.tsx b/src/features/market-detail/components/market-header.tsx index d67015c9..4ceb5fe9 100644 --- a/src/features/market-detail/components/market-header.tsx +++ b/src/features/market-detail/components/market-header.tsx @@ -25,6 +25,7 @@ import { TooltipContent } from '@/components/shared/tooltip-content'; import { AddressIdentity } from '@/components/shared/address-identity'; import { CampaignBadge } from '@/features/market-detail/components/campaign-badge'; import { OracleTypeInfo } from '@/features/markets/components/oracle/MarketOracle/OracleTypeInfo'; +import { useLeverageSupport } from '@/hooks/useLeverageSupport'; import { useRateLabel } from '@/hooks/useRateLabel'; import { useStyledToast } from '@/hooks/useStyledToast'; import { useAppSettings } from '@/stores/useAppSettings'; @@ -126,6 +127,8 @@ type ActionButtonsProps = { onWithdrawClick: () => void; onBorrowClick: () => void; onRepayClick: () => void; + onLeverageClick: () => void; + onDeleverageClick: () => void; }; function ActionButtons({ @@ -135,7 +138,11 @@ function ActionButtons({ onWithdrawClick, onBorrowClick, onRepayClick, + onLeverageClick, + onDeleverageClick, }: ActionButtonsProps): React.ReactNode { + const leverageSupport = useLeverageSupport({ market }); + // Compute position states once const hasSupply = userPosition !== null && BigInt(userPosition.state.supplyShares) > 0n; const hasBorrow = userPosition !== null && BigInt(userPosition.state.borrowShares) > 0n; @@ -235,6 +242,23 @@ function ActionButtons({ }, ]} /> + + {!leverageSupport.isLoading && leverageSupport.supportsLeverage && ( + } + onClick={onLeverageClick} + indicator={{ show: hasBorrowPosition }} + dropdownItems={[ + { + label: 'Deleverage', + icon: , + onClick: onDeleverageClick, + disabled: !leverageSupport.supportsDeleverage || !hasBorrow, + }, + ]} + /> + )} ); } @@ -250,6 +274,8 @@ type MarketHeaderProps = { onWithdrawClick?: () => void; onBorrowClick?: () => void; onRepayClick?: () => void; + onLeverageClick?: () => void; + onDeleverageClick?: () => void; accrueInterest?: () => void; isLoading?: boolean; }; @@ -344,6 +370,8 @@ export function MarketHeader({ onWithdrawClick = () => {}, onBorrowClick = () => {}, onRepayClick = () => {}, + onLeverageClick = () => {}, + onDeleverageClick = () => {}, accrueInterest = () => {}, isLoading = false, }: MarketHeaderProps) { @@ -573,6 +601,8 @@ export function MarketHeader({ onWithdrawClick={onWithdrawClick} onBorrowClick={onBorrowClick} onRepayClick={onRepayClick} + onLeverageClick={onLeverageClick} + onDeleverageClick={onDeleverageClick} /> {/* Advanced Options Dropdown */} diff --git a/src/features/market-detail/market-view.tsx b/src/features/market-detail/market-view.tsx index b71c954e..ad9fd30c 100644 --- a/src/features/market-detail/market-view.tsx +++ b/src/features/market-detail/market-view.tsx @@ -300,6 +300,14 @@ function MarketContent() { openModal('borrow', { market, refetch: handleRefresh, liquiditySourcing, defaultMode: 'repay' }); }; + const handleLeverageClick = () => { + openModal('leverage', { market, refetch: handleRefresh, defaultMode: 'leverage' }); + }; + + const handleDeleverageClick = () => { + openModal('leverage', { market, refetch: handleRefresh, defaultMode: 'deleverage' }); + }; + const handleAccrueInterest = async () => { const morphoAddress = market.morphoBlue.address as Address; await switchChainAsync({ chainId: market.morphoBlue.chain.id }); @@ -364,6 +372,8 @@ function MarketContent() { onWithdrawClick={handleWithdrawClick} onBorrowClick={handleBorrowClick} onRepayClick={handleRepayClick} + onLeverageClick={handleLeverageClick} + onDeleverageClick={handleDeleverageClick} accrueInterest={handleAccrueInterest} /> diff --git a/src/features/markets/components/market-actions-dropdown.tsx b/src/features/markets/components/market-actions-dropdown.tsx index 55d0069d..f78a08df 100644 --- a/src/features/markets/components/market-actions-dropdown.tsx +++ b/src/features/markets/components/market-actions-dropdown.tsx @@ -91,7 +91,9 @@ export function MarketActionsDropdown({ market }: MarketActionsDropdownProps) { { + onMarketClick(); + }} startContent={} > View Market diff --git a/src/hooks/leverage/math.ts b/src/hooks/leverage/math.ts new file mode 100644 index 00000000..586e632a --- /dev/null +++ b/src/hooks/leverage/math.ts @@ -0,0 +1,111 @@ +import { LEVERAGE_MAX_MULTIPLIER_BPS, LEVERAGE_MIN_MULTIPLIER_BPS, LEVERAGE_MULTIPLIER_SCALE_BPS } from './types'; + +export const LEVERAGE_SLIPPAGE_BUFFER_BPS = 9_950n; // 0.50% tolerance +export const LTV_WAD = 10n ** 18n; +export const ORACLE_PRICE_SCALE = 10n ** 36n; + +export const clampMultiplierBps = (value: bigint): bigint => { + if (value < LEVERAGE_MIN_MULTIPLIER_BPS) return LEVERAGE_MIN_MULTIPLIER_BPS; + if (value > LEVERAGE_MAX_MULTIPLIER_BPS) return LEVERAGE_MAX_MULTIPLIER_BPS; + return value; +}; + +export const parseMultiplierToBps = (value: string): bigint => { + const normalized = value.trim().replace(',', '.'); + if (normalized.length === 0) return LEVERAGE_MIN_MULTIPLIER_BPS; + + const parsed = Number.parseFloat(normalized); + if (!Number.isFinite(parsed) || parsed <= 1) return LEVERAGE_MIN_MULTIPLIER_BPS; + return clampMultiplierBps(BigInt(Math.round(parsed * 10_000))); +}; + +export const formatMultiplierBps = (value: bigint): string => { + const safe = clampMultiplierBps(value); + return (Number(safe) / 10_000).toFixed(2); +}; + +/** + * Converts user collateral and desired multiplier into extra collateral required + * via flash liquidity. + */ +export const computeFlashCollateralAmount = (userCollateralAmount: bigint, multiplierBps: bigint): bigint => { + if (userCollateralAmount <= 0n) return 0n; + const safeMultiplier = clampMultiplierBps(multiplierBps); + const leveragedCollateral = (userCollateralAmount * safeMultiplier) / LEVERAGE_MULTIPLIER_SCALE_BPS; + return leveragedCollateral > userCollateralAmount ? leveragedCollateral - userCollateralAmount : 0n; +}; + +export const computeProjectedLtv = ({ + currentBorrowAssets, + borrowDelta, + currentCollateralAssets, + collateralDelta, + oraclePrice, +}: { + currentBorrowAssets: bigint; + borrowDelta: bigint; + currentCollateralAssets: bigint; + collateralDelta: bigint; + oraclePrice: bigint; +}): bigint => { + const projectedBorrowAssets = currentBorrowAssets + borrowDelta; + const projectedCollateralAssets = currentCollateralAssets + collateralDelta; + + if (projectedBorrowAssets <= 0n) return 0n; + if (projectedCollateralAssets <= 0n || oraclePrice <= 0n) return 10n ** 30n; + + const collateralValueInLoan = (projectedCollateralAssets * oraclePrice) / ORACLE_PRICE_SCALE; + if (collateralValueInLoan <= 0n) return 10n ** 30n; + + return (projectedBorrowAssets * LTV_WAD) / collateralValueInLoan; +}; + +export const withSlippageFloor = (value: bigint): bigint => { + if (value <= 0n) return 0n; + const floored = (value * LEVERAGE_SLIPPAGE_BUFFER_BPS) / LEVERAGE_MULTIPLIER_SCALE_BPS; + return floored > 0n ? floored : 1n; +}; + +export const withSlippageCeil = (value: bigint): bigint => { + if (value <= 0n) return 0n; + const ceilBps = LEVERAGE_MULTIPLIER_SCALE_BPS + (LEVERAGE_MULTIPLIER_SCALE_BPS - LEVERAGE_SLIPPAGE_BUFFER_BPS); + return (value * ceilBps + LEVERAGE_MULTIPLIER_SCALE_BPS - 1n) / LEVERAGE_MULTIPLIER_SCALE_BPS; +}; + +export const computeBorrowSharesWithBuffer = ({ + borrowAssets, + totalBorrowAssets, + totalBorrowShares, +}: { + borrowAssets: bigint; + totalBorrowAssets: bigint; + totalBorrowShares: bigint; +}): bigint => { + if (borrowAssets <= 0n) return 0n; + + // Morpho virtual shares/assets from SharesMathLib to avoid edge-case division by zero. + const VIRTUAL_SHARES = 1_000_000n; + const VIRTUAL_ASSETS = 1n; + + const denominator = totalBorrowAssets + VIRTUAL_ASSETS; + const numerator = borrowAssets * (totalBorrowShares + VIRTUAL_SHARES); + const expectedShares = (numerator + denominator - 1n) / denominator; // round up + + // Add 0.5% headroom to keep borrow slippage checks stable across minor state drift. + return expectedShares + expectedShares / 200n + 1n; +}; + +export const computeRepaySharesWithBuffer = ({ + repayAssets, + totalBorrowAssets, + totalBorrowShares, +}: { + repayAssets: bigint; + totalBorrowAssets: bigint; + totalBorrowShares: bigint; +}): bigint => { + if (repayAssets <= 0n || totalBorrowAssets <= 0n || totalBorrowShares <= 0n) return 0n; + + const expectedShares = (repayAssets * totalBorrowShares) / totalBorrowAssets; + return expectedShares + expectedShares / 200n + 1n; +}; diff --git a/src/hooks/leverage/types.ts b/src/hooks/leverage/types.ts new file mode 100644 index 00000000..436a0f09 --- /dev/null +++ b/src/hooks/leverage/types.ts @@ -0,0 +1,32 @@ +import type { Address } from 'viem'; + +export type StEthLoanMode = 'steth' | 'mainnet-weth-steth-wsteth'; + +export type Erc4626LeverageRoute = { + kind: 'erc4626'; + collateralVault: Address; + underlyingLoanToken: Address; +}; + +export type StEthLeverageRoute = { + kind: 'steth'; + collateralToken: Address; + stEthToken: Address; + loanMode: StEthLoanMode; +}; + +export type LeverageRoute = Erc4626LeverageRoute | StEthLeverageRoute; + +export type LeverageSupport = { + isSupported: boolean; + supportsLeverage: boolean; + supportsDeleverage: boolean; + isLoading: boolean; + route: LeverageRoute | null; + reason: string | null; +}; + +export const LEVERAGE_MULTIPLIER_SCALE_BPS = 10_000n; +export const LEVERAGE_MIN_MULTIPLIER_BPS = 10_000n; // 1.00x +export const LEVERAGE_DEFAULT_MULTIPLIER_BPS = 20_000n; // 2.00x +export const LEVERAGE_MAX_MULTIPLIER_BPS = 100_000n; // 10.00x diff --git a/src/hooks/useDeleverageQuote.ts b/src/hooks/useDeleverageQuote.ts new file mode 100644 index 00000000..b2ecec09 --- /dev/null +++ b/src/hooks/useDeleverageQuote.ts @@ -0,0 +1,137 @@ +import { useMemo } from 'react'; +import { useReadContract } from 'wagmi'; +import { erc4626Abi } from '@/abis/erc4626'; +import { wstEthAbi } from '@/abis/wsteth'; +import { withSlippageCeil } from './leverage/math'; +import type { LeverageRoute } from './leverage/types'; + +type UseDeleverageQuoteParams = { + chainId: number; + route: LeverageRoute | null; + withdrawCollateralAmount: bigint; + currentBorrowAssets: bigint; +}; + +export type DeleverageQuote = { + repayAmount: bigint; + rawRouteRepayAmount: bigint; + maxCollateralForDebtRepay: bigint; + isLoading: boolean; + error: string | null; +}; + +/** + * Quotes how much debt can be repaid when unwinding a given collateral amount. + * + * We intentionally quote from `withdrawCollateralAmount -> loanAssets` using redeem + * side conversions so the callback consumes exactly the requested collateral amount. + */ +export function useDeleverageQuote({ + chainId, + route, + withdrawCollateralAmount, + currentBorrowAssets, +}: UseDeleverageQuoteParams): DeleverageQuote { + const isErc4626 = route?.kind === 'erc4626'; + const isStEth = route?.kind === 'steth'; + const bufferedBorrowAssets = withSlippageCeil(currentBorrowAssets); + + const { + data: erc4626PreviewRedeem, + isLoading: isLoadingRedeem, + error: redeemError, + } = useReadContract({ + address: isErc4626 ? route.collateralVault : undefined, + abi: erc4626Abi, + functionName: 'previewRedeem', + args: [withdrawCollateralAmount], + chainId, + query: { + enabled: isErc4626 && withdrawCollateralAmount > 0n, + }, + }); + + const { + data: erc4626PreviewWithdrawForDebt, + isLoading: isLoadingWithdraw, + error: withdrawError, + } = useReadContract({ + address: isErc4626 ? route.collateralVault : undefined, + abi: erc4626Abi, + functionName: 'previewWithdraw', + args: [bufferedBorrowAssets], + chainId, + query: { + enabled: isErc4626 && bufferedBorrowAssets > 0n, + }, + }); + + const { + data: stEthByWstEth, + isLoading: isLoadingStEthRedeem, + error: stEthRedeemError, + } = useReadContract({ + address: isStEth ? route.collateralToken : undefined, + abi: wstEthAbi, + functionName: 'getStETHByWstETH', + args: [withdrawCollateralAmount], + chainId, + query: { + enabled: isStEth && withdrawCollateralAmount > 0n, + }, + }); + + const { + data: wstEthByStEthDebt, + isLoading: isLoadingWstDebt, + error: stEthDebtError, + } = useReadContract({ + address: isStEth ? route.collateralToken : undefined, + abi: wstEthAbi, + functionName: 'getWstETHByStETH', + args: [bufferedBorrowAssets], + chainId, + query: { + enabled: isStEth && bufferedBorrowAssets > 0n, + }, + }); + + const rawRouteRepayAmount = useMemo(() => { + if (!route || withdrawCollateralAmount <= 0n) return 0n; + if (route.kind === 'erc4626') return (erc4626PreviewRedeem as bigint | undefined) ?? 0n; + return (stEthByWstEth as bigint | undefined) ?? 0n; + }, [route, withdrawCollateralAmount, erc4626PreviewRedeem, stEthByWstEth]); + + const repayAmount = useMemo(() => { + if (rawRouteRepayAmount <= 0n) return 0n; + return rawRouteRepayAmount > currentBorrowAssets ? currentBorrowAssets : rawRouteRepayAmount; + }, [rawRouteRepayAmount, currentBorrowAssets]); + + const maxCollateralForDebtRepay = useMemo(() => { + if (!route || currentBorrowAssets <= 0n) return 0n; + if (route.kind === 'erc4626') return (erc4626PreviewWithdrawForDebt as bigint | undefined) ?? 0n; + return (wstEthByStEthDebt as bigint | undefined) ?? 0n; + }, [route, currentBorrowAssets, erc4626PreviewWithdrawForDebt, wstEthByStEthDebt]); + + const error = useMemo(() => { + if (!route) return null; + const routeError = route.kind === 'erc4626' ? (redeemError ?? withdrawError) : (stEthRedeemError ?? stEthDebtError); + if (!routeError) return null; + return routeError instanceof Error ? routeError.message : 'Failed to quote deleverage route'; + }, [route, redeemError, withdrawError, stEthRedeemError, stEthDebtError]); + + const isLoading = + route?.kind === 'erc4626' + ? isLoadingRedeem || isLoadingWithdraw + : route?.kind === 'steth' + ? isLoadingStEthRedeem || isLoadingWstDebt + : false; + + return { + repayAmount, + rawRouteRepayAmount, + maxCollateralForDebtRepay, + isLoading, + error, + }; +} diff --git a/src/hooks/useDeleverageTransaction.ts b/src/hooks/useDeleverageTransaction.ts new file mode 100644 index 00000000..b2754eb9 --- /dev/null +++ b/src/hooks/useDeleverageTransaction.ts @@ -0,0 +1,325 @@ +import { useCallback } from 'react'; +import { type Address, encodeAbiParameters, encodeFunctionData } from 'viem'; +import { useConnection } from 'wagmi'; +import morphoBundlerAbi from '@/abis/bundlerV2'; +import { useBundlerAuthorizationStep } from '@/hooks/useBundlerAuthorizationStep'; +import { useStyledToast } from '@/hooks/useStyledToast'; +import { useTransactionWithToast } from '@/hooks/useTransactionWithToast'; +import { useTransactionTracking } from '@/hooks/useTransactionTracking'; +import { useUserMarketsCache } from '@/stores/useUserMarketsCache'; +import { useAppSettings } from '@/stores/useAppSettings'; +import { formatBalance } from '@/utils/balance'; +import { getBundlerV2, MONARCH_TX_IDENTIFIER } from '@/utils/morpho'; +import type { Market } from '@/utils/types'; +import { withSlippageFloor } from './leverage/math'; +import type { LeverageRoute } from './leverage/types'; + +export type DeleverageStepType = 'authorize_bundler_sig' | 'authorize_bundler_tx' | 'execute'; + +type UseDeleverageTransactionProps = { + market: Market; + route: LeverageRoute; + withdrawCollateralAmount: bigint; + flashLoanAmount: bigint; + repayBySharesAmount: bigint; + autoWithdrawCollateralAmount: bigint; + onSuccess?: () => void; +}; + +/** + * Executes V2 deleverage for deterministic conversion routes. + * + * Flow: + * 1) flash-loan debt token + * 2) repay debt on behalf of user + * 3) withdraw requested collateral + * 4) convert withdrawn collateral back into debt token + * + * Morpho pulls the flash-loaned debt token back from bundler after callback. + */ +export function useDeleverageTransaction({ + market, + route, + withdrawCollateralAmount, + flashLoanAmount, + repayBySharesAmount, + autoWithdrawCollateralAmount, + onSuccess, +}: UseDeleverageTransactionProps) { + const { usePermit2: usePermit2Setting } = useAppSettings(); + const tracking = useTransactionTracking('deleverage'); + const { address: account, chainId } = useConnection(); + const toast = useStyledToast(); + const bundlerAddress = getBundlerV2(market.morphoBlue.chain.id); + const { batchAddUserMarkets } = useUserMarketsCache(account); + + const { isBundlerAuthorized, isAuthorizingBundler, ensureBundlerAuthorization, refetchIsBundlerAuthorized } = useBundlerAuthorizationStep( + { + chainId: market.morphoBlue.chain.id, + bundlerAddress: bundlerAddress as Address, + }, + ); + + const { isConfirming: deleveragePending, sendTransactionAsync } = useTransactionWithToast({ + toastId: 'deleverage', + pendingText: `Deleveraging ${formatBalance(withdrawCollateralAmount, market.collateralAsset.decimals)} ${market.collateralAsset.symbol}`, + successText: 'Deleverage Executed', + errorText: 'Failed to execute deleverage', + chainId, + pendingDescription: `Executing deleverage on market ${market.uniqueKey.slice(2, 8)}...`, + successDescription: 'Position delevered successfully', + onSuccess: () => { + void refetchIsBundlerAuthorized(); + if (onSuccess) void onSuccess(); + }, + }); + + const getStepsForFlow = useCallback((isPermit2: boolean) => { + if (isPermit2) { + return [ + { + id: 'authorize_bundler_sig', + title: 'Authorize Morpho Bundler', + description: 'Sign a message to authorize deleverage actions.', + }, + { + id: 'execute', + title: 'Confirm Deleverage', + description: 'Confirm the deleverage transaction in your wallet.', + }, + ]; + } + + return [ + { + id: 'authorize_bundler_tx', + title: 'Authorize Morpho Bundler', + description: 'Submit one transaction authorizing bundler actions on your Morpho position.', + }, + { + id: 'execute', + title: 'Confirm Deleverage', + description: 'Confirm the deleverage transaction in your wallet.', + }, + ]; + }, []); + + const executeDeleverage = useCallback(async () => { + if (!account) { + toast.info('No account connected', 'Please connect your wallet.'); + return; + } + + if (withdrawCollateralAmount <= 0n || flashLoanAmount <= 0n) { + toast.info('Invalid deleverage inputs', 'Set a collateral unwind amount above zero.'); + return; + } + + if (route.kind === 'steth' && route.loanMode !== 'steth') { + toast.info('Unsupported route', 'This stETH route supports leverage only. Deleverage is unavailable.'); + return; + } + + try { + const txs: `0x${string}`[] = []; + + if (usePermit2Setting) { + tracking.update('authorize_bundler_sig'); + const { authorizationTxData } = await ensureBundlerAuthorization({ mode: 'signature' }); + if (authorizationTxData) { + txs.push(authorizationTxData); + await new Promise((resolve) => setTimeout(resolve, 700)); + } + } else { + tracking.update('authorize_bundler_tx'); + const { authorized } = await ensureBundlerAuthorization({ mode: 'transaction' }); + if (!authorized) { + throw new Error('Failed to authorize Bundler via transaction.'); + } + } + + const isRepayByShares = repayBySharesAmount > 0n; + // WHY: when repaying by assets, Morpho expects a *minimum* shares bound. + // Using an upper-bound style estimate causes false "slippage exceeded" reverts. + const minRepayShares = 1n; + + const callbackTxs: `0x${string}`[] = [ + encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'morphoRepay', + args: [ + { + loanToken: market.loanAsset.address as Address, + collateralToken: market.collateralAsset.address as Address, + oracle: market.oracleAddress as Address, + irm: market.irmAddress as Address, + lltv: BigInt(market.lltv), + }, + isRepayByShares ? 0n : flashLoanAmount, + isRepayByShares ? repayBySharesAmount : 0n, + isRepayByShares ? flashLoanAmount : minRepayShares, + account as Address, + '0x', + ], + }), + encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'morphoWithdrawCollateral', + args: [ + { + loanToken: market.loanAsset.address as Address, + collateralToken: market.collateralAsset.address as Address, + oracle: market.oracleAddress as Address, + irm: market.irmAddress as Address, + lltv: BigInt(market.lltv), + }, + withdrawCollateralAmount, + bundlerAddress as Address, + ], + }), + ]; + + if (route.kind === 'erc4626') { + const minAssetsOut = withSlippageFloor(flashLoanAmount); + callbackTxs.push( + encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'erc4626Redeem', + args: [route.collateralVault, withdrawCollateralAmount, minAssetsOut, bundlerAddress as Address, bundlerAddress as Address], + }), + ); + } else { + callbackTxs.push( + encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'unwrapStEth', + args: [withdrawCollateralAmount], + }), + ); + } + + if (autoWithdrawCollateralAmount > 0n) { + // WHY: if deleverage fully clears debt, keeping collateral locked in Morpho adds friction. + // We withdraw the remaining collateral in the same transaction so the position is closed. + callbackTxs.push( + encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'morphoWithdrawCollateral', + args: [ + { + loanToken: market.loanAsset.address as Address, + collateralToken: market.collateralAsset.address as Address, + oracle: market.oracleAddress as Address, + irm: market.irmAddress as Address, + lltv: BigInt(market.lltv), + }, + autoWithdrawCollateralAmount, + account as Address, + ], + }), + ); + } + + const flashLoanCallbackData = encodeAbiParameters([{ type: 'bytes[]' }], [callbackTxs]); + txs.push( + encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'morphoFlashLoan', + args: [market.loanAsset.address as Address, flashLoanAmount, flashLoanCallbackData], + }), + ); + + tracking.update('execute'); + await new Promise((resolve) => setTimeout(resolve, 700)); + + await sendTransactionAsync({ + account, + to: bundlerAddress, + data: (encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'multicall', + args: [txs], + }) + MONARCH_TX_IDENTIFIER) as `0x${string}`, + value: 0n, + }); + + batchAddUserMarkets([ + { + marketUniqueKey: market.uniqueKey, + chainId: market.morphoBlue.chain.id, + }, + ]); + + tracking.complete(); + } catch (error: unknown) { + tracking.fail(); + console.error('Error during deleverage execution:', error); + if (error instanceof Error && !error.message.toLowerCase().includes('rejected')) { + toast.error('Deleverage Failed', 'An unexpected error occurred during deleverage.'); + } + } + }, [ + account, + market, + route, + withdrawCollateralAmount, + flashLoanAmount, + repayBySharesAmount, + autoWithdrawCollateralAmount, + usePermit2Setting, + ensureBundlerAuthorization, + bundlerAddress, + sendTransactionAsync, + batchAddUserMarkets, + tracking, + toast, + ]); + + const authorizeAndDeleverage = useCallback(async () => { + if (!account) { + toast.info('No account connected', 'Please connect your wallet.'); + return; + } + + try { + const initialStep = usePermit2Setting ? 'authorize_bundler_sig' : 'authorize_bundler_tx'; + tracking.start( + getStepsForFlow(usePermit2Setting), + { + title: 'Deleverage', + description: `${market.collateralAsset.symbol} unwound into ${market.loanAsset.symbol}`, + tokenSymbol: market.collateralAsset.symbol, + amount: withdrawCollateralAmount, + marketId: market.uniqueKey, + }, + initialStep, + ); + + await executeDeleverage(); + } catch (error: unknown) { + console.error('Error in authorizeAndDeleverage:', error); + tracking.fail(); + if (error instanceof Error) { + if (error.message.includes('User rejected')) { + toast.error('Transaction rejected', 'Transaction rejected by user'); + } else { + toast.error('Error', 'Failed to process deleverage transaction'); + } + } else { + toast.error('Error', 'An unexpected error occurred'); + } + } + }, [account, usePermit2Setting, tracking, getStepsForFlow, market, withdrawCollateralAmount, executeDeleverage, toast]); + + const isLoading = deleveragePending || isAuthorizingBundler; + + return { + transaction: tracking.transaction, + dismiss: tracking.dismiss, + currentStep: tracking.currentStep as DeleverageStepType | null, + deleveragePending, + isLoading, + isBundlerAuthorized, + authorizeAndDeleverage, + }; +} diff --git a/src/hooks/useLeverageQuote.ts b/src/hooks/useLeverageQuote.ts new file mode 100644 index 00000000..f60c6693 --- /dev/null +++ b/src/hooks/useLeverageQuote.ts @@ -0,0 +1,119 @@ +import { useMemo } from 'react'; +import { useReadContract } from 'wagmi'; +import { erc4626Abi } from '@/abis/erc4626'; +import { wstEthAbi } from '@/abis/wsteth'; +import { computeFlashCollateralAmount } from './leverage/math'; +import type { LeverageRoute } from './leverage/types'; + +type UseLeverageQuoteParams = { + chainId: number; + route: LeverageRoute | null; + userCollateralAmount: bigint; + multiplierBps: bigint; +}; + +export type LeverageQuote = { + flashCollateralAmount: bigint; + flashLoanAmount: bigint; + totalAddedCollateral: bigint; + isLoading: boolean; + error: string | null; +}; + +/** + * Converts user leverage intent into deterministic route amounts. + * + * - `flashCollateralAmount`: extra collateral target sourced via the flash leg + * - `flashLoanAmount`: debt token flash amount needed to mint that extra collateral + * + * First-principles route semantics: + * - ERC4626: multiplier applies to collateral shares, then `previewMint` quotes underlying debt assets. + * - stETH/wstETH: multiplier applies to stETH-equivalent collateral exposure, then we map debt assets back to wstETH shares. + */ +export function useLeverageQuote({ chainId, route, userCollateralAmount, multiplierBps }: UseLeverageQuoteParams): LeverageQuote { + const targetFlashCollateralAmount = useMemo( + () => computeFlashCollateralAmount(userCollateralAmount, multiplierBps), + [userCollateralAmount, multiplierBps], + ); + + const { + data: erc4626PreviewMint, + isLoading: isLoadingErc4626, + error: erc4626Error, + } = useReadContract({ + address: route?.kind === 'erc4626' ? route.collateralVault : undefined, + abi: erc4626Abi, + functionName: 'previewMint', + args: [targetFlashCollateralAmount], + chainId, + query: { + enabled: route?.kind === 'erc4626' && targetFlashCollateralAmount > 0n, + }, + }); + + const { + data: userStEthAmount, + isLoading: isLoadingUserStEthAmount, + error: userStEthAmountError, + } = useReadContract({ + address: route?.kind === 'steth' ? route.collateralToken : undefined, + abi: wstEthAbi, + functionName: 'getStETHByWstETH', + args: [userCollateralAmount], + chainId, + query: { + enabled: route?.kind === 'steth' && userCollateralAmount > 0n, + }, + }); + + const stEthFlashLoanAmount = useMemo(() => { + if (route?.kind !== 'steth') return 0n; + const stEthCollateralAmount = (userStEthAmount as bigint | undefined) ?? 0n; + return computeFlashCollateralAmount(stEthCollateralAmount, multiplierBps); + }, [route, userStEthAmount, multiplierBps]); + + const { + data: wstEthByStEth, + isLoading: isLoadingWstEthByStEth, + error: wstEthByStEthError, + } = useReadContract({ + address: route?.kind === 'steth' ? route.collateralToken : undefined, + abi: wstEthAbi, + functionName: 'getWstETHByStETH', + args: [stEthFlashLoanAmount], + chainId, + query: { + enabled: route?.kind === 'steth' && stEthFlashLoanAmount > 0n, + }, + }); + + const flashCollateralAmount = useMemo(() => { + if (!route) return 0n; + if (route.kind === 'erc4626') return targetFlashCollateralAmount; + return (wstEthByStEth as bigint | undefined) ?? 0n; + }, [route, targetFlashCollateralAmount, wstEthByStEth]); + + const flashLoanAmount = useMemo(() => { + if (!route) return 0n; + if (route.kind === 'erc4626') return (erc4626PreviewMint as bigint | undefined) ?? 0n; + return stEthFlashLoanAmount; + }, [route, erc4626PreviewMint, stEthFlashLoanAmount]); + + const error = useMemo(() => { + if (!route) return null; + const e = route.kind === 'erc4626' ? erc4626Error : (userStEthAmountError ?? wstEthByStEthError); + if (!e) return null; + return e instanceof Error ? e.message : 'Failed to quote leverage route'; + }, [route, erc4626Error, userStEthAmountError, wstEthByStEthError]); + + const isLoading = + route?.kind === 'erc4626' ? isLoadingErc4626 : route?.kind === 'steth' ? isLoadingUserStEthAmount || isLoadingWstEthByStEth : false; + + return { + flashCollateralAmount, + flashLoanAmount, + totalAddedCollateral: userCollateralAmount + flashCollateralAmount, + isLoading, + error, + }; +} diff --git a/src/hooks/useLeverageSupport.ts b/src/hooks/useLeverageSupport.ts new file mode 100644 index 00000000..c1013a14 --- /dev/null +++ b/src/hooks/useLeverageSupport.ts @@ -0,0 +1,151 @@ +import { useMemo } from 'react'; +import { type Address, isAddressEqual, zeroAddress } from 'viem'; +import { useReadContracts } from 'wagmi'; +import { erc4626Abi } from '@/abis/erc4626'; +import { getCanonicalStEthAddress, getCanonicalWethAddress, getCanonicalWstEthAddress } from '@/types/token'; +import { SupportedNetworks } from '@/utils/networks'; +import type { Market } from '@/utils/types'; +import type { LeverageSupport, StEthLeverageRoute, Erc4626LeverageRoute } from './leverage/types'; + +type UseLeverageSupportParams = { + market: Market; +}; + +/** + * Detects whether a market can be levered/delevered with deterministic V2 routes. + * + * Supported routes: + * - ERC4626 collateral where `vault.asset() == loanToken` + * - wstETH collateral paired with either: + * - stETH loan (leverage + deleverage), or + * - mainnet WETH loan (leverage only via `unwrapNative -> stakeEth -> wrapStEth`) + */ +export function useLeverageSupport({ market }: UseLeverageSupportParams): LeverageSupport { + const chainId = market.morphoBlue.chain.id; + const loanToken = market.loanAsset.address as Address; + const collateralToken = market.collateralAsset.address as Address; + const canonicalWstEth = getCanonicalWstEthAddress(chainId); + const canonicalStEth = getCanonicalStEthAddress(chainId); + const canonicalWeth = getCanonicalWethAddress(chainId); + const supportsStEthBundlerRoute = chainId === SupportedNetworks.Mainnet; + + const { data, isLoading, isRefetching } = useReadContracts({ + contracts: [ + { + address: collateralToken, + abi: erc4626Abi, + functionName: 'asset', + args: [], + chainId, + }, + ], + allowFailure: true, + query: { + enabled: !!collateralToken && collateralToken !== zeroAddress, + }, + }); + + return useMemo((): LeverageSupport => { + const erc4626Asset = data?.[0]?.result as Address | undefined; + const hasErc4626Asset = !!erc4626Asset && erc4626Asset !== zeroAddress; + const isCanonicalWstEthCollateral = !!canonicalWstEth && isAddressEqual(collateralToken, canonicalWstEth); + + if (hasErc4626Asset && isAddressEqual(erc4626Asset, loanToken)) { + const route: Erc4626LeverageRoute = { + kind: 'erc4626', + collateralVault: collateralToken, + underlyingLoanToken: loanToken, + }; + + return { + isSupported: true, + supportsLeverage: true, + supportsDeleverage: true, + isLoading: isLoading || isRefetching, + route, + reason: null, + }; + } + + if (isCanonicalWstEthCollateral) { + // `stakeEth` / `wrapStEth` / `unwrapStEth` only exist on the mainnet BundlerV2 variant. + // ChainAgnosticBundlerV2 does not expose these selectors, so we block route discovery up-front. + if (!supportsStEthBundlerRoute) { + return { + isSupported: false, + supportsLeverage: false, + supportsDeleverage: false, + isLoading: isLoading || isRefetching, + route: null, + reason: 'stETH leverage routes are currently supported on mainnet Bundler V2 only.', + }; + } + + if (!canonicalStEth || canonicalStEth === zeroAddress) { + return { + isSupported: false, + supportsLeverage: false, + supportsDeleverage: false, + isLoading: isLoading || isRefetching, + route: null, + reason: 'stETH route addresses are not configured for this chain.', + }; + } + + if (canonicalStEth && isAddressEqual(canonicalStEth, loanToken)) { + const route: StEthLeverageRoute = { + kind: 'steth', + collateralToken, + stEthToken: canonicalStEth, + loanMode: 'steth', + }; + + return { + isSupported: true, + supportsLeverage: true, + supportsDeleverage: true, + isLoading: isLoading || isRefetching, + route, + reason: null, + }; + } + + if (canonicalWeth && isAddressEqual(loanToken, canonicalWeth)) { + const route: StEthLeverageRoute = { + kind: 'steth', + collateralToken, + stEthToken: canonicalStEth, + loanMode: 'mainnet-weth-steth-wsteth', + }; + + return { + isSupported: true, + supportsLeverage: true, + supportsDeleverage: false, + isLoading: isLoading || isRefetching, + route, + reason: 'This market supports leverage only. Deleverage requires a direct stETH-denominated debt token.', + }; + } + } + + return { + isSupported: false, + supportsLeverage: false, + supportsDeleverage: false, + isLoading: isLoading || isRefetching, + route: null, + reason: 'Leverage is only available for ERC4626-underlying routes or stETH/wstETH routes on Bundler V2.', + }; + }, [ + collateralToken, + loanToken, + canonicalStEth, + canonicalWeth, + canonicalWstEth, + data, + isLoading, + isRefetching, + supportsStEthBundlerRoute, + ]); +} diff --git a/src/hooks/useLeverageTransaction.ts b/src/hooks/useLeverageTransaction.ts new file mode 100644 index 00000000..9f8c8953 --- /dev/null +++ b/src/hooks/useLeverageTransaction.ts @@ -0,0 +1,551 @@ +import { useCallback } from 'react'; +import { type Address, encodeAbiParameters, encodeFunctionData, maxUint256, zeroAddress } from 'viem'; +import { useConnection } from 'wagmi'; +import morphoBundlerAbi from '@/abis/bundlerV2'; +import { useERC20Approval } from '@/hooks/useERC20Approval'; +import { useBundlerAuthorizationStep } from '@/hooks/useBundlerAuthorizationStep'; +import { usePermit2 } from '@/hooks/usePermit2'; +import { useStyledToast } from '@/hooks/useStyledToast'; +import { useTransactionWithToast } from '@/hooks/useTransactionWithToast'; +import { useTransactionTracking } from '@/hooks/useTransactionTracking'; +import { useUserMarketsCache } from '@/stores/useUserMarketsCache'; +import { useAppSettings } from '@/stores/useAppSettings'; +import { formatBalance } from '@/utils/balance'; +import { getBundlerV2, MONARCH_TX_IDENTIFIER } from '@/utils/morpho'; +import { getNativeTokenSymbol } from '@/utils/networks'; +import type { Market } from '@/utils/types'; +import { computeBorrowSharesWithBuffer, withSlippageFloor } from './leverage/math'; +import type { LeverageRoute } from './leverage/types'; + +export type LeverageStepType = + | 'approve_permit2' + | 'authorize_bundler_sig' + | 'sign_permit' + | 'authorize_bundler_tx' + | 'approve_token' + | 'execute'; + +type UseLeverageTransactionProps = { + market: Market; + route: LeverageRoute; + collateralAmount: bigint; + collateralAmountInCollateralToken: bigint; + flashCollateralAmount: bigint; + flashLoanAmount: bigint; + useEth: boolean; + useLoanAssetAsInput: boolean; + onSuccess?: () => void; +}; + +/** + * Executes a Bundler V2 leverage transaction in a single multicall. + * + * Important design choice: + * We flash-loan the *loan token* (not collateral), convert it to collateral, + * supply all collateral, then borrow the same loan amount back to the bundler + * so Morpho can pull repayment after callback. This avoids intermediate dust + * accounting and keeps flows deterministic for V2 supported routes. + */ +export function useLeverageTransaction({ + market, + route, + collateralAmount, + collateralAmountInCollateralToken, + flashCollateralAmount, + flashLoanAmount, + useEth, + useLoanAssetAsInput, + onSuccess, +}: UseLeverageTransactionProps) { + const { usePermit2: usePermit2Setting } = useAppSettings(); + const tracking = useTransactionTracking('leverage'); + const { address: account, chainId } = useConnection(); + const toast = useStyledToast(); + const bundlerAddress = getBundlerV2(market.morphoBlue.chain.id); + const { batchAddUserMarkets } = useUserMarketsCache(account); + const supportsNativeCollateralInput = route.kind === 'steth' && route.loanMode === 'mainnet-weth-steth-wsteth'; + const isErc4626LoanAssetInput = route.kind === 'erc4626' && useLoanAssetAsInput; + const inputTokenAddress = isErc4626LoanAssetInput ? (market.loanAsset.address as Address) : (market.collateralAsset.address as Address); + const inputTokenSymbol = isErc4626LoanAssetInput ? market.loanAsset.symbol : market.collateralAsset.symbol; + const inputTokenDecimals = isErc4626LoanAssetInput ? market.loanAsset.decimals : market.collateralAsset.decimals; + const inputTokenAmountForTransfer = useEth ? 0n : isErc4626LoanAssetInput ? collateralAmount : collateralAmountInCollateralToken; + const inputDisplaySymbol = useEth ? getNativeTokenSymbol(market.morphoBlue.chain.id) : inputTokenSymbol; + const inputDisplayDecimals = useEth ? 18 : inputTokenDecimals; + + const { isBundlerAuthorized, isAuthorizingBundler, ensureBundlerAuthorization, refetchIsBundlerAuthorized } = useBundlerAuthorizationStep( + { + chainId: market.morphoBlue.chain.id, + bundlerAddress: bundlerAddress as Address, + }, + ); + + const { + authorizePermit2, + permit2Authorized, + isLoading: isLoadingPermit2, + signForBundlers, + } = usePermit2({ + user: account as `0x${string}`, + spender: bundlerAddress, + token: inputTokenAddress as `0x${string}`, + refetchInterval: 10_000, + chainId: market.morphoBlue.chain.id, + tokenSymbol: inputTokenSymbol, + amount: inputTokenAmountForTransfer, + }); + + const { isApproved, approve, isApproving } = useERC20Approval({ + token: inputTokenAddress, + spender: bundlerAddress, + amount: inputTokenAmountForTransfer, + tokenSymbol: inputTokenSymbol, + chainId: market.morphoBlue.chain.id, + }); + + const { isConfirming: leveragePending, sendTransactionAsync } = useTransactionWithToast({ + toastId: 'leverage', + pendingText: `Leveraging ${formatBalance(collateralAmount, inputDisplayDecimals)} ${inputDisplaySymbol}`, + successText: 'Leverage Executed', + errorText: 'Failed to execute leverage', + chainId, + pendingDescription: `Executing leverage on market ${market.uniqueKey.slice(2, 8)}...`, + successDescription: 'Position levered successfully', + onSuccess: () => { + void refetchIsBundlerAuthorized(); + if (onSuccess) void onSuccess(); + }, + }); + + const getStepsForFlow = useCallback( + (isEth: boolean, isPermit2: boolean) => { + if (isEth) { + return [ + { + id: 'authorize_bundler_sig', + title: 'Authorize Morpho Bundler', + description: 'Sign a message to authorize the bundler for Morpho actions.', + }, + { + id: 'execute', + title: 'Confirm Leverage', + description: 'Confirm the leverage transaction in your wallet.', + }, + ]; + } + + if (isPermit2) { + return [ + { + id: 'approve_permit2', + title: 'Authorize Permit2', + description: "One-time approval so future leverage transactions don't need token approvals.", + }, + { + id: 'authorize_bundler_sig', + title: 'Authorize Morpho Bundler', + description: 'Sign a message to authorize the bundler for Morpho actions.', + }, + { + id: 'sign_permit', + title: 'Sign Token Permit', + description: 'Sign Permit2 transfer authorization for collateral transfer.', + }, + { + id: 'execute', + title: 'Confirm Leverage', + description: 'Confirm the leverage transaction in your wallet.', + }, + ]; + } + + return [ + { + id: 'authorize_bundler_tx', + title: 'Authorize Morpho Bundler', + description: 'Submit one transaction authorizing bundler actions on your Morpho position.', + }, + { + id: 'approve_token', + title: `Approve ${inputTokenSymbol}`, + description: `Approve ${inputTokenSymbol} transfer for the leverage flow.`, + }, + { + id: 'execute', + title: 'Confirm Leverage', + description: 'Confirm the leverage transaction in your wallet.', + }, + ]; + }, + [inputTokenSymbol], + ); + + const executeLeverage = useCallback(async () => { + if (!account) { + toast.info('No account connected', 'Please connect your wallet.'); + return; + } + + if (useEth && !supportsNativeCollateralInput) { + toast.info('Unsupported route', 'Native ETH collateral input is only available on the mainnet WETH -> stETH -> wstETH route.'); + return; + } + + if (collateralAmount <= 0n || flashLoanAmount <= 0n || flashCollateralAmount <= 0n) { + toast.info('Invalid leverage inputs', 'Set collateral and multiplier above 1x before submitting.'); + return; + } + + try { + const txs: `0x${string}`[] = []; + + if (useEth) { + tracking.update('authorize_bundler_sig'); + const { authorizationTxData } = await ensureBundlerAuthorization({ mode: 'signature' }); + if (authorizationTxData) { + txs.push(authorizationTxData); + await new Promise((resolve) => setTimeout(resolve, 800)); + } + + // WHY: user collateral comes in native ETH for this specific route, so we convert it to wstETH + // before running the flashloan callback that adds the looped collateral leg. + txs.push( + encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'stakeEth', + args: [collateralAmount, 1n, zeroAddress], + }), + ); + txs.push( + encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'wrapStEth', + args: [maxUint256], + }), + ); + } else if (usePermit2Setting) { + tracking.update('approve_permit2'); + if (!permit2Authorized) { + await authorizePermit2(); + await new Promise((resolve) => setTimeout(resolve, 800)); + } + + tracking.update('authorize_bundler_sig'); + const { authorizationTxData } = await ensureBundlerAuthorization({ mode: 'signature' }); + if (authorizationTxData) { + txs.push(authorizationTxData); + await new Promise((resolve) => setTimeout(resolve, 800)); + } + + tracking.update('sign_permit'); + const { sigs, permitSingle } = await signForBundlers(); + + txs.push( + encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'approve2', + args: [permitSingle, sigs, false], + }), + ); + } else { + tracking.update('authorize_bundler_tx'); + const { authorized } = await ensureBundlerAuthorization({ mode: 'transaction' }); + if (!authorized) { + throw new Error('Failed to authorize Bundler via transaction.'); + } + + tracking.update('approve_token'); + if (!isApproved) { + await approve(); + await new Promise((resolve) => setTimeout(resolve, 900)); + } + } + + // User input transfer is done before flashloan so callback can supply all at once. + if (!useEth && inputTokenAmountForTransfer > 0n) { + txs.push( + encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: usePermit2Setting ? 'transferFrom2' : 'erc20TransferFrom', + args: [inputTokenAddress, inputTokenAmountForTransfer], + }), + ); + } + + if (isErc4626LoanAssetInput) { + // WHY: allow users holding loan-token underlying to start leverage directly. + // We mint collateral shares from their upfront underlying before the flash loop leg. + txs.push( + encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'erc4626Deposit', + args: [ + route.collateralVault, + collateralAmount, + withSlippageFloor(collateralAmountInCollateralToken), + bundlerAddress as Address, + ], + }), + ); + } + + const callbackTxs: `0x${string}`[] = []; + + if (route.kind === 'erc4626') { + // Spend the full flash-loaned underlying to mint collateral shares. + callbackTxs.push( + encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'erc4626Deposit', + args: [route.collateralVault, flashLoanAmount, withSlippageFloor(flashCollateralAmount), bundlerAddress as Address], + }), + ); + } else if (route.loanMode === 'steth') { + callbackTxs.push( + encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'wrapStEth', + args: [flashLoanAmount], + }), + ); + } else { + // Dedicated mainnet WETH route: unwrap WETH -> stake ETH -> wrap stETH into wstETH collateral. + callbackTxs.push( + encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'unwrapNative', + args: [flashLoanAmount], + }), + ); + callbackTxs.push( + encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'stakeEth', + args: [flashLoanAmount, 1n, zeroAddress], + }), + ); + callbackTxs.push( + encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'wrapStEth', + args: [maxUint256], + }), + ); + } + + callbackTxs.push( + encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'morphoSupplyCollateral', + args: [ + { + loanToken: market.loanAsset.address as Address, + collateralToken: market.collateralAsset.address as Address, + oracle: market.oracleAddress as Address, + irm: market.irmAddress as Address, + lltv: BigInt(market.lltv), + }, + maxUint256, + account as Address, + '0x', + ], + }), + ); + + const maxBorrowShares = computeBorrowSharesWithBuffer({ + borrowAssets: flashLoanAmount, + totalBorrowAssets: BigInt(market.state.borrowAssets), + totalBorrowShares: BigInt(market.state.borrowShares), + }); + + callbackTxs.push( + encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'morphoBorrow', + args: [ + { + loanToken: market.loanAsset.address as Address, + collateralToken: market.collateralAsset.address as Address, + oracle: market.oracleAddress as Address, + irm: market.irmAddress as Address, + lltv: BigInt(market.lltv), + }, + flashLoanAmount, + 0n, + maxBorrowShares, + bundlerAddress as Address, + ], + }), + ); + + // Bundler callback decodes flash data as abi.decode(data, (bytes[])). + const flashLoanCallbackData = encodeAbiParameters([{ type: 'bytes[]' }], [callbackTxs]); + txs.push( + encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'morphoFlashLoan', + args: [market.loanAsset.address as Address, flashLoanAmount, flashLoanCallbackData], + }), + ); + + tracking.update('execute'); + + await new Promise((resolve) => setTimeout(resolve, 800)); + + await sendTransactionAsync({ + account, + to: bundlerAddress, + data: (encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'multicall', + args: [txs], + }) + MONARCH_TX_IDENTIFIER) as `0x${string}`, + value: useEth ? collateralAmount : 0n, + }); + + batchAddUserMarkets([ + { + marketUniqueKey: market.uniqueKey, + chainId: market.morphoBlue.chain.id, + }, + ]); + + tracking.complete(); + } catch (error: unknown) { + tracking.fail(); + console.error('Error during leverage execution:', error); + if (error instanceof Error && !error.message.toLowerCase().includes('rejected')) { + toast.error('Leverage Failed', 'An unexpected error occurred during leverage.'); + } + } + }, [ + account, + market, + route, + collateralAmount, + collateralAmountInCollateralToken, + inputTokenAmountForTransfer, + inputTokenAddress, + isErc4626LoanAssetInput, + flashCollateralAmount, + flashLoanAmount, + useEth, + useLoanAssetAsInput, + supportsNativeCollateralInput, + usePermit2Setting, + permit2Authorized, + authorizePermit2, + ensureBundlerAuthorization, + signForBundlers, + isApproved, + approve, + bundlerAddress, + sendTransactionAsync, + batchAddUserMarkets, + tracking, + toast, + ]); + + const approveAndLeverage = useCallback(async () => { + if (!account) { + toast.info('No account connected', 'Please connect your wallet.'); + return; + } + + try { + const initialStep = useEth ? 'authorize_bundler_sig' : usePermit2Setting ? 'approve_permit2' : 'authorize_bundler_tx'; + tracking.start( + getStepsForFlow(useEth, usePermit2Setting), + { + title: 'Leverage', + description: `${market.collateralAsset.symbol} leveraged using ${market.loanAsset.symbol} debt`, + tokenSymbol: inputDisplaySymbol, + amount: collateralAmount, + marketId: market.uniqueKey, + }, + initialStep, + ); + + await executeLeverage(); + } catch (error: unknown) { + console.error('Error in approveAndLeverage:', error); + tracking.fail(); + if (error instanceof Error) { + if (error.message.includes('User rejected')) { + toast.error('Transaction rejected', 'Transaction rejected by user'); + } else { + toast.error('Error', 'Failed to process leverage transaction'); + } + } else { + toast.error('Error', 'An unexpected error occurred'); + } + } + }, [account, useEth, usePermit2Setting, tracking, getStepsForFlow, market, inputDisplaySymbol, collateralAmount, executeLeverage, toast]); + + const signAndLeverage = useCallback(async () => { + if (!account) { + toast.info('No account connected', 'Please connect your wallet.'); + return; + } + + if (useEth) { + await approveAndLeverage(); + return; + } + + try { + tracking.start( + getStepsForFlow(false, usePermit2Setting), + { + title: 'Leverage', + description: `${market.collateralAsset.symbol} leveraged using ${market.loanAsset.symbol} debt`, + tokenSymbol: inputDisplaySymbol, + amount: collateralAmount, + marketId: market.uniqueKey, + }, + 'sign_permit', + ); + + await executeLeverage(); + } catch (error: unknown) { + console.error('Error in signAndLeverage:', error); + tracking.fail(); + if (error instanceof Error) { + if (error.message.includes('User rejected')) { + toast.error('Transaction rejected', 'Transaction rejected by user'); + } else { + toast.error('Transaction Error', 'Failed to process leverage transaction'); + } + } else { + toast.error('Transaction Error', 'An unexpected error occurred'); + } + } + }, [ + account, + useEth, + approveAndLeverage, + tracking, + getStepsForFlow, + usePermit2Setting, + market, + inputDisplaySymbol, + collateralAmount, + executeLeverage, + toast, + ]); + + const isLoading = leveragePending || isLoadingPermit2 || isApproving || isAuthorizingBundler; + + return { + transaction: tracking.transaction, + dismiss: tracking.dismiss, + currentStep: tracking.currentStep as LeverageStepType | null, + isLoadingPermit2, + isApproved, + permit2Authorized, + leveragePending, + isLoading, + isBundlerAuthorized, + approveAndLeverage, + signAndLeverage, + }; +} diff --git a/src/modals/borrow/components/borrow-position-risk-card.tsx b/src/modals/borrow/components/borrow-position-risk-card.tsx index 244f9d99..ab1bf3f3 100644 --- a/src/modals/borrow/components/borrow-position-risk-card.tsx +++ b/src/modals/borrow/components/borrow-position-risk-card.tsx @@ -1,7 +1,9 @@ -import { useMemo } from 'react'; +import { type ReactNode, useMemo } from 'react'; import { RefetchIcon } from '@/components/ui/refetch-icon'; +import { Tooltip } from '@/components/ui/tooltip'; import { TokenIcon } from '@/components/shared/token-icon'; import { formatBalance } from '@/utils/balance'; +import { formatCompactTokenAmount, formatFullTokenAmount } from '@/utils/token-amount-format'; import type { Market } from '@/utils/types'; import { formatLtvPercent, getLTVColor, getLTVProgressColor } from './helpers'; @@ -9,32 +11,57 @@ type BorrowPositionRiskCardProps = { market: Market; currentCollateral: bigint; currentBorrow: bigint; + projectedCollateral?: bigint; + projectedBorrow?: bigint; currentLtv: bigint; projectedLtv: bigint; lltv: bigint; onRefresh?: () => void; isRefreshing?: boolean; - borrowLabel?: string; hasChanges?: boolean; + useCompactAmountDisplay?: boolean; }; +function renderAmountValue(value: bigint, decimals: number, useCompactAmountDisplay: boolean): ReactNode { + if (!useCompactAmountDisplay) { + return formatBalance(value, decimals); + } + + const compactValue = formatCompactTokenAmount(value, decimals); + const fullValue = formatFullTokenAmount(value, decimals); + + return ( + {fullValue}}> + {compactValue} + + ); +} + export function BorrowPositionRiskCard({ market, currentCollateral, currentBorrow, + projectedCollateral, + projectedBorrow, currentLtv, projectedLtv, lltv, onRefresh, isRefreshing = false, - borrowLabel = 'Borrow', hasChanges = false, + useCompactAmountDisplay = false, }: BorrowPositionRiskCardProps): JSX.Element { const projectedLtvWidth = useMemo(() => { if (lltv <= 0n) return 0; return Math.min(100, (Number(projectedLtv) / Number(lltv)) * 100); }, [projectedLtv, lltv]); + const projectedCollateralValue = projectedCollateral ?? currentCollateral; + const projectedBorrowValue = projectedBorrow ?? currentBorrow; + + const showProjectedCollateral = hasChanges && projectedCollateralValue !== currentCollateral; + const showProjectedBorrow = hasChanges && projectedBorrowValue !== currentBorrow; + return (
@@ -49,12 +76,24 @@ export function BorrowPositionRiskCard({ height={16} />

- {formatBalance(currentCollateral, market.collateralAsset.decimals)} {market.collateralAsset.symbol} + {showProjectedCollateral ? ( + <> + + {renderAmountValue(currentCollateral, market.collateralAsset.decimals, useCompactAmountDisplay)} + + + {renderAmountValue(projectedCollateralValue, market.collateralAsset.decimals, useCompactAmountDisplay)} + + + ) : ( + renderAmountValue(projectedCollateralValue, market.collateralAsset.decimals, useCompactAmountDisplay) + )}{' '} + {market.collateralAsset.symbol}

-

{borrowLabel}

+

Debt

- {formatBalance(currentBorrow, market.loanAsset.decimals)} {market.loanAsset.symbol} + {showProjectedBorrow ? ( + <> + + {renderAmountValue(currentBorrow, market.loanAsset.decimals, useCompactAmountDisplay)} + + + {renderAmountValue(projectedBorrowValue, market.loanAsset.decimals, useCompactAmountDisplay)} + + + ) : ( + renderAmountValue(projectedBorrowValue, market.loanAsset.decimals, useCompactAmountDisplay) + )}{' '} + {market.loanAsset.symbol}

diff --git a/src/modals/borrow/components/withdraw-collateral-and-repay.tsx b/src/modals/borrow/components/withdraw-collateral-and-repay.tsx index 09118320..1da26c29 100644 --- a/src/modals/borrow/components/withdraw-collateral-and-repay.tsx +++ b/src/modals/borrow/components/withdraw-collateral-and-repay.tsx @@ -250,7 +250,6 @@ export function WithdrawCollateralAndRepay({ lltv={lltv} onRefresh={onSuccess ? handleRefresh : undefined} isRefreshing={isRefreshing} - borrowLabel="Outstanding Debt" hasChanges={hasChanges} /> diff --git a/src/modals/leverage/components/add-collateral-and-leverage.tsx b/src/modals/leverage/components/add-collateral-and-leverage.tsx new file mode 100644 index 00000000..ed3fb7da --- /dev/null +++ b/src/modals/leverage/components/add-collateral-and-leverage.tsx @@ -0,0 +1,557 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { erc20Abi } from 'viem'; +import { useBalance, useConnection, useReadContract } from 'wagmi'; +import Input from '@/components/Input/Input'; +import { ExecuteTransactionButton } from '@/components/ui/ExecuteTransactionButton'; +import { Button } from '@/components/ui/button'; +import { IconSwitch } from '@/components/ui/icon-switch'; +import { Tooltip } from '@/components/ui/tooltip'; +import { LTVWarning } from '@/components/shared/ltv-warning'; +import { TokenIcon } from '@/components/shared/token-icon'; +import { erc4626Abi } from '@/abis/erc4626'; +import { wstEthAbi } from '@/abis/wsteth'; +import { useLeverageQuote } from '@/hooks/useLeverageQuote'; +import { useLeverageTransaction } from '@/hooks/useLeverageTransaction'; +import { useAppSettings } from '@/stores/useAppSettings'; +import { formatBalance } from '@/utils/balance'; +import { getNativeTokenSymbol } from '@/utils/networks'; +import { formatCompactTokenAmount, formatFullTokenAmount } from '@/utils/token-amount-format'; +import type { Market, MarketPosition } from '@/utils/types'; +import type { LeverageSupport } from '@/hooks/leverage/types'; +import { computeLtv, formatLtvPercent, getLTVColor } from '@/modals/borrow/components/helpers'; +import { BorrowPositionRiskCard } from '@/modals/borrow/components/borrow-position-risk-card'; +import { clampMultiplierBps, formatMultiplierBps, parseMultiplierToBps } from '@/hooks/leverage/math'; +import { LEVERAGE_DEFAULT_MULTIPLIER_BPS } from '@/hooks/leverage/types'; + +type AddCollateralAndLeverageProps = { + market: Market; + support: LeverageSupport; + currentPosition: MarketPosition | null; + collateralTokenBalance: bigint | undefined; + oraclePrice: bigint; + onSuccess?: () => void; + isRefreshing?: boolean; + showDeleverageManualRepayNotice?: boolean; +}; + +const MULTIPLIER_INPUT_REGEX = /^\d*\.?\d*$/; +const STETH_ETH_DELEVERAGE_NOTICE_KEY = 'hasReadStEthEthDeleverageNotice'; +const STETH_DISPLAY_SYMBOL = 'stETH'; + +export function AddCollateralAndLeverage({ + market, + support, + currentPosition, + collateralTokenBalance, + oraclePrice, + onSuccess, + isRefreshing = false, + showDeleverageManualRepayNotice = false, +}: AddCollateralAndLeverageProps): JSX.Element { + const route = support.route; + const { address: account } = useConnection(); + const { usePermit2: usePermit2Setting } = useAppSettings(); + + const [collateralAmount, setCollateralAmount] = useState(0n); + const [collateralInputError, setCollateralInputError] = useState(null); + const [multiplierInput, setMultiplierInput] = useState(formatMultiplierBps(LEVERAGE_DEFAULT_MULTIPLIER_BPS)); + const [useEth, setUseEth] = useState(false); + const [useLoanAssetInput, setUseLoanAssetInput] = useState(false); + const [showRouteNotice, setShowRouteNotice] = useState(false); + + const multiplierBps = useMemo(() => clampMultiplierBps(parseMultiplierToBps(multiplierInput)), [multiplierInput]); + + const isMainnetEthStEthRoute = route?.kind === 'steth' && route.loanMode === 'mainnet-weth-steth-wsteth'; + const isErc4626Route = route?.kind === 'erc4626'; + + const { data: nativeBalance } = useBalance({ + address: account as `0x${string}` | undefined, + chainId: market.morphoBlue.chain.id, + query: { + enabled: !!account && isMainnetEthStEthRoute, + }, + }); + + const { data: loanTokenBalance } = useReadContract({ + address: market.loanAsset.address as `0x${string}`, + args: [account as `0x${string}`], + functionName: 'balanceOf', + abi: erc20Abi, + chainId: market.morphoBlue.chain.id, + query: { + enabled: !!account && isErc4626Route, + }, + }); + + const { + data: convertedWstEthAmount, + isLoading: isLoadingEthToWstEthConversion, + error: ethToWstEthConversionError, + } = useReadContract({ + address: route?.kind === 'steth' ? route.collateralToken : undefined, + abi: wstEthAbi, + functionName: 'getWstETHByStETH', + args: [collateralAmount], + chainId: market.morphoBlue.chain.id, + query: { + enabled: isMainnetEthStEthRoute && useEth && collateralAmount > 0n, + }, + }); + + useEffect(() => { + // ETH and wstETH inputs have different units. Reset the amount to avoid accidental cross-unit reuse. + setCollateralAmount(0n); + setCollateralInputError(null); + }, [useEth]); + + useEffect(() => { + // Underlying and collateral shares use different units. Reset amount when switching input source. + setCollateralAmount(0n); + setCollateralInputError(null); + }, [useLoanAssetInput]); + + useEffect(() => { + if (isMainnetEthStEthRoute) return; + setUseEth(false); + }, [isMainnetEthStEthRoute]); + + useEffect(() => { + if (isErc4626Route) return; + setUseLoanAssetInput(false); + }, [isErc4626Route]); + + useEffect(() => { + if (!showDeleverageManualRepayNotice) { + setShowRouteNotice(false); + return; + } + + const hasReadNotice = localStorage.getItem(STETH_ETH_DELEVERAGE_NOTICE_KEY) === 'true'; + setShowRouteNotice(!hasReadNotice); + }, [showDeleverageManualRepayNotice]); + + const handleDismissRouteNotice = useCallback(() => { + localStorage.setItem(STETH_ETH_DELEVERAGE_NOTICE_KEY, 'true'); + setShowRouteNotice(false); + }, []); + + const collateralAmountInCollateralToken = useMemo(() => { + // WHY: leverage math is always computed in the market collateral token units. + // For ETH input mode, we first map user ETH -> wstETH before applying multiplier, + // so target collateral remains deterministic with the actual supplied token. + if (!useEth) return collateralAmount; + return (convertedWstEthAmount as bigint | undefined) ?? 0n; + }, [useEth, collateralAmount, convertedWstEthAmount]); + + const { + data: previewCollateralSharesFromUnderlying, + isLoading: isLoadingUnderlyingToCollateralConversion, + error: underlyingToCollateralConversionError, + } = useReadContract({ + // WHY: for ERC4626 "start with loan asset" mode, user input is underlying assets. + // We convert to collateral shares first so multiplier/flash math stays in collateral units. + address: route?.kind === 'erc4626' ? route.collateralVault : undefined, + abi: erc4626Abi, + functionName: 'previewDeposit', + args: [collateralAmount], + chainId: market.morphoBlue.chain.id, + query: { + enabled: isErc4626Route && useLoanAssetInput && collateralAmount > 0n, + }, + }); + + const collateralAmountForLeverageQuote = useMemo(() => { + if (useEth) return collateralAmountInCollateralToken; + if (useLoanAssetInput) return (previewCollateralSharesFromUnderlying as bigint | undefined) ?? 0n; + return collateralAmount; + }, [useEth, useLoanAssetInput, collateralAmountInCollateralToken, previewCollateralSharesFromUnderlying, collateralAmount]); + + const conversionErrorMessage = useMemo(() => { + if (useEth && ethToWstEthConversionError) { + return ethToWstEthConversionError instanceof Error ? ethToWstEthConversionError.message : 'Failed to quote ETH to wstETH conversion.'; + } + if (useLoanAssetInput && underlyingToCollateralConversionError) { + return underlyingToCollateralConversionError instanceof Error + ? underlyingToCollateralConversionError.message + : 'Failed to quote loan asset to collateral conversion.'; + } + return null; + }, [useEth, ethToWstEthConversionError, useLoanAssetInput, underlyingToCollateralConversionError]); + + const quote = useLeverageQuote({ + chainId: market.morphoBlue.chain.id, + route, + userCollateralAmount: collateralAmountForLeverageQuote, + multiplierBps, + }); + + const currentCollateralAssets = BigInt(currentPosition?.state.collateral ?? 0); + const currentBorrowAssets = BigInt(currentPosition?.state.borrowAssets ?? 0); + const projectedCollateralAssets = currentCollateralAssets + quote.totalAddedCollateral; + const projectedBorrowAssets = currentBorrowAssets + quote.flashLoanAmount; + const lltv = BigInt(market.lltv); + const marketLiquidity = BigInt(market.state.liquidityAssets); + const isEthInputStEthRoute = isMainnetEthStEthRoute && useEth && route?.kind === 'steth'; + + const { data: currentCollateralInStEth } = useReadContract({ + address: route?.kind === 'steth' ? route.collateralToken : undefined, + abi: wstEthAbi, + functionName: 'getStETHByWstETH', + args: [currentCollateralAssets], + chainId: market.morphoBlue.chain.id, + query: { + enabled: isEthInputStEthRoute && currentCollateralAssets > 0n, + }, + }); + + const addedCollateralInStEth = useMemo(() => { + if (!isEthInputStEthRoute) return 0n; + // WHY: for ETH input mode, leverage target is defined on ETH/stETH exposure. + // Added collateral in stETH terms is user ETH leg + flash-borrowed stETH-equivalent debt leg. + return collateralAmount + quote.flashLoanAmount; + }, [isEthInputStEthRoute, collateralAmount, quote.flashLoanAmount]); + + const canUseStEthDisplayForPosition = useMemo(() => { + if (!isEthInputStEthRoute) return false; + if (currentCollateralAssets === 0n) return true; + return currentCollateralInStEth !== undefined; + }, [isEthInputStEthRoute, currentCollateralAssets, currentCollateralInStEth]); + + const previewCurrentCollateral = useMemo(() => { + if (!isEthInputStEthRoute || !canUseStEthDisplayForPosition) return currentCollateralAssets; + if (currentCollateralAssets === 0n) return 0n; + return (currentCollateralInStEth as bigint | undefined) ?? 0n; + }, [isEthInputStEthRoute, canUseStEthDisplayForPosition, currentCollateralAssets, currentCollateralInStEth]); + + const previewProjectedCollateral = useMemo(() => { + if (!isEthInputStEthRoute || !canUseStEthDisplayForPosition) return projectedCollateralAssets; + return previewCurrentCollateral + addedCollateralInStEth; + }, [isEthInputStEthRoute, canUseStEthDisplayForPosition, projectedCollateralAssets, previewCurrentCollateral, addedCollateralInStEth]); + + const previewMarket = useMemo(() => { + if (!isEthInputStEthRoute || !canUseStEthDisplayForPosition || route?.kind !== 'steth') return market; + return { + ...market, + collateralAsset: { + ...market.collateralAsset, + address: route.stEthToken, + symbol: STETH_DISPLAY_SYMBOL, + decimals: 18, + }, + }; + }, [isEthInputStEthRoute, canUseStEthDisplayForPosition, route, market]); + + const transactionPreviewCollateralAmount = useMemo(() => { + if (!isEthInputStEthRoute) return quote.totalAddedCollateral; + return addedCollateralInStEth; + }, [isEthInputStEthRoute, quote.totalAddedCollateral, addedCollateralInStEth]); + + const transactionPreviewCollateralAddress = + isEthInputStEthRoute && route?.kind === 'steth' ? route.stEthToken : market.collateralAsset.address; + const transactionPreviewCollateralSymbol = isEthInputStEthRoute ? STETH_DISPLAY_SYMBOL : market.collateralAsset.symbol; + const transactionPreviewCollateralDecimals = isEthInputStEthRoute ? 18 : market.collateralAsset.decimals; + + const projectedLTV = useMemo( + () => + computeLtv({ + borrowAssets: projectedBorrowAssets, + collateralAssets: projectedCollateralAssets, + oraclePrice, + }), + [projectedBorrowAssets, projectedCollateralAssets, oraclePrice], + ); + + const currentLTV = useMemo( + () => + computeLtv({ + borrowAssets: currentBorrowAssets, + collateralAssets: currentCollateralAssets, + oraclePrice, + }), + [currentBorrowAssets, currentCollateralAssets, oraclePrice], + ); + + const { transaction, isLoadingPermit2, isApproved, permit2Authorized, leveragePending, approveAndLeverage, signAndLeverage } = + useLeverageTransaction({ + market, + route: route!, + collateralAmount, + collateralAmountInCollateralToken: collateralAmountForLeverageQuote, + flashCollateralAmount: quote.flashCollateralAmount, + flashLoanAmount: quote.flashLoanAmount, + useEth, + useLoanAssetAsInput: useLoanAssetInput, + onSuccess, + }); + + const handleMultiplierInputChange = useCallback((value: string) => { + const normalized = value.replace(',', '.'); + if (!MULTIPLIER_INPUT_REGEX.test(normalized)) return; + setMultiplierInput(normalized); + }, []); + + const handleMultiplierInputBlur = useCallback(() => { + setMultiplierInput(formatMultiplierBps(clampMultiplierBps(parseMultiplierToBps(multiplierInput)))); + }, [multiplierInput]); + + const handleLeverage = useCallback(() => { + if (useEth) { + void approveAndLeverage(); + return; + } + + if (usePermit2Setting && permit2Authorized) { + void signAndLeverage(); + return; + } + if (!usePermit2Setting && isApproved) { + void approveAndLeverage(); + return; + } + void approveAndLeverage(); + }, [useEth, usePermit2Setting, permit2Authorized, signAndLeverage, isApproved, approveAndLeverage]); + + const projectedOverLimit = projectedLTV >= lltv; + const insufficientLiquidity = quote.flashLoanAmount > marketLiquidity; + const hasChanges = collateralAmountForLeverageQuote > 0n && quote.flashLoanAmount > 0n; + const inputAssetSymbol = useEth + ? getNativeTokenSymbol(market.morphoBlue.chain.id) + : useLoanAssetInput + ? market.loanAsset.symbol + : market.collateralAsset.symbol; + const inputAssetDecimals = useEth ? 18 : useLoanAssetInput ? market.loanAsset.decimals : market.collateralAsset.decimals; + const inputAssetBalance = useEth + ? nativeBalance?.value + : useLoanAssetInput + ? (loanTokenBalance as bigint | undefined) + : collateralTokenBalance; + const inputTokenIconAddress = useEth + ? market.loanAsset.address + : useLoanAssetInput + ? market.loanAsset.address + : market.collateralAsset.address; + const isLoadingInputConversion = useEth + ? isLoadingEthToWstEthConversion + : useLoanAssetInput + ? isLoadingUnderlyingToCollateralConversion + : false; + + return ( +
+ {!transaction?.isModalVisible && ( +
+

Leverage Preview

+ + +
+
+
+

+ {useLoanAssetInput + ? `Start with ${market.loanAsset.symbol} (mints ${market.collateralAsset.symbol} collateral)` + : `Add Collateral ${inputAssetSymbol}`} +

+ {isMainnetEthStEthRoute && ( +
+
Use {getNativeTokenSymbol(market.morphoBlue.chain.id)}
+ +
+ )} + {isErc4626Route && ( +
+
Start with {market.loanAsset.symbol}
+ +
+ )} +
+ + } + /> +
+ {collateralInputError ?? ''} + + Balance: {formatBalance(inputAssetBalance ?? 0n, inputAssetDecimals)} {inputAssetSymbol} + +
+
+ +
+

Target Multiplier

+
+ handleMultiplierInputChange(event.target.value)} + onBlur={handleMultiplierInputBlur} + className="h-10 w-full rounded bg-hovered px-3 py-2 pr-10 text-base font-medium tabular-nums focus:border-primary focus:outline-none" + /> + x +
+
+ +
+

Transaction Preview

+
+
+ Flash Borrow + + + {formatFullTokenAmount(quote.flashLoanAmount, market.loanAsset.decimals)} + + } + > + + {formatCompactTokenAmount(quote.flashLoanAmount, market.loanAsset.decimals)} + + + + +
+
+ Total Collateral Added + + + {formatFullTokenAmount(transactionPreviewCollateralAmount, transactionPreviewCollateralDecimals)} + + } + > + + {formatCompactTokenAmount(transactionPreviewCollateralAmount, transactionPreviewCollateralDecimals)} + + + + +
+
+ Projected LTV + {formatLtvPercent(projectedLTV)}% +
+
+ {conversionErrorMessage &&

{conversionErrorMessage}

} + {quote.error &&

{quote.error}

} + {insufficientLiquidity && ( +

+ Flash loan repayment borrow exceeds market liquidity ({formatBalance(marketLiquidity, market.loanAsset.decimals)}{' '} + {market.loanAsset.symbol} available). +

+ )} +
+
+ +
+ {showRouteNotice && ( +
+

+ Deleverage is not supported for this stETH-ETH route yet. To reduce exposure, manually repay debt for now. +

+ +
+ )} + +
+ + Leverage + +
+ + {hasChanges && projectedOverLimit && ( + + )} +
+
+ )} +
+ ); +} diff --git a/src/modals/leverage/components/remove-collateral-and-deleverage.tsx b/src/modals/leverage/components/remove-collateral-and-deleverage.tsx new file mode 100644 index 00000000..912d2ea9 --- /dev/null +++ b/src/modals/leverage/components/remove-collateral-and-deleverage.tsx @@ -0,0 +1,240 @@ +import { useCallback, useMemo, useState } from 'react'; +import Input from '@/components/Input/Input'; +import { ExecuteTransactionButton } from '@/components/ui/ExecuteTransactionButton'; +import { Tooltip } from '@/components/ui/tooltip'; +import { LTVWarning } from '@/components/shared/ltv-warning'; +import { TokenIcon } from '@/components/shared/token-icon'; +import { useDeleverageQuote } from '@/hooks/useDeleverageQuote'; +import { useDeleverageTransaction } from '@/hooks/useDeleverageTransaction'; +import { formatBalance } from '@/utils/balance'; +import { formatCompactTokenAmount, formatFullTokenAmount } from '@/utils/token-amount-format'; +import type { Market, MarketPosition } from '@/utils/types'; +import type { LeverageSupport } from '@/hooks/leverage/types'; +import { computeLtv, formatLtvPercent, getLTVColor } from '@/modals/borrow/components/helpers'; +import { BorrowPositionRiskCard } from '@/modals/borrow/components/borrow-position-risk-card'; + +type RemoveCollateralAndDeleverageProps = { + market: Market; + support: LeverageSupport; + currentPosition: MarketPosition | null; + oraclePrice: bigint; + onSuccess?: () => void; + isRefreshing?: boolean; +}; + +export function RemoveCollateralAndDeleverage({ + market, + support, + currentPosition, + oraclePrice, + onSuccess, + isRefreshing = false, +}: RemoveCollateralAndDeleverageProps): JSX.Element { + const route = support.route; + const [withdrawCollateralAmount, setWithdrawCollateralAmount] = useState(0n); + const [withdrawInputError, setWithdrawInputError] = useState(null); + + const currentCollateralAssets = BigInt(currentPosition?.state.collateral ?? 0); + const currentBorrowAssets = BigInt(currentPosition?.state.borrowAssets ?? 0); + const currentBorrowShares = BigInt(currentPosition?.state.borrowShares ?? 0); + const lltv = BigInt(market.lltv); + + const quote = useDeleverageQuote({ + chainId: market.morphoBlue.chain.id, + route, + withdrawCollateralAmount, + currentBorrowAssets, + }); + + const maxWithdrawCollateral = useMemo(() => { + if (quote.maxCollateralForDebtRepay <= 0n) return 0n; + return quote.maxCollateralForDebtRepay > currentCollateralAssets ? currentCollateralAssets : quote.maxCollateralForDebtRepay; + }, [quote.maxCollateralForDebtRepay, currentCollateralAssets]); + + const projectedCollateralAfterInput = + withdrawCollateralAmount > currentCollateralAssets ? 0n : currentCollateralAssets - withdrawCollateralAmount; + const closesDebt = currentBorrowAssets > 0n && quote.repayAmount >= currentBorrowAssets; + const repayBySharesAmount = closesDebt ? currentBorrowShares : 0n; + const flashLoanAmountForTx = closesDebt ? quote.rawRouteRepayAmount : quote.repayAmount; + const autoWithdrawCollateralAmount = closesDebt ? projectedCollateralAfterInput : 0n; + const projectedCollateralAssets = closesDebt ? 0n : projectedCollateralAfterInput; + const projectedBorrowAssets = quote.repayAmount > currentBorrowAssets ? 0n : currentBorrowAssets - quote.repayAmount; + + const currentLTV = useMemo( + () => + computeLtv({ + borrowAssets: currentBorrowAssets, + collateralAssets: currentCollateralAssets, + oraclePrice, + }), + [currentBorrowAssets, currentCollateralAssets, oraclePrice], + ); + + const projectedLTV = useMemo( + () => + computeLtv({ + borrowAssets: projectedBorrowAssets, + collateralAssets: projectedCollateralAssets, + oraclePrice, + }), + [projectedBorrowAssets, projectedCollateralAssets, oraclePrice], + ); + + const { transaction, deleveragePending, authorizeAndDeleverage } = useDeleverageTransaction({ + market, + route: route!, + withdrawCollateralAmount, + flashLoanAmount: flashLoanAmountForTx, + repayBySharesAmount, + autoWithdrawCollateralAmount, + onSuccess, + }); + + const handleDeleverage = useCallback(() => { + void authorizeAndDeleverage(); + }, [authorizeAndDeleverage]); + + // Treat user input as an intent change immediately so the preview card updates as soon as the amount changes. + const hasChanges = withdrawCollateralAmount > 0n; + const projectedOverLimit = projectedLTV >= lltv; + const previewDebtRepaid = closesDebt ? currentBorrowAssets : quote.repayAmount; + + return ( +
+ {!transaction?.isModalVisible && ( +
+

Deleverage Preview

+ + +
+
+

+ Collateral To Unwind {market.collateralAsset.symbol} +

+ + } + /> +

+ Max: {formatBalance(maxWithdrawCollateral, market.collateralAsset.decimals)} {market.collateralAsset.symbol} +

+ {withdrawInputError &&

{withdrawInputError}

} +
+ +
+

Transaction Preview

+
+
+ Flash Borrow + + + {formatFullTokenAmount(flashLoanAmountForTx, market.loanAsset.decimals)} + + } + > + + {formatCompactTokenAmount(flashLoanAmountForTx, market.loanAsset.decimals)} + + + + +
+
+ Debt Repaid + + + {formatFullTokenAmount(previewDebtRepaid, market.loanAsset.decimals)} + + } + > + + {formatCompactTokenAmount(previewDebtRepaid, market.loanAsset.decimals)} + + + + +
+
+ Projected LTV + {formatLtvPercent(projectedLTV)}% +
+
+ {quote.error &&

{quote.error}

} +
+
+ +
+
+ + Deleverage + +
+ + {hasChanges && projectedOverLimit && ( + + )} +
+
+ )} +
+ ); +} diff --git a/src/modals/leverage/leverage-modal-global.tsx b/src/modals/leverage/leverage-modal-global.tsx new file mode 100644 index 00000000..37b03513 --- /dev/null +++ b/src/modals/leverage/leverage-modal-global.tsx @@ -0,0 +1,55 @@ +'use client'; + +import { useConnection } from 'wagmi'; +import { useOraclePrice } from '@/hooks/useOraclePrice'; +import useUserPosition from '@/hooks/useUserPosition'; +import type { SupportedNetworks } from '@/utils/networks'; +import type { Market } from '@/utils/types'; +import { LeverageModal } from './leverage-modal'; + +type LeverageModalGlobalProps = { + market: Market; + defaultMode?: 'leverage' | 'deleverage'; + toggleLeverageDeleverage?: boolean; + refetch?: () => void; + onOpenChange: (open: boolean) => void; +}; + +/** + * Global wrapper that mirrors BorrowModalGlobal behavior: + * it resolves oracle price + user position before rendering the leverage modal. + */ +export function LeverageModalGlobal({ + market, + defaultMode, + toggleLeverageDeleverage, + refetch: externalRefetch, + onOpenChange, +}: LeverageModalGlobalProps): JSX.Element { + const { address } = useConnection(); + const chainId = market.morphoBlue.chain.id as SupportedNetworks; + + const { price: oraclePrice } = useOraclePrice({ + oracle: market.oracleAddress as `0x${string}`, + chainId, + }); + + const { position, refetch: refetchPosition } = useUserPosition(address, chainId, market.uniqueKey); + + const handleRefetch = () => { + refetchPosition(); + externalRefetch?.(); + }; + + return ( + + ); +} diff --git a/src/modals/leverage/leverage-modal.tsx b/src/modals/leverage/leverage-modal.tsx new file mode 100644 index 00000000..7708d1e2 --- /dev/null +++ b/src/modals/leverage/leverage-modal.tsx @@ -0,0 +1,159 @@ +import { useCallback, useMemo, useState } from 'react'; +import { LuArrowRightLeft } from 'react-icons/lu'; +import { erc20Abi } from 'viem'; +import { useConnection, useReadContract } from 'wagmi'; +import { Modal, ModalBody, ModalHeader } from '@/components/common/Modal'; +import { Button } from '@/components/ui/button'; +import { TokenIcon } from '@/components/shared/token-icon'; +import { useLeverageSupport } from '@/hooks/useLeverageSupport'; +import type { Market, MarketPosition } from '@/utils/types'; +import { AddCollateralAndLeverage } from './components/add-collateral-and-leverage'; +import { RemoveCollateralAndDeleverage } from './components/remove-collateral-and-deleverage'; + +type LeverageModalProps = { + market: Market; + onOpenChange: (open: boolean) => void; + oraclePrice: bigint; + refetch?: () => void; + isRefreshing?: boolean; + position: MarketPosition | null; + defaultMode?: 'leverage' | 'deleverage'; + toggleLeverageDeleverage?: boolean; +}; + +export function LeverageModal({ + market, + onOpenChange, + oraclePrice, + refetch, + isRefreshing = false, + position, + defaultMode = 'leverage', + toggleLeverageDeleverage = true, +}: LeverageModalProps): JSX.Element { + const [mode, setMode] = useState<'leverage' | 'deleverage'>(defaultMode); + const { address: account } = useConnection(); + const support = useLeverageSupport({ market }); + + const leveragePanelType = useMemo(() => { + if (support.route?.kind === 'steth' && support.route.loanMode === 'mainnet-weth-steth-wsteth') return 'steth-eth' as const; + if (support.route?.kind === 'erc4626') return 'erc4626-leverage' as const; + return 'other' as const; + }, [support.route]); + + const isStEthEthLeverageType = leveragePanelType === 'steth-eth'; + const effectiveMode = isStEthEthLeverageType ? 'leverage' : mode; + + const { + data: collateralTokenBalance, + refetch: refetchCollateralTokenBalance, + isFetching: isFetchingCollateralTokenBalance, + } = useReadContract({ + address: market.collateralAsset.address as `0x${string}`, + args: [account as `0x${string}`], + functionName: 'balanceOf', + abi: erc20Abi, + chainId: market.morphoBlue.chain.id, + query: { + enabled: !!account, + }, + }); + + const handleRefreshAll = useCallback(() => { + const tasks: Promise[] = []; + if (refetch) tasks.push(Promise.resolve(refetch())); + if (account) tasks.push(refetchCollateralTokenBalance()); + if (tasks.length > 0) void Promise.allSettled(tasks); + }, [refetch, account, refetchCollateralTokenBalance]); + + const isRefreshingAnyData = isRefreshing || isFetchingCollateralTokenBalance; + + const mainIcon = ( +
+ +
+ +
+
+ ); + + return ( + + onOpenChange(false)} + title={ +
+ {market.loanAsset.symbol} + / {market.collateralAsset.symbol} +
+ } + description={effectiveMode === 'leverage' ? 'Amplify collateral exposure' : 'Reduce borrowed exposure'} + actions={ + toggleLeverageDeleverage && !isStEthEthLeverageType ? ( + + ) : undefined + } + /> + + {support.isLoading ? ( +
Checking leverage route support...
+ ) : support.isSupported ? ( + effectiveMode === 'leverage' ? ( + + ) : support.supportsDeleverage ? ( + + ) : ( +
+ {support.reason ?? 'Deleverage is not available for this route.'} +
+ ) + ) : ( +
+ {support.reason ?? 'This market is not supported by the V2 leverage routes.'} +
+ )} +
+
+ ); +} diff --git a/src/modals/registry.tsx b/src/modals/registry.tsx index 90787268..d55edd0c 100644 --- a/src/modals/registry.tsx +++ b/src/modals/registry.tsx @@ -15,6 +15,7 @@ const SwapModal = lazy(() => import('@/features/swap/components/SwapModal').then // Borrow & Repay const BorrowModalGlobal = lazy(() => import('@/modals/borrow/borrow-modal-global').then((m) => ({ default: m.BorrowModalGlobal }))); +const LeverageModalGlobal = lazy(() => import('@/modals/leverage/leverage-modal-global').then((m) => ({ default: m.LeverageModalGlobal }))); // Supply & Withdraw const SupplyModalV2 = lazy(() => import('@/modals/supply/supply-modal').then((m) => ({ default: m.SupplyModalV2 }))); @@ -49,6 +50,7 @@ export const MODAL_REGISTRY: { [K in ModalType]: ComponentType; } = { borrow: BorrowModalGlobal, + leverage: LeverageModalGlobal, bridgeSwap: SwapModal, supply: SupplyModalV2, rebalance: RebalanceModal, diff --git a/src/stores/useModalStore.ts b/src/stores/useModalStore.ts index 16e9a739..874801bb 100644 --- a/src/stores/useModalStore.ts +++ b/src/stores/useModalStore.ts @@ -25,6 +25,14 @@ export type ModalProps = { liquiditySourcing?: LiquiditySourcingResult; }; + // Leverage & Deleverage + leverage: { + market: Market; + defaultMode?: 'leverage' | 'deleverage'; + toggleLeverageDeleverage?: boolean; + refetch?: () => void; + }; + // Supply & Withdraw supply: { market: Market; diff --git a/src/types/token.ts b/src/types/token.ts index d6d67847..1bd4f5c3 100644 --- a/src/types/token.ts +++ b/src/types/token.ts @@ -1,4 +1,5 @@ -import type { SupportedNetworks } from '@/utils/networks'; +import type { Address } from 'viem'; +import { SupportedNetworks } from '@/utils/networks'; /** * Represents a token with fixed network and address information @@ -14,3 +15,41 @@ export type NetworkToken = { /** Token contract address on the network */ address: string; }; + +/** + * Canonical token addresses used for deterministic route checks. + * These are intentionally explicit to avoid relying on optional + * contract introspection methods for route discovery. + */ +export const WETH_BY_CHAIN: Partial> = { + [SupportedNetworks.Mainnet]: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + [SupportedNetworks.Base]: '0x4200000000000000000000000000000000000006', + [SupportedNetworks.Polygon]: '0x7ceb23fd6bc0add59e62ac25578270cff1b9f619', + [SupportedNetworks.Unichain]: '0x4200000000000000000000000000000000000006', + [SupportedNetworks.Arbitrum]: '0x82af49447d8a07e3bd95bd0d56f35241523fbab1', + [SupportedNetworks.Monad]: '0xEE8c0E9f1BFFb4Eb878d8f15f368A02a35481242', +}; + +export const WSTETH_BY_CHAIN: Partial> = { + [SupportedNetworks.Mainnet]: '0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0', + [SupportedNetworks.Base]: '0xc1cba3fcea344f92d9239c08c0568f6f2f0ee452', + [SupportedNetworks.Unichain]: '0xc02fe7317d4eb8753a02c35fe019786854a92001', + [SupportedNetworks.Arbitrum]: '0x5979D7b546E38E414F7E9822514be443A4800529', + [SupportedNetworks.Monad]: '0x10Aeaf63194db8d453d4D85a06E5eFE1dd0b5417', +}; + +export const STETH_BY_CHAIN: Partial> = { + [SupportedNetworks.Mainnet]: '0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84', +}; + +export const getCanonicalWethAddress = (chainId: number): Address | undefined => { + return WETH_BY_CHAIN[chainId as SupportedNetworks]; +}; + +export const getCanonicalWstEthAddress = (chainId: number): Address | undefined => { + return WSTETH_BY_CHAIN[chainId as SupportedNetworks]; +}; + +export const getCanonicalStEthAddress = (chainId: number): Address | undefined => { + return STETH_BY_CHAIN[chainId as SupportedNetworks]; +}; diff --git a/src/utils/token-amount-format.ts b/src/utils/token-amount-format.ts new file mode 100644 index 00000000..4d029a7b --- /dev/null +++ b/src/utils/token-amount-format.ts @@ -0,0 +1,49 @@ +import { formatUnits } from 'viem'; + +const COMPACT_AMOUNT_LOCALE = 'en-US'; +const COMPACT_AMOUNT_MIN_THRESHOLD = 0.000001; + +export function formatFullTokenAmount(value: bigint, decimals: number): string { + const formattedUnits = formatUnits(value, decimals); + const [integerPart, fractionalPart = ''] = formattedUnits.split('.'); + const hasNegativeSign = integerPart.startsWith('-'); + const unsignedIntegerPart = hasNegativeSign ? integerPart.slice(1) : integerPart; + const groupedIntegerPart = unsignedIntegerPart.replace(/\B(?=(\d{3})+(?!\d))/g, ','); + const trimmedFractionalPart = fractionalPart.replace(/0+$/, ''); + + if (trimmedFractionalPart.length > 0) { + return `${hasNegativeSign ? '-' : ''}${groupedIntegerPart}.${trimmedFractionalPart}`; + } + + return `${hasNegativeSign ? '-' : ''}${groupedIntegerPart}`; +} + +export function formatCompactTokenAmount(value: bigint, decimals: number): string { + if (value === 0n) return '0'; + + const numericValue = Number(formatUnits(value, decimals)); + if (!Number.isFinite(numericValue)) return formatUnits(value, decimals); + + const absoluteValue = Math.abs(numericValue); + + if (absoluteValue >= 1000) { + return new Intl.NumberFormat(COMPACT_AMOUNT_LOCALE, { + notation: 'compact', + maximumFractionDigits: 2, + }).format(numericValue); + } + + if (absoluteValue >= 1) { + return numericValue.toLocaleString(COMPACT_AMOUNT_LOCALE, { + maximumFractionDigits: 4, + }); + } + + if (absoluteValue >= COMPACT_AMOUNT_MIN_THRESHOLD) { + return numericValue.toLocaleString(COMPACT_AMOUNT_LOCALE, { + maximumSignificantDigits: 4, + }); + } + + return `<${COMPACT_AMOUNT_MIN_THRESHOLD}`; +} From 33a752e85208544d1bcd6fbf8924706e1604641e Mon Sep 17 00:00:00 2001 From: antoncoding Date: Wed, 25 Feb 2026 22:29:10 +0800 Subject: [PATCH 02/10] feat: swapping modes --- src/components/common/Modal/ModalHeader.tsx | 8 ++- .../common/Modal/ModalIntentSwitcher.tsx | 61 +++++++++++++++++ .../components/market-header.tsx | 30 --------- src/features/market-detail/market-view.tsx | 10 --- src/modals/borrow/borrow-modal.tsx | 44 +++++++++---- .../add-collateral-and-leverage.tsx | 12 +++- .../remove-collateral-and-deleverage.tsx | 9 ++- src/modals/leverage/leverage-modal.tsx | 37 +++++------ src/modals/supply/supply-modal.tsx | 65 +++++++++++-------- 9 files changed, 173 insertions(+), 103 deletions(-) create mode 100644 src/components/common/Modal/ModalIntentSwitcher.tsx diff --git a/src/components/common/Modal/ModalHeader.tsx b/src/components/common/Modal/ModalHeader.tsx index 880d4e78..ae82b389 100644 --- a/src/components/common/Modal/ModalHeader.tsx +++ b/src/components/common/Modal/ModalHeader.tsx @@ -49,6 +49,12 @@ export function ModalHeader({ const handleClose = onClose; const iconButtonBaseClass = 'flex h-8 w-8 items-center justify-center rounded-full text-secondary transition hover:text-primary focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white/70'; + const titleNode = + typeof title === 'string' ? ( + {title} + ) : ( +
{title}
+ ); // If children are provided, use them directly (for custom layouts) if (children) { @@ -61,7 +67,7 @@ export function ModalHeader({
{mainIcon &&
{mainIcon}
} - {title} + {titleNode}
{description &&
{description}
}
diff --git a/src/components/common/Modal/ModalIntentSwitcher.tsx b/src/components/common/Modal/ModalIntentSwitcher.tsx new file mode 100644 index 00000000..4a053729 --- /dev/null +++ b/src/components/common/Modal/ModalIntentSwitcher.tsx @@ -0,0 +1,61 @@ +import { ChevronDownIcon } from '@radix-ui/react-icons'; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; +import { cn } from '@/utils/components'; + +type ModalIntentOption = { + value: string; + label: string; + disabled?: boolean; +}; + +type ModalIntentSwitcherProps = { + value: string; + options: ModalIntentOption[]; + onValueChange: (value: string) => void; + className?: string; +}; + +export function ModalIntentSwitcher({ value, options, onValueChange, className }: ModalIntentSwitcherProps): JSX.Element { + const selected = options.find((option) => option.value === value) ?? options[0]; + const canSwitch = options.length > 1; + + if (!selected) { + return -; + } + + if (!canSwitch) { + return {selected.label}; + } + + return ( + + + + + + {options.map((option) => ( + onValueChange(option.value)} + className={cn(value === option.value && 'bg-hovered')} + > + {option.label} + + ))} + + + ); +} diff --git a/src/features/market-detail/components/market-header.tsx b/src/features/market-detail/components/market-header.tsx index 4ceb5fe9..d67015c9 100644 --- a/src/features/market-detail/components/market-header.tsx +++ b/src/features/market-detail/components/market-header.tsx @@ -25,7 +25,6 @@ import { TooltipContent } from '@/components/shared/tooltip-content'; import { AddressIdentity } from '@/components/shared/address-identity'; import { CampaignBadge } from '@/features/market-detail/components/campaign-badge'; import { OracleTypeInfo } from '@/features/markets/components/oracle/MarketOracle/OracleTypeInfo'; -import { useLeverageSupport } from '@/hooks/useLeverageSupport'; import { useRateLabel } from '@/hooks/useRateLabel'; import { useStyledToast } from '@/hooks/useStyledToast'; import { useAppSettings } from '@/stores/useAppSettings'; @@ -127,8 +126,6 @@ type ActionButtonsProps = { onWithdrawClick: () => void; onBorrowClick: () => void; onRepayClick: () => void; - onLeverageClick: () => void; - onDeleverageClick: () => void; }; function ActionButtons({ @@ -138,11 +135,7 @@ function ActionButtons({ onWithdrawClick, onBorrowClick, onRepayClick, - onLeverageClick, - onDeleverageClick, }: ActionButtonsProps): React.ReactNode { - const leverageSupport = useLeverageSupport({ market }); - // Compute position states once const hasSupply = userPosition !== null && BigInt(userPosition.state.supplyShares) > 0n; const hasBorrow = userPosition !== null && BigInt(userPosition.state.borrowShares) > 0n; @@ -242,23 +235,6 @@ function ActionButtons({ }, ]} /> - - {!leverageSupport.isLoading && leverageSupport.supportsLeverage && ( - } - onClick={onLeverageClick} - indicator={{ show: hasBorrowPosition }} - dropdownItems={[ - { - label: 'Deleverage', - icon: , - onClick: onDeleverageClick, - disabled: !leverageSupport.supportsDeleverage || !hasBorrow, - }, - ]} - /> - )} ); } @@ -274,8 +250,6 @@ type MarketHeaderProps = { onWithdrawClick?: () => void; onBorrowClick?: () => void; onRepayClick?: () => void; - onLeverageClick?: () => void; - onDeleverageClick?: () => void; accrueInterest?: () => void; isLoading?: boolean; }; @@ -370,8 +344,6 @@ export function MarketHeader({ onWithdrawClick = () => {}, onBorrowClick = () => {}, onRepayClick = () => {}, - onLeverageClick = () => {}, - onDeleverageClick = () => {}, accrueInterest = () => {}, isLoading = false, }: MarketHeaderProps) { @@ -601,8 +573,6 @@ export function MarketHeader({ onWithdrawClick={onWithdrawClick} onBorrowClick={onBorrowClick} onRepayClick={onRepayClick} - onLeverageClick={onLeverageClick} - onDeleverageClick={onDeleverageClick} /> {/* Advanced Options Dropdown */} diff --git a/src/features/market-detail/market-view.tsx b/src/features/market-detail/market-view.tsx index ad9fd30c..b71c954e 100644 --- a/src/features/market-detail/market-view.tsx +++ b/src/features/market-detail/market-view.tsx @@ -300,14 +300,6 @@ function MarketContent() { openModal('borrow', { market, refetch: handleRefresh, liquiditySourcing, defaultMode: 'repay' }); }; - const handleLeverageClick = () => { - openModal('leverage', { market, refetch: handleRefresh, defaultMode: 'leverage' }); - }; - - const handleDeleverageClick = () => { - openModal('leverage', { market, refetch: handleRefresh, defaultMode: 'deleverage' }); - }; - const handleAccrueInterest = async () => { const morphoAddress = market.morphoBlue.address as Address; await switchChainAsync({ chainId: market.morphoBlue.chain.id }); @@ -372,8 +364,6 @@ function MarketContent() { onWithdrawClick={handleWithdrawClick} onBorrowClick={handleBorrowClick} onRepayClick={handleRepayClick} - onLeverageClick={handleLeverageClick} - onDeleverageClick={handleDeleverageClick} accrueInterest={handleAccrueInterest} /> diff --git a/src/modals/borrow/borrow-modal.tsx b/src/modals/borrow/borrow-modal.tsx index 46562700..3868c7c6 100644 --- a/src/modals/borrow/borrow-modal.tsx +++ b/src/modals/borrow/borrow-modal.tsx @@ -1,14 +1,17 @@ import { useCallback, useEffect, useState } from 'react'; -import { LuArrowRightLeft } from 'react-icons/lu'; +import { BsFillLightningFill } from 'react-icons/bs'; import { useConnection, useReadContract, useBalance } from 'wagmi'; import { erc20Abi } from 'viem'; import { Button } from '@/components/ui/button'; import { Modal, ModalHeader, ModalBody } from '@/components/common/Modal'; +import { ModalIntentSwitcher } from '@/components/common/Modal/ModalIntentSwitcher'; import type { Market, MarketPosition } from '@/utils/types'; import type { LiquiditySourcingResult } from '@/hooks/useMarketLiquiditySourcing'; import { AddCollateralAndBorrow } from './components/add-collateral-and-borrow'; import { WithdrawCollateralAndRepay } from './components/withdraw-collateral-and-repay'; import { TokenIcon } from '@/components/shared/token-icon'; +import { useModal } from '@/hooks/useModal'; +import { useLeverageSupport } from '@/hooks/useLeverageSupport'; type BorrowModalProps = { market: Market; @@ -30,16 +33,27 @@ export function BorrowModal({ isRefreshing = false, position, defaultMode = 'borrow', - toggleBorrowRepay = true, + toggleBorrowRepay: _toggleBorrowRepay = true, liquiditySourcing, }: BorrowModalProps): JSX.Element { const [mode, setMode] = useState<'borrow' | 'repay'>(() => defaultMode); const { address: account } = useConnection(); + const { open: openModal } = useModal(); + const leverageSupport = useLeverageSupport({ market }); useEffect(() => { setMode(defaultMode); }, [defaultMode]); + const handleOpenLeverage = useCallback(() => { + openModal('leverage', { + market, + refetch, + defaultMode: 'leverage', + }); + onOpenChange(false); + }, [openModal, market, refetch, onOpenChange]); + // Get token balances const { data: loanTokenBalance, @@ -131,22 +145,30 @@ export function BorrowModal({ mainIcon={mainIcon} onClose={() => onOpenChange(false)} title={ -
- {market.loanAsset.symbol} - / {market.collateralAsset.symbol} -
+ setMode(nextMode as 'borrow' | 'repay')} + /> + } + description={ + mode === 'borrow' + ? `Use ${market.collateralAsset.symbol} as collateral to borrow ${market.loanAsset.symbol}.` + : `Repay ${market.loanAsset.symbol} debt to reduce risk and unlock ${market.collateralAsset.symbol} collateral.` } - description={mode === 'borrow' ? 'Borrow against collateral' : 'Repay borrowed assets'} actions={ - toggleBorrowRepay ? ( + !leverageSupport.isLoading && leverageSupport.supportsLeverage ? ( ) : undefined } diff --git a/src/modals/leverage/components/add-collateral-and-leverage.tsx b/src/modals/leverage/components/add-collateral-and-leverage.tsx index ed3fb7da..dd6626cb 100644 --- a/src/modals/leverage/components/add-collateral-and-leverage.tsx +++ b/src/modals/leverage/components/add-collateral-and-leverage.tsx @@ -271,6 +271,14 @@ export function AddCollateralAndLeverage({ [currentBorrowAssets, currentCollateralAssets, oraclePrice], ); + const handleTransactionSuccess = useCallback(() => { + // WHY: after a confirmed leverage tx, we reset draft inputs so the panel reflects the live position state. + setCollateralAmount(0n); + setCollateralInputError(null); + setMultiplierInput(formatMultiplierBps(LEVERAGE_DEFAULT_MULTIPLIER_BPS)); + if (onSuccess) onSuccess(); + }, [onSuccess]); + const { transaction, isLoadingPermit2, isApproved, permit2Authorized, leveragePending, approveAndLeverage, signAndLeverage } = useLeverageTransaction({ market, @@ -281,7 +289,7 @@ export function AddCollateralAndLeverage({ flashLoanAmount: quote.flashLoanAmount, useEth, useLoanAssetAsInput: useLoanAssetInput, - onSuccess, + onSuccess: handleTransactionSuccess, }); const handleMultiplierInputChange = useCallback((value: string) => { @@ -361,7 +369,7 @@ export function AddCollateralAndLeverage({

{useLoanAssetInput - ? `Start with ${market.loanAsset.symbol} (mints ${market.collateralAsset.symbol} collateral)` + ? `Mints ${market.collateralAsset.symbol} collateral` : `Add Collateral ${inputAssetSymbol}`}

{isMainnetEthStEthRoute && ( diff --git a/src/modals/leverage/components/remove-collateral-and-deleverage.tsx b/src/modals/leverage/components/remove-collateral-and-deleverage.tsx index 912d2ea9..ec73277c 100644 --- a/src/modals/leverage/components/remove-collateral-and-deleverage.tsx +++ b/src/modals/leverage/components/remove-collateral-and-deleverage.tsx @@ -80,6 +80,13 @@ export function RemoveCollateralAndDeleverage({ [projectedBorrowAssets, projectedCollateralAssets, oraclePrice], ); + const handleTransactionSuccess = useCallback(() => { + // WHY: clear unwind draft after confirmation so users see the refreshed live position, not stale input. + setWithdrawCollateralAmount(0n); + setWithdrawInputError(null); + if (onSuccess) onSuccess(); + }, [onSuccess]); + const { transaction, deleveragePending, authorizeAndDeleverage } = useDeleverageTransaction({ market, route: route!, @@ -87,7 +94,7 @@ export function RemoveCollateralAndDeleverage({ flashLoanAmount: flashLoanAmountForTx, repayBySharesAmount, autoWithdrawCollateralAmount, - onSuccess, + onSuccess: handleTransactionSuccess, }); const handleDeleverage = useCallback(() => { diff --git a/src/modals/leverage/leverage-modal.tsx b/src/modals/leverage/leverage-modal.tsx index 7708d1e2..bfaff2eb 100644 --- a/src/modals/leverage/leverage-modal.tsx +++ b/src/modals/leverage/leverage-modal.tsx @@ -1,9 +1,8 @@ import { useCallback, useMemo, useState } from 'react'; -import { LuArrowRightLeft } from 'react-icons/lu'; import { erc20Abi } from 'viem'; import { useConnection, useReadContract } from 'wagmi'; import { Modal, ModalBody, ModalHeader } from '@/components/common/Modal'; -import { Button } from '@/components/ui/button'; +import { ModalIntentSwitcher } from '@/components/common/Modal/ModalIntentSwitcher'; import { TokenIcon } from '@/components/shared/token-icon'; import { useLeverageSupport } from '@/hooks/useLeverageSupport'; import type { Market, MarketPosition } from '@/utils/types'; @@ -29,7 +28,7 @@ export function LeverageModal({ isRefreshing = false, position, defaultMode = 'leverage', - toggleLeverageDeleverage = true, + toggleLeverageDeleverage: _toggleLeverageDeleverage = true, }: LeverageModalProps): JSX.Element { const [mode, setMode] = useState<'leverage' | 'deleverage'>(defaultMode); const { address: account } = useConnection(); @@ -42,7 +41,11 @@ export function LeverageModal({ }, [support.route]); const isStEthEthLeverageType = leveragePanelType === 'steth-eth'; - const effectiveMode = isStEthEthLeverageType ? 'leverage' : mode; + const effectiveMode = mode; + const modeOptions = [ + { value: 'leverage', label: `Leverage ${market.collateralAsset.symbol}` }, + { value: 'deleverage', label: `Deleverage ${market.collateralAsset.symbol}` }, + ]; const { data: collateralTokenBalance, @@ -99,24 +102,16 @@ export function LeverageModal({ mainIcon={mainIcon} onClose={() => onOpenChange(false)} title={ -
- {market.loanAsset.symbol} - / {market.collateralAsset.symbol} -
+ setMode(nextMode as 'leverage' | 'deleverage')} + /> } - description={effectiveMode === 'leverage' ? 'Amplify collateral exposure' : 'Reduce borrowed exposure'} - actions={ - toggleLeverageDeleverage && !isStEthEthLeverageType ? ( - - ) : undefined + description={ + effectiveMode === 'leverage' + ? `Leverage your ${market.collateralAsset.symbol} exposure by looping.` + : `Reduce leveraged ${market.collateralAsset.symbol} exposure by unwinding your loop.` } /> diff --git a/src/modals/supply/supply-modal.tsx b/src/modals/supply/supply-modal.tsx index 9a59df19..b6e76924 100644 --- a/src/modals/supply/supply-modal.tsx +++ b/src/modals/supply/supply-modal.tsx @@ -1,11 +1,10 @@ import { useEffect, useMemo, useState } from 'react'; -import { LuArrowRightLeft } from 'react-icons/lu'; import { Modal, ModalBody, ModalHeader } from '@/components/common/Modal'; +import { ModalIntentSwitcher } from '@/components/common/Modal/ModalIntentSwitcher'; import { useFreshMarketsState } from '@/hooks/useFreshMarketsState'; import type { Market, MarketPosition } from '@/utils/types'; import type { LiquiditySourcingResult } from '@/hooks/useMarketLiquiditySourcing'; import { MarketDetailsBlock } from '@/features/markets/components/market-details-block'; -import { Button } from '@/components/ui/button'; import { SupplyModalContent } from './supply-modal-content'; import { TokenIcon } from '@/components/shared/token-icon'; import { WithdrawModalContent } from './withdraw-modal-content'; @@ -44,17 +43,34 @@ export function SupplyModalV2({ const activeMarket = freshMarkets?.[0] ?? market; const hasPosition = position && BigInt(position.state.supplyAssets) > 0n; + const effectiveMode = mode === 'withdraw' && !hasPosition ? 'supply' : mode; + const modeOptions = useMemo( + () => + hasPosition + ? [ + { value: 'supply', label: `Supply ${activeMarket.loanAsset.symbol}` }, + { value: 'withdraw', label: `Withdraw ${activeMarket.loanAsset.symbol}` }, + ] + : [{ value: 'supply', label: `Supply ${activeMarket.loanAsset.symbol}` }], + [activeMarket.loanAsset.symbol, hasPosition], + ); + + useEffect(() => { + if (mode === 'withdraw' && !hasPosition) { + setMode('supply'); + } + }, [mode, hasPosition]); // Calculate supply delta for preview based on current mode and amounts // Only use positive values to prevent incorrect APY direction const supplyDelta = useMemo(() => { - if (mode === 'supply') { + if (effectiveMode === 'supply') { // Supply mode: positive delta if amount is valid return supplyPreviewAmount && supplyPreviewAmount > 0n ? supplyPreviewAmount : undefined; } // Withdraw mode: negative delta (withdrawal) if amount is valid return withdrawPreviewAmount && withdrawPreviewAmount > 0n ? -withdrawPreviewAmount : undefined; - }, [mode, supplyPreviewAmount, withdrawPreviewAmount]); + }, [effectiveMode, supplyPreviewAmount, withdrawPreviewAmount]); return ( { + setMode(nextMode as 'supply' | 'withdraw'); + setSupplyPreviewAmount(undefined); + setWithdrawPreviewAmount(undefined); + }} + /> + } + description={ + effectiveMode === 'supply' + ? `Supply ${activeMarket.loanAsset.symbol} to earn interest in this market.` + : `Withdraw your supplied ${activeMarket.loanAsset.symbol} from this market.` + } mainIcon={ } onClose={() => onOpenChange(false)} - actions={ - hasPosition ? ( - - ) : undefined - } /> - {mode === 'supply' ? ( + {effectiveMode === 'supply' ? ( onOpenChange(false)} From 2f10fd381d681b194c1bc83333cbc956ef696126 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Wed, 25 Feb 2026 22:40:54 +0800 Subject: [PATCH 03/10] refactor: simplified and removed stETH --- src/abis/wsteth.ts | 28 -- src/hooks/leverage/types.ts | 11 +- src/hooks/useDeleverageQuote.ts | 62 +---- src/hooks/useDeleverageTransaction.ts | 31 +-- src/hooks/useLeverageQuote.ts | 62 +---- src/hooks/useLeverageSupport.ts | 92 +------ src/hooks/useLeverageTransaction.ts | 182 +++---------- .../add-collateral-and-leverage.tsx | 252 +++--------------- src/modals/leverage/leverage-modal.tsx | 10 +- src/types/token.ts | 26 +- 10 files changed, 102 insertions(+), 654 deletions(-) delete mode 100644 src/abis/wsteth.ts diff --git a/src/abis/wsteth.ts b/src/abis/wsteth.ts deleted file mode 100644 index 38dc3468..00000000 --- a/src/abis/wsteth.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { Abi } from 'viem'; - -/** - * Minimal wstETH ABI surface required for V2 stETH leverage/deleverage routing. - */ -export const wstEthAbi = [ - { - inputs: [], - name: 'stETH', - outputs: [{ internalType: 'address', name: '', type: 'address' }], - stateMutability: 'view', - type: 'function', - }, - { - inputs: [{ internalType: 'uint256', name: '_wstETHAmount', type: 'uint256' }], - name: 'getStETHByWstETH', - outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], - stateMutability: 'view', - type: 'function', - }, - { - inputs: [{ internalType: 'uint256', name: '_stETHAmount', type: 'uint256' }], - name: 'getWstETHByStETH', - outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], - stateMutability: 'view', - type: 'function', - }, -] as const satisfies Abi; diff --git a/src/hooks/leverage/types.ts b/src/hooks/leverage/types.ts index 436a0f09..d1b8f816 100644 --- a/src/hooks/leverage/types.ts +++ b/src/hooks/leverage/types.ts @@ -1,21 +1,12 @@ import type { Address } from 'viem'; -export type StEthLoanMode = 'steth' | 'mainnet-weth-steth-wsteth'; - export type Erc4626LeverageRoute = { kind: 'erc4626'; collateralVault: Address; underlyingLoanToken: Address; }; -export type StEthLeverageRoute = { - kind: 'steth'; - collateralToken: Address; - stEthToken: Address; - loanMode: StEthLoanMode; -}; - -export type LeverageRoute = Erc4626LeverageRoute | StEthLeverageRoute; +export type LeverageRoute = Erc4626LeverageRoute; export type LeverageSupport = { isSupported: boolean; diff --git a/src/hooks/useDeleverageQuote.ts b/src/hooks/useDeleverageQuote.ts index b2ecec09..5523a552 100644 --- a/src/hooks/useDeleverageQuote.ts +++ b/src/hooks/useDeleverageQuote.ts @@ -1,7 +1,6 @@ import { useMemo } from 'react'; import { useReadContract } from 'wagmi'; import { erc4626Abi } from '@/abis/erc4626'; -import { wstEthAbi } from '@/abis/wsteth'; import { withSlippageCeil } from './leverage/math'; import type { LeverageRoute } from './leverage/types'; @@ -32,8 +31,6 @@ export function useDeleverageQuote({ withdrawCollateralAmount, currentBorrowAssets, }: UseDeleverageQuoteParams): DeleverageQuote { - const isErc4626 = route?.kind === 'erc4626'; - const isStEth = route?.kind === 'steth'; const bufferedBorrowAssets = withSlippageCeil(currentBorrowAssets); const { @@ -41,13 +38,13 @@ export function useDeleverageQuote({ isLoading: isLoadingRedeem, error: redeemError, } = useReadContract({ - address: isErc4626 ? route.collateralVault : undefined, + address: route?.collateralVault, abi: erc4626Abi, functionName: 'previewRedeem', args: [withdrawCollateralAmount], chainId, query: { - enabled: isErc4626 && withdrawCollateralAmount > 0n, + enabled: !!route && withdrawCollateralAmount > 0n, }, }); @@ -56,51 +53,20 @@ export function useDeleverageQuote({ isLoading: isLoadingWithdraw, error: withdrawError, } = useReadContract({ - address: isErc4626 ? route.collateralVault : undefined, + address: route?.collateralVault, abi: erc4626Abi, functionName: 'previewWithdraw', args: [bufferedBorrowAssets], chainId, query: { - enabled: isErc4626 && bufferedBorrowAssets > 0n, - }, - }); - - const { - data: stEthByWstEth, - isLoading: isLoadingStEthRedeem, - error: stEthRedeemError, - } = useReadContract({ - address: isStEth ? route.collateralToken : undefined, - abi: wstEthAbi, - functionName: 'getStETHByWstETH', - args: [withdrawCollateralAmount], - chainId, - query: { - enabled: isStEth && withdrawCollateralAmount > 0n, - }, - }); - - const { - data: wstEthByStEthDebt, - isLoading: isLoadingWstDebt, - error: stEthDebtError, - } = useReadContract({ - address: isStEth ? route.collateralToken : undefined, - abi: wstEthAbi, - functionName: 'getWstETHByStETH', - args: [bufferedBorrowAssets], - chainId, - query: { - enabled: isStEth && bufferedBorrowAssets > 0n, + enabled: !!route && bufferedBorrowAssets > 0n, }, }); const rawRouteRepayAmount = useMemo(() => { if (!route || withdrawCollateralAmount <= 0n) return 0n; - if (route.kind === 'erc4626') return (erc4626PreviewRedeem as bigint | undefined) ?? 0n; - return (stEthByWstEth as bigint | undefined) ?? 0n; - }, [route, withdrawCollateralAmount, erc4626PreviewRedeem, stEthByWstEth]); + return (erc4626PreviewRedeem as bigint | undefined) ?? 0n; + }, [route, withdrawCollateralAmount, erc4626PreviewRedeem]); const repayAmount = useMemo(() => { if (rawRouteRepayAmount <= 0n) return 0n; @@ -109,23 +75,17 @@ export function useDeleverageQuote({ const maxCollateralForDebtRepay = useMemo(() => { if (!route || currentBorrowAssets <= 0n) return 0n; - if (route.kind === 'erc4626') return (erc4626PreviewWithdrawForDebt as bigint | undefined) ?? 0n; - return (wstEthByStEthDebt as bigint | undefined) ?? 0n; - }, [route, currentBorrowAssets, erc4626PreviewWithdrawForDebt, wstEthByStEthDebt]); + return (erc4626PreviewWithdrawForDebt as bigint | undefined) ?? 0n; + }, [route, currentBorrowAssets, erc4626PreviewWithdrawForDebt]); const error = useMemo(() => { if (!route) return null; - const routeError = route.kind === 'erc4626' ? (redeemError ?? withdrawError) : (stEthRedeemError ?? stEthDebtError); + const routeError = redeemError ?? withdrawError; if (!routeError) return null; return routeError instanceof Error ? routeError.message : 'Failed to quote deleverage route'; - }, [route, redeemError, withdrawError, stEthRedeemError, stEthDebtError]); + }, [route, redeemError, withdrawError]); - const isLoading = - route?.kind === 'erc4626' - ? isLoadingRedeem || isLoadingWithdraw - : route?.kind === 'steth' - ? isLoadingStEthRedeem || isLoadingWstDebt - : false; + const isLoading = !!route && (isLoadingRedeem || isLoadingWithdraw); return { repayAmount, diff --git a/src/hooks/useDeleverageTransaction.ts b/src/hooks/useDeleverageTransaction.ts index b2754eb9..f9becd7a 100644 --- a/src/hooks/useDeleverageTransaction.ts +++ b/src/hooks/useDeleverageTransaction.ts @@ -115,11 +115,6 @@ export function useDeleverageTransaction({ return; } - if (route.kind === 'steth' && route.loanMode !== 'steth') { - toast.info('Unsupported route', 'This stETH route supports leverage only. Deleverage is unavailable.'); - return; - } - try { const txs: `0x${string}`[] = []; @@ -179,24 +174,14 @@ export function useDeleverageTransaction({ }), ]; - if (route.kind === 'erc4626') { - const minAssetsOut = withSlippageFloor(flashLoanAmount); - callbackTxs.push( - encodeFunctionData({ - abi: morphoBundlerAbi, - functionName: 'erc4626Redeem', - args: [route.collateralVault, withdrawCollateralAmount, minAssetsOut, bundlerAddress as Address, bundlerAddress as Address], - }), - ); - } else { - callbackTxs.push( - encodeFunctionData({ - abi: morphoBundlerAbi, - functionName: 'unwrapStEth', - args: [withdrawCollateralAmount], - }), - ); - } + const minAssetsOut = withSlippageFloor(flashLoanAmount); + callbackTxs.push( + encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'erc4626Redeem', + args: [route.collateralVault, withdrawCollateralAmount, minAssetsOut, bundlerAddress as Address, bundlerAddress as Address], + }), + ); if (autoWithdrawCollateralAmount > 0n) { // WHY: if deleverage fully clears debt, keeping collateral locked in Morpho adds friction. diff --git a/src/hooks/useLeverageQuote.ts b/src/hooks/useLeverageQuote.ts index f60c6693..0944658f 100644 --- a/src/hooks/useLeverageQuote.ts +++ b/src/hooks/useLeverageQuote.ts @@ -1,7 +1,6 @@ import { useMemo } from 'react'; import { useReadContract } from 'wagmi'; import { erc4626Abi } from '@/abis/erc4626'; -import { wstEthAbi } from '@/abis/wsteth'; import { computeFlashCollateralAmount } from './leverage/math'; import type { LeverageRoute } from './leverage/types'; @@ -25,10 +24,6 @@ export type LeverageQuote = { * * - `flashCollateralAmount`: extra collateral target sourced via the flash leg * - `flashLoanAmount`: debt token flash amount needed to mint that extra collateral - * - * First-principles route semantics: - * - ERC4626: multiplier applies to collateral shares, then `previewMint` quotes underlying debt assets. - * - stETH/wstETH: multiplier applies to stETH-equivalent collateral exposure, then we map debt assets back to wstETH shares. */ export function useLeverageQuote({ chainId, route, userCollateralAmount, multiplierBps }: UseLeverageQuoteParams): LeverageQuote { const targetFlashCollateralAmount = useMemo( @@ -41,73 +36,34 @@ export function useLeverageQuote({ chainId, route, userCollateralAmount, multipl isLoading: isLoadingErc4626, error: erc4626Error, } = useReadContract({ - address: route?.kind === 'erc4626' ? route.collateralVault : undefined, + address: route?.collateralVault, abi: erc4626Abi, functionName: 'previewMint', args: [targetFlashCollateralAmount], chainId, query: { - enabled: route?.kind === 'erc4626' && targetFlashCollateralAmount > 0n, - }, - }); - - const { - data: userStEthAmount, - isLoading: isLoadingUserStEthAmount, - error: userStEthAmountError, - } = useReadContract({ - address: route?.kind === 'steth' ? route.collateralToken : undefined, - abi: wstEthAbi, - functionName: 'getStETHByWstETH', - args: [userCollateralAmount], - chainId, - query: { - enabled: route?.kind === 'steth' && userCollateralAmount > 0n, - }, - }); - - const stEthFlashLoanAmount = useMemo(() => { - if (route?.kind !== 'steth') return 0n; - const stEthCollateralAmount = (userStEthAmount as bigint | undefined) ?? 0n; - return computeFlashCollateralAmount(stEthCollateralAmount, multiplierBps); - }, [route, userStEthAmount, multiplierBps]); - - const { - data: wstEthByStEth, - isLoading: isLoadingWstEthByStEth, - error: wstEthByStEthError, - } = useReadContract({ - address: route?.kind === 'steth' ? route.collateralToken : undefined, - abi: wstEthAbi, - functionName: 'getWstETHByStETH', - args: [stEthFlashLoanAmount], - chainId, - query: { - enabled: route?.kind === 'steth' && stEthFlashLoanAmount > 0n, + enabled: !!route && targetFlashCollateralAmount > 0n, }, }); const flashCollateralAmount = useMemo(() => { if (!route) return 0n; - if (route.kind === 'erc4626') return targetFlashCollateralAmount; - return (wstEthByStEth as bigint | undefined) ?? 0n; - }, [route, targetFlashCollateralAmount, wstEthByStEth]); + return targetFlashCollateralAmount; + }, [route, targetFlashCollateralAmount]); const flashLoanAmount = useMemo(() => { if (!route) return 0n; - if (route.kind === 'erc4626') return (erc4626PreviewMint as bigint | undefined) ?? 0n; - return stEthFlashLoanAmount; - }, [route, erc4626PreviewMint, stEthFlashLoanAmount]); + return (erc4626PreviewMint as bigint | undefined) ?? 0n; + }, [route, erc4626PreviewMint]); const error = useMemo(() => { if (!route) return null; - const e = route.kind === 'erc4626' ? erc4626Error : (userStEthAmountError ?? wstEthByStEthError); + const e = erc4626Error; if (!e) return null; return e instanceof Error ? e.message : 'Failed to quote leverage route'; - }, [route, erc4626Error, userStEthAmountError, wstEthByStEthError]); + }, [route, erc4626Error]); - const isLoading = - route?.kind === 'erc4626' ? isLoadingErc4626 : route?.kind === 'steth' ? isLoadingUserStEthAmount || isLoadingWstEthByStEth : false; + const isLoading = !!route && isLoadingErc4626; return { flashCollateralAmount, diff --git a/src/hooks/useLeverageSupport.ts b/src/hooks/useLeverageSupport.ts index c1013a14..05f64c30 100644 --- a/src/hooks/useLeverageSupport.ts +++ b/src/hooks/useLeverageSupport.ts @@ -2,10 +2,8 @@ import { useMemo } from 'react'; import { type Address, isAddressEqual, zeroAddress } from 'viem'; import { useReadContracts } from 'wagmi'; import { erc4626Abi } from '@/abis/erc4626'; -import { getCanonicalStEthAddress, getCanonicalWethAddress, getCanonicalWstEthAddress } from '@/types/token'; -import { SupportedNetworks } from '@/utils/networks'; import type { Market } from '@/utils/types'; -import type { LeverageSupport, StEthLeverageRoute, Erc4626LeverageRoute } from './leverage/types'; +import type { Erc4626LeverageRoute, LeverageSupport } from './leverage/types'; type UseLeverageSupportParams = { market: Market; @@ -14,20 +12,13 @@ type UseLeverageSupportParams = { /** * Detects whether a market can be levered/delevered with deterministic V2 routes. * - * Supported routes: + * Supported route: * - ERC4626 collateral where `vault.asset() == loanToken` - * - wstETH collateral paired with either: - * - stETH loan (leverage + deleverage), or - * - mainnet WETH loan (leverage only via `unwrapNative -> stakeEth -> wrapStEth`) */ export function useLeverageSupport({ market }: UseLeverageSupportParams): LeverageSupport { - const chainId = market.morphoBlue.chain.id; const loanToken = market.loanAsset.address as Address; const collateralToken = market.collateralAsset.address as Address; - const canonicalWstEth = getCanonicalWstEthAddress(chainId); - const canonicalStEth = getCanonicalStEthAddress(chainId); - const canonicalWeth = getCanonicalWethAddress(chainId); - const supportsStEthBundlerRoute = chainId === SupportedNetworks.Mainnet; + const chainId = market.morphoBlue.chain.id; const { data, isLoading, isRefetching } = useReadContracts({ contracts: [ @@ -48,7 +39,6 @@ export function useLeverageSupport({ market }: UseLeverageSupportParams): Levera return useMemo((): LeverageSupport => { const erc4626Asset = data?.[0]?.result as Address | undefined; const hasErc4626Asset = !!erc4626Asset && erc4626Asset !== zeroAddress; - const isCanonicalWstEthCollateral = !!canonicalWstEth && isAddressEqual(collateralToken, canonicalWstEth); if (hasErc4626Asset && isAddressEqual(erc4626Asset, loanToken)) { const route: Erc4626LeverageRoute = { @@ -67,85 +57,13 @@ export function useLeverageSupport({ market }: UseLeverageSupportParams): Levera }; } - if (isCanonicalWstEthCollateral) { - // `stakeEth` / `wrapStEth` / `unwrapStEth` only exist on the mainnet BundlerV2 variant. - // ChainAgnosticBundlerV2 does not expose these selectors, so we block route discovery up-front. - if (!supportsStEthBundlerRoute) { - return { - isSupported: false, - supportsLeverage: false, - supportsDeleverage: false, - isLoading: isLoading || isRefetching, - route: null, - reason: 'stETH leverage routes are currently supported on mainnet Bundler V2 only.', - }; - } - - if (!canonicalStEth || canonicalStEth === zeroAddress) { - return { - isSupported: false, - supportsLeverage: false, - supportsDeleverage: false, - isLoading: isLoading || isRefetching, - route: null, - reason: 'stETH route addresses are not configured for this chain.', - }; - } - - if (canonicalStEth && isAddressEqual(canonicalStEth, loanToken)) { - const route: StEthLeverageRoute = { - kind: 'steth', - collateralToken, - stEthToken: canonicalStEth, - loanMode: 'steth', - }; - - return { - isSupported: true, - supportsLeverage: true, - supportsDeleverage: true, - isLoading: isLoading || isRefetching, - route, - reason: null, - }; - } - - if (canonicalWeth && isAddressEqual(loanToken, canonicalWeth)) { - const route: StEthLeverageRoute = { - kind: 'steth', - collateralToken, - stEthToken: canonicalStEth, - loanMode: 'mainnet-weth-steth-wsteth', - }; - - return { - isSupported: true, - supportsLeverage: true, - supportsDeleverage: false, - isLoading: isLoading || isRefetching, - route, - reason: 'This market supports leverage only. Deleverage requires a direct stETH-denominated debt token.', - }; - } - } - return { isSupported: false, supportsLeverage: false, supportsDeleverage: false, isLoading: isLoading || isRefetching, route: null, - reason: 'Leverage is only available for ERC4626-underlying routes or stETH/wstETH routes on Bundler V2.', + reason: 'Leverage is currently available only for ERC4626-underlying routes on Bundler V2.', }; - }, [ - collateralToken, - loanToken, - canonicalStEth, - canonicalWeth, - canonicalWstEth, - data, - isLoading, - isRefetching, - supportsStEthBundlerRoute, - ]); + }, [collateralToken, loanToken, data, isLoading, isRefetching]); } diff --git a/src/hooks/useLeverageTransaction.ts b/src/hooks/useLeverageTransaction.ts index 9f8c8953..16a83506 100644 --- a/src/hooks/useLeverageTransaction.ts +++ b/src/hooks/useLeverageTransaction.ts @@ -1,5 +1,5 @@ import { useCallback } from 'react'; -import { type Address, encodeAbiParameters, encodeFunctionData, maxUint256, zeroAddress } from 'viem'; +import { type Address, encodeAbiParameters, encodeFunctionData, maxUint256 } from 'viem'; import { useConnection } from 'wagmi'; import morphoBundlerAbi from '@/abis/bundlerV2'; import { useERC20Approval } from '@/hooks/useERC20Approval'; @@ -12,7 +12,6 @@ import { useUserMarketsCache } from '@/stores/useUserMarketsCache'; import { useAppSettings } from '@/stores/useAppSettings'; import { formatBalance } from '@/utils/balance'; import { getBundlerV2, MONARCH_TX_IDENTIFIER } from '@/utils/morpho'; -import { getNativeTokenSymbol } from '@/utils/networks'; import type { Market } from '@/utils/types'; import { computeBorrowSharesWithBuffer, withSlippageFloor } from './leverage/math'; import type { LeverageRoute } from './leverage/types'; @@ -32,19 +31,17 @@ type UseLeverageTransactionProps = { collateralAmountInCollateralToken: bigint; flashCollateralAmount: bigint; flashLoanAmount: bigint; - useEth: boolean; useLoanAssetAsInput: boolean; onSuccess?: () => void; }; /** - * Executes a Bundler V2 leverage transaction in a single multicall. + * Executes an ERC4626 leverage loop in Bundler V2. * - * Important design choice: - * We flash-loan the *loan token* (not collateral), convert it to collateral, - * supply all collateral, then borrow the same loan amount back to the bundler - * so Morpho can pull repayment after callback. This avoids intermediate dust - * accounting and keeps flows deterministic for V2 supported routes. + * Flow: + * 1) transfer user input token (collateral shares or loan-asset underlying) + * 2) optionally deposit upfront underlying into ERC4626 collateral shares + * 3) flash-loan loan token, mint more collateral shares, supply collateral, then borrow back the flash amount */ export function useLeverageTransaction({ market, @@ -53,7 +50,6 @@ export function useLeverageTransaction({ collateralAmountInCollateralToken, flashCollateralAmount, flashLoanAmount, - useEth, useLoanAssetAsInput, onSuccess, }: UseLeverageTransactionProps) { @@ -63,14 +59,11 @@ export function useLeverageTransaction({ const toast = useStyledToast(); const bundlerAddress = getBundlerV2(market.morphoBlue.chain.id); const { batchAddUserMarkets } = useUserMarketsCache(account); - const supportsNativeCollateralInput = route.kind === 'steth' && route.loanMode === 'mainnet-weth-steth-wsteth'; - const isErc4626LoanAssetInput = route.kind === 'erc4626' && useLoanAssetAsInput; - const inputTokenAddress = isErc4626LoanAssetInput ? (market.loanAsset.address as Address) : (market.collateralAsset.address as Address); - const inputTokenSymbol = isErc4626LoanAssetInput ? market.loanAsset.symbol : market.collateralAsset.symbol; - const inputTokenDecimals = isErc4626LoanAssetInput ? market.loanAsset.decimals : market.collateralAsset.decimals; - const inputTokenAmountForTransfer = useEth ? 0n : isErc4626LoanAssetInput ? collateralAmount : collateralAmountInCollateralToken; - const inputDisplaySymbol = useEth ? getNativeTokenSymbol(market.morphoBlue.chain.id) : inputTokenSymbol; - const inputDisplayDecimals = useEth ? 18 : inputTokenDecimals; + const isLoanAssetInput = useLoanAssetAsInput; + const inputTokenAddress = isLoanAssetInput ? (market.loanAsset.address as Address) : (market.collateralAsset.address as Address); + const inputTokenSymbol = isLoanAssetInput ? market.loanAsset.symbol : market.collateralAsset.symbol; + const inputTokenDecimals = isLoanAssetInput ? market.loanAsset.decimals : market.collateralAsset.decimals; + const inputTokenAmountForTransfer = isLoanAssetInput ? collateralAmount : collateralAmountInCollateralToken; const { isBundlerAuthorized, isAuthorizingBundler, ensureBundlerAuthorization, refetchIsBundlerAuthorized } = useBundlerAuthorizationStep( { @@ -104,7 +97,7 @@ export function useLeverageTransaction({ const { isConfirming: leveragePending, sendTransactionAsync } = useTransactionWithToast({ toastId: 'leverage', - pendingText: `Leveraging ${formatBalance(collateralAmount, inputDisplayDecimals)} ${inputDisplaySymbol}`, + pendingText: `Leveraging ${formatBalance(collateralAmount, inputTokenDecimals)} ${inputTokenSymbol}`, successText: 'Leverage Executed', errorText: 'Failed to execute leverage', chainId, @@ -117,22 +110,7 @@ export function useLeverageTransaction({ }); const getStepsForFlow = useCallback( - (isEth: boolean, isPermit2: boolean) => { - if (isEth) { - return [ - { - id: 'authorize_bundler_sig', - title: 'Authorize Morpho Bundler', - description: 'Sign a message to authorize the bundler for Morpho actions.', - }, - { - id: 'execute', - title: 'Confirm Leverage', - description: 'Confirm the leverage transaction in your wallet.', - }, - ]; - } - + (isPermit2: boolean) => { if (isPermit2) { return [ { @@ -185,11 +163,6 @@ export function useLeverageTransaction({ return; } - if (useEth && !supportsNativeCollateralInput) { - toast.info('Unsupported route', 'Native ETH collateral input is only available on the mainnet WETH -> stETH -> wstETH route.'); - return; - } - if (collateralAmount <= 0n || flashLoanAmount <= 0n || flashCollateralAmount <= 0n) { toast.info('Invalid leverage inputs', 'Set collateral and multiplier above 1x before submitting.'); return; @@ -198,31 +171,7 @@ export function useLeverageTransaction({ try { const txs: `0x${string}`[] = []; - if (useEth) { - tracking.update('authorize_bundler_sig'); - const { authorizationTxData } = await ensureBundlerAuthorization({ mode: 'signature' }); - if (authorizationTxData) { - txs.push(authorizationTxData); - await new Promise((resolve) => setTimeout(resolve, 800)); - } - - // WHY: user collateral comes in native ETH for this specific route, so we convert it to wstETH - // before running the flashloan callback that adds the looped collateral leg. - txs.push( - encodeFunctionData({ - abi: morphoBundlerAbi, - functionName: 'stakeEth', - args: [collateralAmount, 1n, zeroAddress], - }), - ); - txs.push( - encodeFunctionData({ - abi: morphoBundlerAbi, - functionName: 'wrapStEth', - args: [maxUint256], - }), - ); - } else if (usePermit2Setting) { + if (usePermit2Setting) { tracking.update('approve_permit2'); if (!permit2Authorized) { await authorizePermit2(); @@ -238,7 +187,6 @@ export function useLeverageTransaction({ tracking.update('sign_permit'); const { sigs, permitSingle } = await signForBundlers(); - txs.push( encodeFunctionData({ abi: morphoBundlerAbi, @@ -260,8 +208,7 @@ export function useLeverageTransaction({ } } - // User input transfer is done before flashloan so callback can supply all at once. - if (!useEth && inputTokenAmountForTransfer > 0n) { + if (inputTokenAmountForTransfer > 0n) { txs.push( encodeFunctionData({ abi: morphoBundlerAbi, @@ -271,9 +218,9 @@ export function useLeverageTransaction({ ); } - if (isErc4626LoanAssetInput) { - // WHY: allow users holding loan-token underlying to start leverage directly. - // We mint collateral shares from their upfront underlying before the flash loop leg. + if (isLoanAssetInput) { + // WHY: this lets users start with loan-token underlying for ERC4626 markets. + // We mint shares first so all leverage math and downstream Morpho collateral is in share units. txs.push( encodeFunctionData({ abi: morphoBundlerAbi, @@ -288,51 +235,12 @@ export function useLeverageTransaction({ ); } - const callbackTxs: `0x${string}`[] = []; - - if (route.kind === 'erc4626') { - // Spend the full flash-loaned underlying to mint collateral shares. - callbackTxs.push( - encodeFunctionData({ - abi: morphoBundlerAbi, - functionName: 'erc4626Deposit', - args: [route.collateralVault, flashLoanAmount, withSlippageFloor(flashCollateralAmount), bundlerAddress as Address], - }), - ); - } else if (route.loanMode === 'steth') { - callbackTxs.push( - encodeFunctionData({ - abi: morphoBundlerAbi, - functionName: 'wrapStEth', - args: [flashLoanAmount], - }), - ); - } else { - // Dedicated mainnet WETH route: unwrap WETH -> stake ETH -> wrap stETH into wstETH collateral. - callbackTxs.push( - encodeFunctionData({ - abi: morphoBundlerAbi, - functionName: 'unwrapNative', - args: [flashLoanAmount], - }), - ); - callbackTxs.push( - encodeFunctionData({ - abi: morphoBundlerAbi, - functionName: 'stakeEth', - args: [flashLoanAmount, 1n, zeroAddress], - }), - ); - callbackTxs.push( - encodeFunctionData({ - abi: morphoBundlerAbi, - functionName: 'wrapStEth', - args: [maxUint256], - }), - ); - } - - callbackTxs.push( + const callbackTxs: `0x${string}`[] = [ + encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'erc4626Deposit', + args: [route.collateralVault, flashLoanAmount, withSlippageFloor(flashCollateralAmount), bundlerAddress as Address], + }), encodeFunctionData({ abi: morphoBundlerAbi, functionName: 'morphoSupplyCollateral', @@ -349,7 +257,7 @@ export function useLeverageTransaction({ '0x', ], }), - ); + ]; const maxBorrowShares = computeBorrowSharesWithBuffer({ borrowAssets: flashLoanAmount, @@ -377,7 +285,6 @@ export function useLeverageTransaction({ }), ); - // Bundler callback decodes flash data as abi.decode(data, (bytes[])). const flashLoanCallbackData = encodeAbiParameters([{ type: 'bytes[]' }], [callbackTxs]); txs.push( encodeFunctionData({ @@ -388,7 +295,6 @@ export function useLeverageTransaction({ ); tracking.update('execute'); - await new Promise((resolve) => setTimeout(resolve, 800)); await sendTransactionAsync({ @@ -399,7 +305,7 @@ export function useLeverageTransaction({ functionName: 'multicall', args: [txs], }) + MONARCH_TX_IDENTIFIER) as `0x${string}`, - value: useEth ? collateralAmount : 0n, + value: 0n, }); batchAddUserMarkets([ @@ -425,12 +331,9 @@ export function useLeverageTransaction({ collateralAmountInCollateralToken, inputTokenAmountForTransfer, inputTokenAddress, - isErc4626LoanAssetInput, + isLoanAssetInput, flashCollateralAmount, flashLoanAmount, - useEth, - useLoanAssetAsInput, - supportsNativeCollateralInput, usePermit2Setting, permit2Authorized, authorizePermit2, @@ -452,13 +355,13 @@ export function useLeverageTransaction({ } try { - const initialStep = useEth ? 'authorize_bundler_sig' : usePermit2Setting ? 'approve_permit2' : 'authorize_bundler_tx'; + const initialStep = usePermit2Setting ? 'approve_permit2' : 'authorize_bundler_tx'; tracking.start( - getStepsForFlow(useEth, usePermit2Setting), + getStepsForFlow(usePermit2Setting), { title: 'Leverage', description: `${market.collateralAsset.symbol} leveraged using ${market.loanAsset.symbol} debt`, - tokenSymbol: inputDisplaySymbol, + tokenSymbol: inputTokenSymbol, amount: collateralAmount, marketId: market.uniqueKey, }, @@ -479,7 +382,7 @@ export function useLeverageTransaction({ toast.error('Error', 'An unexpected error occurred'); } } - }, [account, useEth, usePermit2Setting, tracking, getStepsForFlow, market, inputDisplaySymbol, collateralAmount, executeLeverage, toast]); + }, [account, usePermit2Setting, tracking, getStepsForFlow, market, inputTokenSymbol, collateralAmount, executeLeverage, toast]); const signAndLeverage = useCallback(async () => { if (!account) { @@ -487,18 +390,13 @@ export function useLeverageTransaction({ return; } - if (useEth) { - await approveAndLeverage(); - return; - } - try { tracking.start( - getStepsForFlow(false, usePermit2Setting), + getStepsForFlow(usePermit2Setting), { title: 'Leverage', description: `${market.collateralAsset.symbol} leveraged using ${market.loanAsset.symbol} debt`, - tokenSymbol: inputDisplaySymbol, + tokenSymbol: inputTokenSymbol, amount: collateralAmount, marketId: market.uniqueKey, }, @@ -519,19 +417,7 @@ export function useLeverageTransaction({ toast.error('Transaction Error', 'An unexpected error occurred'); } } - }, [ - account, - useEth, - approveAndLeverage, - tracking, - getStepsForFlow, - usePermit2Setting, - market, - inputDisplaySymbol, - collateralAmount, - executeLeverage, - toast, - ]); + }, [account, tracking, getStepsForFlow, usePermit2Setting, market, inputTokenSymbol, collateralAmount, executeLeverage, toast]); const isLoading = leveragePending || isLoadingPermit2 || isApproving || isAuthorizingBundler; diff --git a/src/modals/leverage/components/add-collateral-and-leverage.tsx b/src/modals/leverage/components/add-collateral-and-leverage.tsx index dd6626cb..30c44eb2 100644 --- a/src/modals/leverage/components/add-collateral-and-leverage.tsx +++ b/src/modals/leverage/components/add-collateral-and-leverage.tsx @@ -1,27 +1,24 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { erc20Abi } from 'viem'; -import { useBalance, useConnection, useReadContract } from 'wagmi'; +import { useConnection, useReadContract } from 'wagmi'; +import { BorrowPositionRiskCard } from '@/modals/borrow/components/borrow-position-risk-card'; +import { computeLtv, formatLtvPercent, getLTVColor } from '@/modals/borrow/components/helpers'; import Input from '@/components/Input/Input'; +import { LTVWarning } from '@/components/shared/ltv-warning'; +import { TokenIcon } from '@/components/shared/token-icon'; import { ExecuteTransactionButton } from '@/components/ui/ExecuteTransactionButton'; -import { Button } from '@/components/ui/button'; import { IconSwitch } from '@/components/ui/icon-switch'; import { Tooltip } from '@/components/ui/tooltip'; -import { LTVWarning } from '@/components/shared/ltv-warning'; -import { TokenIcon } from '@/components/shared/token-icon'; import { erc4626Abi } from '@/abis/erc4626'; -import { wstEthAbi } from '@/abis/wsteth'; +import { clampMultiplierBps, formatMultiplierBps, parseMultiplierToBps } from '@/hooks/leverage/math'; +import { LEVERAGE_DEFAULT_MULTIPLIER_BPS } from '@/hooks/leverage/types'; import { useLeverageQuote } from '@/hooks/useLeverageQuote'; import { useLeverageTransaction } from '@/hooks/useLeverageTransaction'; import { useAppSettings } from '@/stores/useAppSettings'; import { formatBalance } from '@/utils/balance'; -import { getNativeTokenSymbol } from '@/utils/networks'; import { formatCompactTokenAmount, formatFullTokenAmount } from '@/utils/token-amount-format'; -import type { Market, MarketPosition } from '@/utils/types'; import type { LeverageSupport } from '@/hooks/leverage/types'; -import { computeLtv, formatLtvPercent, getLTVColor } from '@/modals/borrow/components/helpers'; -import { BorrowPositionRiskCard } from '@/modals/borrow/components/borrow-position-risk-card'; -import { clampMultiplierBps, formatMultiplierBps, parseMultiplierToBps } from '@/hooks/leverage/math'; -import { LEVERAGE_DEFAULT_MULTIPLIER_BPS } from '@/hooks/leverage/types'; +import type { Market, MarketPosition } from '@/utils/types'; type AddCollateralAndLeverageProps = { market: Market; @@ -31,12 +28,9 @@ type AddCollateralAndLeverageProps = { oraclePrice: bigint; onSuccess?: () => void; isRefreshing?: boolean; - showDeleverageManualRepayNotice?: boolean; }; const MULTIPLIER_INPUT_REGEX = /^\d*\.?\d*$/; -const STETH_ETH_DELEVERAGE_NOTICE_KEY = 'hasReadStEthEthDeleverageNotice'; -const STETH_DISPLAY_SYMBOL = 'stETH'; export function AddCollateralAndLeverage({ market, @@ -46,7 +40,6 @@ export function AddCollateralAndLeverage({ oraclePrice, onSuccess, isRefreshing = false, - showDeleverageManualRepayNotice = false, }: AddCollateralAndLeverageProps): JSX.Element { const route = support.route; const { address: account } = useConnection(); @@ -55,23 +48,11 @@ export function AddCollateralAndLeverage({ const [collateralAmount, setCollateralAmount] = useState(0n); const [collateralInputError, setCollateralInputError] = useState(null); const [multiplierInput, setMultiplierInput] = useState(formatMultiplierBps(LEVERAGE_DEFAULT_MULTIPLIER_BPS)); - const [useEth, setUseEth] = useState(false); const [useLoanAssetInput, setUseLoanAssetInput] = useState(false); - const [showRouteNotice, setShowRouteNotice] = useState(false); const multiplierBps = useMemo(() => clampMultiplierBps(parseMultiplierToBps(multiplierInput)), [multiplierInput]); - - const isMainnetEthStEthRoute = route?.kind === 'steth' && route.loanMode === 'mainnet-weth-steth-wsteth'; const isErc4626Route = route?.kind === 'erc4626'; - const { data: nativeBalance } = useBalance({ - address: account as `0x${string}` | undefined, - chainId: market.morphoBlue.chain.id, - query: { - enabled: !!account && isMainnetEthStEthRoute, - }, - }); - const { data: loanTokenBalance } = useReadContract({ address: market.loanAsset.address as `0x${string}`, args: [account as `0x${string}`], @@ -83,66 +64,17 @@ export function AddCollateralAndLeverage({ }, }); - const { - data: convertedWstEthAmount, - isLoading: isLoadingEthToWstEthConversion, - error: ethToWstEthConversionError, - } = useReadContract({ - address: route?.kind === 'steth' ? route.collateralToken : undefined, - abi: wstEthAbi, - functionName: 'getWstETHByStETH', - args: [collateralAmount], - chainId: market.morphoBlue.chain.id, - query: { - enabled: isMainnetEthStEthRoute && useEth && collateralAmount > 0n, - }, - }); - - useEffect(() => { - // ETH and wstETH inputs have different units. Reset the amount to avoid accidental cross-unit reuse. - setCollateralAmount(0n); - setCollateralInputError(null); - }, [useEth]); - useEffect(() => { // Underlying and collateral shares use different units. Reset amount when switching input source. setCollateralAmount(0n); setCollateralInputError(null); }, [useLoanAssetInput]); - useEffect(() => { - if (isMainnetEthStEthRoute) return; - setUseEth(false); - }, [isMainnetEthStEthRoute]); - useEffect(() => { if (isErc4626Route) return; setUseLoanAssetInput(false); }, [isErc4626Route]); - useEffect(() => { - if (!showDeleverageManualRepayNotice) { - setShowRouteNotice(false); - return; - } - - const hasReadNotice = localStorage.getItem(STETH_ETH_DELEVERAGE_NOTICE_KEY) === 'true'; - setShowRouteNotice(!hasReadNotice); - }, [showDeleverageManualRepayNotice]); - - const handleDismissRouteNotice = useCallback(() => { - localStorage.setItem(STETH_ETH_DELEVERAGE_NOTICE_KEY, 'true'); - setShowRouteNotice(false); - }, []); - - const collateralAmountInCollateralToken = useMemo(() => { - // WHY: leverage math is always computed in the market collateral token units. - // For ETH input mode, we first map user ETH -> wstETH before applying multiplier, - // so target collateral remains deterministic with the actual supplied token. - if (!useEth) return collateralAmount; - return (convertedWstEthAmount as bigint | undefined) ?? 0n; - }, [useEth, collateralAmount, convertedWstEthAmount]); - const { data: previewCollateralSharesFromUnderlying, isLoading: isLoadingUnderlyingToCollateralConversion, @@ -150,7 +82,7 @@ export function AddCollateralAndLeverage({ } = useReadContract({ // WHY: for ERC4626 "start with loan asset" mode, user input is underlying assets. // We convert to collateral shares first so multiplier/flash math stays in collateral units. - address: route?.kind === 'erc4626' ? route.collateralVault : undefined, + address: route?.collateralVault, abi: erc4626Abi, functionName: 'previewDeposit', args: [collateralAmount], @@ -161,22 +93,16 @@ export function AddCollateralAndLeverage({ }); const collateralAmountForLeverageQuote = useMemo(() => { - if (useEth) return collateralAmountInCollateralToken; if (useLoanAssetInput) return (previewCollateralSharesFromUnderlying as bigint | undefined) ?? 0n; return collateralAmount; - }, [useEth, useLoanAssetInput, collateralAmountInCollateralToken, previewCollateralSharesFromUnderlying, collateralAmount]); + }, [useLoanAssetInput, previewCollateralSharesFromUnderlying, collateralAmount]); const conversionErrorMessage = useMemo(() => { - if (useEth && ethToWstEthConversionError) { - return ethToWstEthConversionError instanceof Error ? ethToWstEthConversionError.message : 'Failed to quote ETH to wstETH conversion.'; - } - if (useLoanAssetInput && underlyingToCollateralConversionError) { - return underlyingToCollateralConversionError instanceof Error - ? underlyingToCollateralConversionError.message - : 'Failed to quote loan asset to collateral conversion.'; - } - return null; - }, [useEth, ethToWstEthConversionError, useLoanAssetInput, underlyingToCollateralConversionError]); + if (!useLoanAssetInput || !underlyingToCollateralConversionError) return null; + return underlyingToCollateralConversionError instanceof Error + ? underlyingToCollateralConversionError.message + : 'Failed to quote loan asset to collateral conversion.'; + }, [useLoanAssetInput, underlyingToCollateralConversionError]); const quote = useLeverageQuote({ chainId: market.morphoBlue.chain.id, @@ -191,65 +117,6 @@ export function AddCollateralAndLeverage({ const projectedBorrowAssets = currentBorrowAssets + quote.flashLoanAmount; const lltv = BigInt(market.lltv); const marketLiquidity = BigInt(market.state.liquidityAssets); - const isEthInputStEthRoute = isMainnetEthStEthRoute && useEth && route?.kind === 'steth'; - - const { data: currentCollateralInStEth } = useReadContract({ - address: route?.kind === 'steth' ? route.collateralToken : undefined, - abi: wstEthAbi, - functionName: 'getStETHByWstETH', - args: [currentCollateralAssets], - chainId: market.morphoBlue.chain.id, - query: { - enabled: isEthInputStEthRoute && currentCollateralAssets > 0n, - }, - }); - - const addedCollateralInStEth = useMemo(() => { - if (!isEthInputStEthRoute) return 0n; - // WHY: for ETH input mode, leverage target is defined on ETH/stETH exposure. - // Added collateral in stETH terms is user ETH leg + flash-borrowed stETH-equivalent debt leg. - return collateralAmount + quote.flashLoanAmount; - }, [isEthInputStEthRoute, collateralAmount, quote.flashLoanAmount]); - - const canUseStEthDisplayForPosition = useMemo(() => { - if (!isEthInputStEthRoute) return false; - if (currentCollateralAssets === 0n) return true; - return currentCollateralInStEth !== undefined; - }, [isEthInputStEthRoute, currentCollateralAssets, currentCollateralInStEth]); - - const previewCurrentCollateral = useMemo(() => { - if (!isEthInputStEthRoute || !canUseStEthDisplayForPosition) return currentCollateralAssets; - if (currentCollateralAssets === 0n) return 0n; - return (currentCollateralInStEth as bigint | undefined) ?? 0n; - }, [isEthInputStEthRoute, canUseStEthDisplayForPosition, currentCollateralAssets, currentCollateralInStEth]); - - const previewProjectedCollateral = useMemo(() => { - if (!isEthInputStEthRoute || !canUseStEthDisplayForPosition) return projectedCollateralAssets; - return previewCurrentCollateral + addedCollateralInStEth; - }, [isEthInputStEthRoute, canUseStEthDisplayForPosition, projectedCollateralAssets, previewCurrentCollateral, addedCollateralInStEth]); - - const previewMarket = useMemo(() => { - if (!isEthInputStEthRoute || !canUseStEthDisplayForPosition || route?.kind !== 'steth') return market; - return { - ...market, - collateralAsset: { - ...market.collateralAsset, - address: route.stEthToken, - symbol: STETH_DISPLAY_SYMBOL, - decimals: 18, - }, - }; - }, [isEthInputStEthRoute, canUseStEthDisplayForPosition, route, market]); - - const transactionPreviewCollateralAmount = useMemo(() => { - if (!isEthInputStEthRoute) return quote.totalAddedCollateral; - return addedCollateralInStEth; - }, [isEthInputStEthRoute, quote.totalAddedCollateral, addedCollateralInStEth]); - - const transactionPreviewCollateralAddress = - isEthInputStEthRoute && route?.kind === 'steth' ? route.stEthToken : market.collateralAsset.address; - const transactionPreviewCollateralSymbol = isEthInputStEthRoute ? STETH_DISPLAY_SYMBOL : market.collateralAsset.symbol; - const transactionPreviewCollateralDecimals = isEthInputStEthRoute ? 18 : market.collateralAsset.decimals; const projectedLTV = useMemo( () => @@ -272,7 +139,7 @@ export function AddCollateralAndLeverage({ ); const handleTransactionSuccess = useCallback(() => { - // WHY: after a confirmed leverage tx, we reset draft inputs so the panel reflects the live position state. + // WHY: after a confirmed leverage tx, reset drafts so the panel reflects refreshed onchain position state. setCollateralAmount(0n); setCollateralInputError(null); setMultiplierInput(formatMultiplierBps(LEVERAGE_DEFAULT_MULTIPLIER_BPS)); @@ -287,7 +154,6 @@ export function AddCollateralAndLeverage({ collateralAmountInCollateralToken: collateralAmountForLeverageQuote, flashCollateralAmount: quote.flashCollateralAmount, flashLoanAmount: quote.flashLoanAmount, - useEth, useLoanAssetAsInput: useLoanAssetInput, onSuccess: handleTransactionSuccess, }); @@ -303,11 +169,6 @@ export function AddCollateralAndLeverage({ }, [multiplierInput]); const handleLeverage = useCallback(() => { - if (useEth) { - void approveAndLeverage(); - return; - } - if (usePermit2Setting && permit2Authorized) { void signAndLeverage(); return; @@ -317,32 +178,16 @@ export function AddCollateralAndLeverage({ return; } void approveAndLeverage(); - }, [useEth, usePermit2Setting, permit2Authorized, signAndLeverage, isApproved, approveAndLeverage]); + }, [usePermit2Setting, permit2Authorized, signAndLeverage, isApproved, approveAndLeverage]); const projectedOverLimit = projectedLTV >= lltv; const insufficientLiquidity = quote.flashLoanAmount > marketLiquidity; const hasChanges = collateralAmountForLeverageQuote > 0n && quote.flashLoanAmount > 0n; - const inputAssetSymbol = useEth - ? getNativeTokenSymbol(market.morphoBlue.chain.id) - : useLoanAssetInput - ? market.loanAsset.symbol - : market.collateralAsset.symbol; - const inputAssetDecimals = useEth ? 18 : useLoanAssetInput ? market.loanAsset.decimals : market.collateralAsset.decimals; - const inputAssetBalance = useEth - ? nativeBalance?.value - : useLoanAssetInput - ? (loanTokenBalance as bigint | undefined) - : collateralTokenBalance; - const inputTokenIconAddress = useEth - ? market.loanAsset.address - : useLoanAssetInput - ? market.loanAsset.address - : market.collateralAsset.address; - const isLoadingInputConversion = useEth - ? isLoadingEthToWstEthConversion - : useLoanAssetInput - ? isLoadingUnderlyingToCollateralConversion - : false; + const inputAssetSymbol = useLoanAssetInput ? market.loanAsset.symbol : market.collateralAsset.symbol; + const inputAssetDecimals = useLoanAssetInput ? market.loanAsset.decimals : market.collateralAsset.decimals; + const inputAssetBalance = useLoanAssetInput ? (loanTokenBalance as bigint | undefined) : collateralTokenBalance; + const inputTokenIconAddress = useLoanAssetInput ? market.loanAsset.address : market.collateralAsset.address; + const isLoadingInputConversion = useLoanAssetInput && isLoadingUnderlyingToCollateralConversion; return (
@@ -350,10 +195,10 @@ export function AddCollateralAndLeverage({

Leverage Preview

- {useLoanAssetInput - ? `Mints ${market.collateralAsset.symbol} collateral` - : `Add Collateral ${inputAssetSymbol}`} + {useLoanAssetInput ? `Start with ${market.loanAsset.symbol}` : `Add Collateral ${market.collateralAsset.symbol}`}

- {isMainnetEthStEthRoute && ( -
-
Use {getNativeTokenSymbol(market.morphoBlue.chain.id)}
- -
- )} {isErc4626Route && (
-
Start with {market.loanAsset.symbol}
+
Use {market.loanAsset.symbol}
- {formatFullTokenAmount(transactionPreviewCollateralAmount, transactionPreviewCollateralDecimals)} + {formatFullTokenAmount(quote.totalAddedCollateral, market.collateralAsset.decimals)} } > - {formatCompactTokenAmount(transactionPreviewCollateralAmount, transactionPreviewCollateralDecimals)} + {formatCompactTokenAmount(quote.totalAddedCollateral, market.collateralAsset.decimals)} @@ -510,22 +338,6 @@ export function AddCollateralAndLeverage({
- {showRouteNotice && ( -
-

- Deleverage is not supported for this stETH-ETH route yet. To reduce exposure, manually repay debt for now. -

- -
- )} -
{ - if (support.route?.kind === 'steth' && support.route.loanMode === 'mainnet-weth-steth-wsteth') return 'steth-eth' as const; - if (support.route?.kind === 'erc4626') return 'erc4626-leverage' as const; - return 'other' as const; - }, [support.route]); - - const isStEthEthLeverageType = leveragePanelType === 'steth-eth'; const effectiveMode = mode; const modeOptions = [ { value: 'leverage', label: `Leverage ${market.collateralAsset.symbol}` }, @@ -127,7 +120,6 @@ export function LeverageModal({ oraclePrice={oraclePrice} onSuccess={handleRefreshAll} isRefreshing={isRefreshingAnyData} - showDeleverageManualRepayNotice={isStEthEthLeverageType} /> ) : support.supportsDeleverage ? ( > = { [SupportedNetworks.Mainnet]: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', [SupportedNetworks.Base]: '0x4200000000000000000000000000000000000006', @@ -30,26 +26,6 @@ export const WETH_BY_CHAIN: Partial> = { [SupportedNetworks.Monad]: '0xEE8c0E9f1BFFb4Eb878d8f15f368A02a35481242', }; -export const WSTETH_BY_CHAIN: Partial> = { - [SupportedNetworks.Mainnet]: '0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0', - [SupportedNetworks.Base]: '0xc1cba3fcea344f92d9239c08c0568f6f2f0ee452', - [SupportedNetworks.Unichain]: '0xc02fe7317d4eb8753a02c35fe019786854a92001', - [SupportedNetworks.Arbitrum]: '0x5979D7b546E38E414F7E9822514be443A4800529', - [SupportedNetworks.Monad]: '0x10Aeaf63194db8d453d4D85a06E5eFE1dd0b5417', -}; - -export const STETH_BY_CHAIN: Partial> = { - [SupportedNetworks.Mainnet]: '0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84', -}; - export const getCanonicalWethAddress = (chainId: number): Address | undefined => { return WETH_BY_CHAIN[chainId as SupportedNetworks]; }; - -export const getCanonicalWstEthAddress = (chainId: number): Address | undefined => { - return WSTETH_BY_CHAIN[chainId as SupportedNetworks]; -}; - -export const getCanonicalStEthAddress = (chainId: number): Address | undefined => { - return STETH_BY_CHAIN[chainId as SupportedNetworks]; -}; From c98420fce18b9f6d1243800c51c0b04f4e389f02 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Wed, 25 Feb 2026 23:34:08 +0800 Subject: [PATCH 04/10] feat: cleanup --- src/modals/borrow/borrow-modal.tsx | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/src/modals/borrow/borrow-modal.tsx b/src/modals/borrow/borrow-modal.tsx index 3868c7c6..55c5863d 100644 --- a/src/modals/borrow/borrow-modal.tsx +++ b/src/modals/borrow/borrow-modal.tsx @@ -1,7 +1,8 @@ import { useCallback, useEffect, useState } from 'react'; -import { BsFillLightningFill } from 'react-icons/bs'; +import { TbTrendingDown, TbTrendingUp } from 'react-icons/tb'; import { useConnection, useReadContract, useBalance } from 'wagmi'; import { erc20Abi } from 'viem'; +import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Modal, ModalHeader, ModalBody } from '@/components/common/Modal'; import { ModalIntentSwitcher } from '@/components/common/Modal/ModalIntentSwitcher'; @@ -45,14 +46,18 @@ export function BorrowModal({ setMode(defaultMode); }, [defaultMode]); + const leverageModalMode = mode === 'repay' ? 'deleverage' : 'leverage'; + const canOpenLeverageModal = + !leverageSupport.isLoading && (mode === 'borrow' ? leverageSupport.supportsLeverage : leverageSupport.supportsDeleverage); + const handleOpenLeverage = useCallback(() => { openModal('leverage', { market, refetch, - defaultMode: 'leverage', + defaultMode: leverageModalMode, }); onOpenChange(false); - }, [openModal, market, refetch, onOpenChange]); + }, [openModal, market, refetch, leverageModalMode, onOpenChange]); // Get token balances const { @@ -160,15 +165,22 @@ export function BorrowModal({ : `Repay ${market.loanAsset.symbol} debt to reduce risk and unlock ${market.collateralAsset.symbol} collateral.` } actions={ - !leverageSupport.isLoading && leverageSupport.supportsLeverage ? ( + canOpenLeverageModal ? ( ) : undefined } From f2ec82eaf4eba60f9e4c3e2d8761f4880b8cf82c Mon Sep 17 00:00:00 2001 From: antoncoding Date: Thu, 26 Feb 2026 11:33:10 +0800 Subject: [PATCH 05/10] chore: review feedbacks --- src/hooks/useDeleverageTransaction.ts | 7 ++++++- src/hooks/useLeverageTransaction.ts | 7 ++++++- src/modals/borrow/borrow-modal-global.tsx | 5 +++-- src/modals/borrow/borrow-modal.tsx | 13 ++++++++----- .../components/add-collateral-and-leverage.tsx | 2 +- .../remove-collateral-and-deleverage.tsx | 2 +- src/modals/leverage/leverage-modal-global.tsx | 5 +++-- src/modals/leverage/leverage-modal.tsx | 17 ++++++++++++----- 8 files changed, 40 insertions(+), 18 deletions(-) diff --git a/src/hooks/useDeleverageTransaction.ts b/src/hooks/useDeleverageTransaction.ts index f9becd7a..2eeb8641 100644 --- a/src/hooks/useDeleverageTransaction.ts +++ b/src/hooks/useDeleverageTransaction.ts @@ -18,7 +18,7 @@ export type DeleverageStepType = 'authorize_bundler_sig' | 'authorize_bundler_tx type UseDeleverageTransactionProps = { market: Market; - route: LeverageRoute; + route: LeverageRoute | null; withdrawCollateralAmount: bigint; flashLoanAmount: bigint; repayBySharesAmount: bigint; @@ -110,6 +110,11 @@ export function useDeleverageTransaction({ return; } + if (!route) { + toast.info('Unsupported route', 'This market is not supported for deleverage.'); + return; + } + if (withdrawCollateralAmount <= 0n || flashLoanAmount <= 0n) { toast.info('Invalid deleverage inputs', 'Set a collateral unwind amount above zero.'); return; diff --git a/src/hooks/useLeverageTransaction.ts b/src/hooks/useLeverageTransaction.ts index 16a83506..2f8e5a9e 100644 --- a/src/hooks/useLeverageTransaction.ts +++ b/src/hooks/useLeverageTransaction.ts @@ -26,7 +26,7 @@ export type LeverageStepType = type UseLeverageTransactionProps = { market: Market; - route: LeverageRoute; + route: LeverageRoute | null; collateralAmount: bigint; collateralAmountInCollateralToken: bigint; flashCollateralAmount: bigint; @@ -163,6 +163,11 @@ export function useLeverageTransaction({ return; } + if (!route) { + toast.info('Unsupported route', 'This market is not supported for leverage.'); + return; + } + if (collateralAmount <= 0n || flashLoanAmount <= 0n || flashCollateralAmount <= 0n) { toast.info('Invalid leverage inputs', 'Set collateral and multiplier above 1x before submitting.'); return; diff --git a/src/modals/borrow/borrow-modal-global.tsx b/src/modals/borrow/borrow-modal-global.tsx index dd66653e..5d3fe236 100644 --- a/src/modals/borrow/borrow-modal-global.tsx +++ b/src/modals/borrow/borrow-modal-global.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useCallback } from 'react'; import { useConnection } from 'wagmi'; import type { Market } from '@/utils/types'; import type { LiquiditySourcingResult } from '@/hooks/useMarketLiquiditySourcing'; @@ -39,10 +40,10 @@ export function BorrowModalGlobal({ const { position, refetch: refetchPosition } = useUserPosition(address, chainId, market.uniqueKey); - const handleRefetch = () => { + const handleRefetch = useCallback(() => { refetchPosition(); externalRefetch?.(); - }; + }, [refetchPosition, externalRefetch]); return ( (() => defaultMode); @@ -49,6 +49,12 @@ export function BorrowModal({ const leverageModalMode = mode === 'repay' ? 'deleverage' : 'leverage'; const canOpenLeverageModal = !leverageSupport.isLoading && (mode === 'borrow' ? leverageSupport.supportsLeverage : leverageSupport.supportsDeleverage); + const modeOptions: { value: string; label: string }[] = toggleBorrowRepay + ? [ + { value: 'borrow', label: `Borrow ${market.loanAsset.symbol}` }, + { value: 'repay', label: `Repay ${market.loanAsset.symbol}` }, + ] + : [{ value: mode, label: mode === 'borrow' ? `Borrow ${market.loanAsset.symbol}` : `Repay ${market.loanAsset.symbol}` }]; const handleOpenLeverage = useCallback(() => { openModal('leverage', { @@ -152,10 +158,7 @@ export function BorrowModal({ title={ setMode(nextMode as 'borrow' | 'repay')} /> } diff --git a/src/modals/leverage/components/add-collateral-and-leverage.tsx b/src/modals/leverage/components/add-collateral-and-leverage.tsx index 30c44eb2..3622fa4d 100644 --- a/src/modals/leverage/components/add-collateral-and-leverage.tsx +++ b/src/modals/leverage/components/add-collateral-and-leverage.tsx @@ -149,7 +149,7 @@ export function AddCollateralAndLeverage({ const { transaction, isLoadingPermit2, isApproved, permit2Authorized, leveragePending, approveAndLeverage, signAndLeverage } = useLeverageTransaction({ market, - route: route!, + route, collateralAmount, collateralAmountInCollateralToken: collateralAmountForLeverageQuote, flashCollateralAmount: quote.flashCollateralAmount, diff --git a/src/modals/leverage/components/remove-collateral-and-deleverage.tsx b/src/modals/leverage/components/remove-collateral-and-deleverage.tsx index ec73277c..e4e5f751 100644 --- a/src/modals/leverage/components/remove-collateral-and-deleverage.tsx +++ b/src/modals/leverage/components/remove-collateral-and-deleverage.tsx @@ -89,7 +89,7 @@ export function RemoveCollateralAndDeleverage({ const { transaction, deleveragePending, authorizeAndDeleverage } = useDeleverageTransaction({ market, - route: route!, + route, withdrawCollateralAmount, flashLoanAmount: flashLoanAmountForTx, repayBySharesAmount, diff --git a/src/modals/leverage/leverage-modal-global.tsx b/src/modals/leverage/leverage-modal-global.tsx index 37b03513..4c0cb4a7 100644 --- a/src/modals/leverage/leverage-modal-global.tsx +++ b/src/modals/leverage/leverage-modal-global.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useCallback } from 'react'; import { useConnection } from 'wagmi'; import { useOraclePrice } from '@/hooks/useOraclePrice'; import useUserPosition from '@/hooks/useUserPosition'; @@ -36,10 +37,10 @@ export function LeverageModalGlobal({ const { position, refetch: refetchPosition } = useUserPosition(address, chainId, market.uniqueKey); - const handleRefetch = () => { + const handleRefetch = useCallback(() => { refetchPosition(); externalRefetch?.(); - }; + }, [refetchPosition, externalRefetch]); return ( (defaultMode); const { address: account } = useConnection(); const support = useLeverageSupport({ market }); const effectiveMode = mode; - const modeOptions = [ - { value: 'leverage', label: `Leverage ${market.collateralAsset.symbol}` }, - { value: 'deleverage', label: `Deleverage ${market.collateralAsset.symbol}` }, - ]; + const modeOptions: { value: string; label: string }[] = toggleLeverageDeleverage + ? [ + { value: 'leverage', label: `Leverage ${market.collateralAsset.symbol}` }, + { value: 'deleverage', label: `Deleverage ${market.collateralAsset.symbol}` }, + ] + : [ + { + value: effectiveMode, + label: effectiveMode === 'leverage' ? `Leverage ${market.collateralAsset.symbol}` : `Deleverage ${market.collateralAsset.symbol}`, + }, + ]; const { data: collateralTokenBalance, From d16560ea6af07f0214330be11f3d03ca46454dc7 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Thu, 26 Feb 2026 12:08:17 +0800 Subject: [PATCH 06/10] chore: cleanup math and agent files --- AGENTS.md | 33 ++++- src/hooks/leverage/math.ts | 133 +++++++++++++++--- src/hooks/useLeverageTransaction.ts | 29 +++- .../add-collateral-and-leverage.tsx | 53 +++---- .../remove-collateral-and-deleverage.tsx | 87 ++++++------ src/utils/token-amount-format.ts | 50 +------ 6 files changed, 241 insertions(+), 144 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8a2dbfef..e42ed2e3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -87,7 +87,7 @@ The skill injects detailed patterns and conventions into the conversation contex --- -## First-Principles Self-Review (Before Proposing Fixes) +## Self-Review (Before Proposing Fixes) Before proposing a solution, add a short self-review: @@ -117,7 +117,9 @@ Plan format: ## MANDATORY: Validate After Every Implementation Step -**STOP after each implementation step and validate before moving on.** This is NOT optional. Do NOT batch all validation to the end. +> **CRITICAL GATE** +> STOP after each implementation step and validate before moving on. +> This is NOT optional. Do NOT batch all validation to the end. After each step, ask out loud: 1. Are all changes necessary? Could this be done more simply? @@ -127,6 +129,31 @@ After each step, ask out loud: Running `tsc` and lint is NOT validation — those are mechanical checks. Validation means **thinking from first principles** about whether the code is correct, simple, and necessary. +### REQUIRED: High-Impact Flow Validation + +When touching transaction and position flows, validation MUST include all relevant checks below (not just the changed line): + +1. **Canonical identity matching**: use canonical IDs/addresses (see `src/types/token.ts`), never symbol/name for logic or route matching. +2. **Shared math/conversion helpers only**: reuse `src/hooks/leverage/math.ts`, `src/utils/repay-estimation.ts`, `src/hooks/useRepayTransaction.ts`, and `src/modals/borrow/components/helpers.ts` (`computeLtv`) instead of ad hoc formulas. +3. **Computation-backed previews**: previews must be built from real oracle/quote/conversion paths and match tx-builder inputs. +4. **Stepper/state-machine correctness**: first step must match runtime auth/signature state, and step order must never go backwards. +5. **Post-transaction state hygiene**: on success reset draft inputs and trigger required refetches without loops/unbounded re-renders. +6. **Display formatting discipline**: use shared formatting utilities from `src/utils/token-amount-format.ts` (`formatCompactTokenAmount`, `formatFullTokenAmount`) and existing readable-amount helpers consistently. +7. **UI clarity and duplication checks**: remove duplicate/redundant/low-signal data and keep only decision-critical information. +8. **Null/data-corruption resilience**: guard null/undefined/stale API/contract fields so malformed data fails gracefully. +9. **Runtime guards on optional config/routes**: avoid unsafe non-null assertions in tx-critical paths; unsupported routes/config must degrade gracefully. + +### REQUIRED: Regression Rule Capture + +After fixing any user-reported bug in a high-impact flow: + +1. Add or update at least one validation bullet in this document if the bug exposed a new failure pattern. +2. State explicitly in the final response: + - root cause category, + - why prior validation missed it, + - which new validation rule now prevents recurrence. +3. Prefer chokepoint validations that protect all related components, not just the touched file. + --- ## Code Quality Standards @@ -178,8 +205,6 @@ Running `tsc` and lint is NOT validation — those are mechanical checks. Valida - Avoid spread syntax in accumulators within loops - Use top-level regex literals instead of creating them in loops - Prefer specific imports over namespace imports -- Avoid barrel files (index files that re-export everything) -- Use proper image components (e.g., Next.js ``) over `` tags ### Framework-Specific Guidance diff --git a/src/hooks/leverage/math.ts b/src/hooks/leverage/math.ts index 586e632a..834cd11e 100644 --- a/src/hooks/leverage/math.ts +++ b/src/hooks/leverage/math.ts @@ -1,8 +1,12 @@ +import { formatUnits } from 'viem'; import { LEVERAGE_MAX_MULTIPLIER_BPS, LEVERAGE_MIN_MULTIPLIER_BPS, LEVERAGE_MULTIPLIER_SCALE_BPS } from './types'; export const LEVERAGE_SLIPPAGE_BUFFER_BPS = 9_950n; // 0.50% tolerance -export const LTV_WAD = 10n ** 18n; -export const ORACLE_PRICE_SCALE = 10n ** 36n; +const COMPACT_AMOUNT_LOCALE = 'en-US'; +const COMPACT_AMOUNT_MIN_THRESHOLD = 0.000001; + +const minBigInt = (a: bigint, b: bigint): bigint => (a < b ? a : b); +const floorSub = (value: bigint, subtract: bigint): bigint => (value > subtract ? value - subtract : 0n); export const clampMultiplierBps = (value: bigint): bigint => { if (value < LEVERAGE_MIN_MULTIPLIER_BPS) return LEVERAGE_MIN_MULTIPLIER_BPS; @@ -35,31 +39,122 @@ export const computeFlashCollateralAmount = (userCollateralAmount: bigint, multi return leveragedCollateral > userCollateralAmount ? leveragedCollateral - userCollateralAmount : 0n; }; -export const computeProjectedLtv = ({ +export const computeLeverageProjectedPosition = ({ currentBorrowAssets, - borrowDelta, currentCollateralAssets, - collateralDelta, - oraclePrice, + addedBorrowAssets, + addedCollateralAssets, }: { currentBorrowAssets: bigint; - borrowDelta: bigint; currentCollateralAssets: bigint; - collateralDelta: bigint; - oraclePrice: bigint; -}): bigint => { - const projectedBorrowAssets = currentBorrowAssets + borrowDelta; - const projectedCollateralAssets = currentCollateralAssets + collateralDelta; - - if (projectedBorrowAssets <= 0n) return 0n; - if (projectedCollateralAssets <= 0n || oraclePrice <= 0n) return 10n ** 30n; - - const collateralValueInLoan = (projectedCollateralAssets * oraclePrice) / ORACLE_PRICE_SCALE; - if (collateralValueInLoan <= 0n) return 10n ** 30n; + addedBorrowAssets: bigint; + addedCollateralAssets: bigint; +}): { projectedBorrowAssets: bigint; projectedCollateralAssets: bigint } => ({ + projectedBorrowAssets: currentBorrowAssets + addedBorrowAssets, + projectedCollateralAssets: currentCollateralAssets + addedCollateralAssets, +}); + +export type DeleverageProjectedPosition = { + closesDebt: boolean; + repayBySharesAmount: bigint; + flashLoanAmountForTx: bigint; + autoWithdrawCollateralAmount: bigint; + projectedCollateralAssets: bigint; + projectedBorrowAssets: bigint; + previewDebtRepaid: bigint; + maxWithdrawCollateral: bigint; +}; - return (projectedBorrowAssets * LTV_WAD) / collateralValueInLoan; +export const computeDeleverageProjectedPosition = ({ + currentCollateralAssets, + currentBorrowAssets, + currentBorrowShares, + withdrawCollateralAmount, + rawRouteRepayAmount, + repayAmount, + maxCollateralForDebtRepay, +}: { + currentCollateralAssets: bigint; + currentBorrowAssets: bigint; + currentBorrowShares: bigint; + withdrawCollateralAmount: bigint; + rawRouteRepayAmount: bigint; + repayAmount: bigint; + maxCollateralForDebtRepay: bigint; +}): DeleverageProjectedPosition => { + const maxWithdrawCollateral = minBigInt(maxCollateralForDebtRepay, currentCollateralAssets); + const boundedWithdrawCollateral = minBigInt(withdrawCollateralAmount, currentCollateralAssets); + const projectedCollateralAfterInput = floorSub(currentCollateralAssets, boundedWithdrawCollateral); + const closesDebt = currentBorrowAssets > 0n && repayAmount >= currentBorrowAssets; + const repayBySharesAmount = closesDebt ? currentBorrowShares : 0n; + const flashLoanAmountForTx = closesDebt ? rawRouteRepayAmount : repayAmount; + const autoWithdrawCollateralAmount = closesDebt ? projectedCollateralAfterInput : 0n; + const projectedCollateralAssets = closesDebt ? 0n : projectedCollateralAfterInput; + const projectedBorrowAssets = floorSub(currentBorrowAssets, repayAmount); + const previewDebtRepaid = closesDebt ? currentBorrowAssets : repayAmount; + + return { + closesDebt, + repayBySharesAmount, + flashLoanAmountForTx, + autoWithdrawCollateralAmount, + projectedCollateralAssets, + projectedBorrowAssets, + previewDebtRepaid, + maxWithdrawCollateral, + }; }; +export function formatFullTokenAmount(value: bigint, decimals: number): string { + const formattedUnits = formatUnits(value, decimals); + const [integerPart, fractionalPart = ''] = formattedUnits.split('.'); + const hasNegativeSign = integerPart.startsWith('-'); + const unsignedIntegerPart = hasNegativeSign ? integerPart.slice(1) : integerPart; + const groupedIntegerPart = unsignedIntegerPart.replace(/\B(?=(\d{3})+(?!\d))/g, ','); + const trimmedFractionalPart = fractionalPart.replace(/0+$/, ''); + + if (trimmedFractionalPart.length > 0) { + return `${hasNegativeSign ? '-' : ''}${groupedIntegerPart}.${trimmedFractionalPart}`; + } + + return `${hasNegativeSign ? '-' : ''}${groupedIntegerPart}`; +} + +export function formatCompactTokenAmount(value: bigint, decimals: number): string { + if (value === 0n) return '0'; + + const numericValue = Number(formatUnits(value, decimals)); + if (!Number.isFinite(numericValue)) return formatUnits(value, decimals); + + const absoluteValue = Math.abs(numericValue); + + if (absoluteValue >= 1000) { + return new Intl.NumberFormat(COMPACT_AMOUNT_LOCALE, { + notation: 'compact', + maximumFractionDigits: 2, + }).format(numericValue); + } + + if (absoluteValue >= 1) { + return numericValue.toLocaleString(COMPACT_AMOUNT_LOCALE, { + maximumFractionDigits: 4, + }); + } + + if (absoluteValue >= COMPACT_AMOUNT_MIN_THRESHOLD) { + return numericValue.toLocaleString(COMPACT_AMOUNT_LOCALE, { + maximumSignificantDigits: 4, + }); + } + + return `<${COMPACT_AMOUNT_MIN_THRESHOLD}`; +} + +export const formatTokenAmountPreview = (value: bigint, decimals: number): { compact: string; full: string } => ({ + compact: formatCompactTokenAmount(value, decimals), + full: formatFullTokenAmount(value, decimals), +}); + export const withSlippageFloor = (value: bigint): bigint => { if (value <= 0n) return 0n; const floored = (value * LEVERAGE_SLIPPAGE_BUFFER_BPS) / LEVERAGE_MULTIPLIER_SCALE_BPS; diff --git a/src/hooks/useLeverageTransaction.ts b/src/hooks/useLeverageTransaction.ts index 2f8e5a9e..290b5cbe 100644 --- a/src/hooks/useLeverageTransaction.ts +++ b/src/hooks/useLeverageTransaction.ts @@ -177,13 +177,15 @@ export function useLeverageTransaction({ const txs: `0x${string}`[] = []; if (usePermit2Setting) { - tracking.update('approve_permit2'); if (!permit2Authorized) { + tracking.update('approve_permit2'); await authorizePermit2(); await new Promise((resolve) => setTimeout(resolve, 800)); } - tracking.update('authorize_bundler_sig'); + if (!isBundlerAuthorized) { + tracking.update('authorize_bundler_sig'); + } const { authorizationTxData } = await ensureBundlerAuthorization({ mode: 'signature' }); if (authorizationTxData) { txs.push(authorizationTxData); @@ -341,6 +343,7 @@ export function useLeverageTransaction({ flashLoanAmount, usePermit2Setting, permit2Authorized, + isBundlerAuthorized, authorizePermit2, ensureBundlerAuthorization, signForBundlers, @@ -396,6 +399,12 @@ export function useLeverageTransaction({ } try { + const initialStep: LeverageStepType = permit2Authorized + ? isBundlerAuthorized + ? 'sign_permit' + : 'authorize_bundler_sig' + : 'approve_permit2'; + tracking.start( getStepsForFlow(usePermit2Setting), { @@ -405,7 +414,7 @@ export function useLeverageTransaction({ amount: collateralAmount, marketId: market.uniqueKey, }, - 'sign_permit', + initialStep, ); await executeLeverage(); @@ -422,7 +431,19 @@ export function useLeverageTransaction({ toast.error('Transaction Error', 'An unexpected error occurred'); } } - }, [account, tracking, getStepsForFlow, usePermit2Setting, market, inputTokenSymbol, collateralAmount, executeLeverage, toast]); + }, [ + account, + tracking, + getStepsForFlow, + usePermit2Setting, + permit2Authorized, + isBundlerAuthorized, + market, + inputTokenSymbol, + collateralAmount, + executeLeverage, + toast, + ]); const isLoading = leveragePending || isLoadingPermit2 || isApproving || isAuthorizingBundler; diff --git a/src/modals/leverage/components/add-collateral-and-leverage.tsx b/src/modals/leverage/components/add-collateral-and-leverage.tsx index 3622fa4d..f9e33dc2 100644 --- a/src/modals/leverage/components/add-collateral-and-leverage.tsx +++ b/src/modals/leverage/components/add-collateral-and-leverage.tsx @@ -10,13 +10,18 @@ import { ExecuteTransactionButton } from '@/components/ui/ExecuteTransactionButt import { IconSwitch } from '@/components/ui/icon-switch'; import { Tooltip } from '@/components/ui/tooltip'; import { erc4626Abi } from '@/abis/erc4626'; -import { clampMultiplierBps, formatMultiplierBps, parseMultiplierToBps } from '@/hooks/leverage/math'; +import { + clampMultiplierBps, + computeLeverageProjectedPosition, + formatMultiplierBps, + formatTokenAmountPreview, + parseMultiplierToBps, +} from '@/hooks/leverage/math'; import { LEVERAGE_DEFAULT_MULTIPLIER_BPS } from '@/hooks/leverage/types'; import { useLeverageQuote } from '@/hooks/useLeverageQuote'; import { useLeverageTransaction } from '@/hooks/useLeverageTransaction'; import { useAppSettings } from '@/stores/useAppSettings'; import { formatBalance } from '@/utils/balance'; -import { formatCompactTokenAmount, formatFullTokenAmount } from '@/utils/token-amount-format'; import type { LeverageSupport } from '@/hooks/leverage/types'; import type { Market, MarketPosition } from '@/utils/types'; @@ -113,8 +118,16 @@ export function AddCollateralAndLeverage({ const currentCollateralAssets = BigInt(currentPosition?.state.collateral ?? 0); const currentBorrowAssets = BigInt(currentPosition?.state.borrowAssets ?? 0); - const projectedCollateralAssets = currentCollateralAssets + quote.totalAddedCollateral; - const projectedBorrowAssets = currentBorrowAssets + quote.flashLoanAmount; + const { projectedCollateralAssets, projectedBorrowAssets } = useMemo( + () => + computeLeverageProjectedPosition({ + currentCollateralAssets, + currentBorrowAssets, + addedCollateralAssets: quote.totalAddedCollateral, + addedBorrowAssets: quote.flashLoanAmount, + }), + [currentCollateralAssets, currentBorrowAssets, quote.totalAddedCollateral, quote.flashLoanAmount], + ); const lltv = BigInt(market.lltv); const marketLiquidity = BigInt(market.state.liquidityAssets); @@ -188,6 +201,14 @@ export function AddCollateralAndLeverage({ const inputAssetBalance = useLoanAssetInput ? (loanTokenBalance as bigint | undefined) : collateralTokenBalance; const inputTokenIconAddress = useLoanAssetInput ? market.loanAsset.address : market.collateralAsset.address; const isLoadingInputConversion = useLoanAssetInput && isLoadingUnderlyingToCollateralConversion; + const flashBorrowPreview = useMemo( + () => formatTokenAmountPreview(quote.flashLoanAmount, market.loanAsset.decimals), + [quote.flashLoanAmount, market.loanAsset.decimals], + ); + const totalCollateralAddedPreview = useMemo( + () => formatTokenAmountPreview(quote.totalAddedCollateral, market.collateralAsset.decimals), + [quote.totalAddedCollateral, market.collateralAsset.decimals], + ); return (
@@ -278,16 +299,8 @@ export function AddCollateralAndLeverage({
Flash Borrow - - {formatFullTokenAmount(quote.flashLoanAmount, market.loanAsset.decimals)} - - } - > - - {formatCompactTokenAmount(quote.flashLoanAmount, market.loanAsset.decimals)} - + {flashBorrowPreview.full}}> + {flashBorrowPreview.compact} Total Collateral Added - - {formatFullTokenAmount(quote.totalAddedCollateral, market.collateralAsset.decimals)} - - } - > - - {formatCompactTokenAmount(quote.totalAddedCollateral, market.collateralAsset.decimals)} - + {totalCollateralAddedPreview.full}}> + {totalCollateralAddedPreview.compact} { - if (quote.maxCollateralForDebtRepay <= 0n) return 0n; - return quote.maxCollateralForDebtRepay > currentCollateralAssets ? currentCollateralAssets : quote.maxCollateralForDebtRepay; - }, [quote.maxCollateralForDebtRepay, currentCollateralAssets]); - - const projectedCollateralAfterInput = - withdrawCollateralAmount > currentCollateralAssets ? 0n : currentCollateralAssets - withdrawCollateralAmount; - const closesDebt = currentBorrowAssets > 0n && quote.repayAmount >= currentBorrowAssets; - const repayBySharesAmount = closesDebt ? currentBorrowShares : 0n; - const flashLoanAmountForTx = closesDebt ? quote.rawRouteRepayAmount : quote.repayAmount; - const autoWithdrawCollateralAmount = closesDebt ? projectedCollateralAfterInput : 0n; - const projectedCollateralAssets = closesDebt ? 0n : projectedCollateralAfterInput; - const projectedBorrowAssets = quote.repayAmount > currentBorrowAssets ? 0n : currentBorrowAssets - quote.repayAmount; + const projection = useMemo( + () => + computeDeleverageProjectedPosition({ + currentCollateralAssets, + currentBorrowAssets, + currentBorrowShares, + withdrawCollateralAmount, + rawRouteRepayAmount: quote.rawRouteRepayAmount, + repayAmount: quote.repayAmount, + maxCollateralForDebtRepay: quote.maxCollateralForDebtRepay, + }), + [ + currentCollateralAssets, + currentBorrowAssets, + currentBorrowShares, + withdrawCollateralAmount, + quote.rawRouteRepayAmount, + quote.repayAmount, + quote.maxCollateralForDebtRepay, + ], + ); const currentLTV = useMemo( () => @@ -73,11 +81,11 @@ export function RemoveCollateralAndDeleverage({ const projectedLTV = useMemo( () => computeLtv({ - borrowAssets: projectedBorrowAssets, - collateralAssets: projectedCollateralAssets, + borrowAssets: projection.projectedBorrowAssets, + collateralAssets: projection.projectedCollateralAssets, oraclePrice, }), - [projectedBorrowAssets, projectedCollateralAssets, oraclePrice], + [projection.projectedBorrowAssets, projection.projectedCollateralAssets, oraclePrice], ); const handleTransactionSuccess = useCallback(() => { @@ -91,9 +99,9 @@ export function RemoveCollateralAndDeleverage({ market, route, withdrawCollateralAmount, - flashLoanAmount: flashLoanAmountForTx, - repayBySharesAmount, - autoWithdrawCollateralAmount, + flashLoanAmount: projection.flashLoanAmountForTx, + repayBySharesAmount: projection.repayBySharesAmount, + autoWithdrawCollateralAmount: projection.autoWithdrawCollateralAmount, onSuccess: handleTransactionSuccess, }); @@ -104,7 +112,14 @@ export function RemoveCollateralAndDeleverage({ // Treat user input as an intent change immediately so the preview card updates as soon as the amount changes. const hasChanges = withdrawCollateralAmount > 0n; const projectedOverLimit = projectedLTV >= lltv; - const previewDebtRepaid = closesDebt ? currentBorrowAssets : quote.repayAmount; + const flashBorrowPreview = useMemo( + () => formatTokenAmountPreview(projection.flashLoanAmountForTx, market.loanAsset.decimals), + [projection.flashLoanAmountForTx, market.loanAsset.decimals], + ); + const debtRepaidPreview = useMemo( + () => formatTokenAmountPreview(projection.previewDebtRepaid, market.loanAsset.decimals), + [projection.previewDebtRepaid, market.loanAsset.decimals], + ); return (
@@ -130,7 +145,7 @@ export function RemoveCollateralAndDeleverage({

- Max: {formatBalance(maxWithdrawCollateral, market.collateralAsset.decimals)} {market.collateralAsset.symbol} + Max: {formatBalance(projection.maxWithdrawCollateral, market.collateralAsset.decimals)} {market.collateralAsset.symbol}

{withdrawInputError &&

{withdrawInputError}

}
@@ -158,16 +173,8 @@ export function RemoveCollateralAndDeleverage({
Flash Borrow - - {formatFullTokenAmount(flashLoanAmountForTx, market.loanAsset.decimals)} - - } - > - - {formatCompactTokenAmount(flashLoanAmountForTx, market.loanAsset.decimals)} - + {flashBorrowPreview.full}}> + {flashBorrowPreview.compact} Debt Repaid - - {formatFullTokenAmount(previewDebtRepaid, market.loanAsset.decimals)} - - } - > - - {formatCompactTokenAmount(previewDebtRepaid, market.loanAsset.decimals)} - + {debtRepaidPreview.full}}> + {debtRepaidPreview.compact} 0) { - return `${hasNegativeSign ? '-' : ''}${groupedIntegerPart}.${trimmedFractionalPart}`; - } - - return `${hasNegativeSign ? '-' : ''}${groupedIntegerPart}`; -} - -export function formatCompactTokenAmount(value: bigint, decimals: number): string { - if (value === 0n) return '0'; - - const numericValue = Number(formatUnits(value, decimals)); - if (!Number.isFinite(numericValue)) return formatUnits(value, decimals); - - const absoluteValue = Math.abs(numericValue); - - if (absoluteValue >= 1000) { - return new Intl.NumberFormat(COMPACT_AMOUNT_LOCALE, { - notation: 'compact', - maximumFractionDigits: 2, - }).format(numericValue); - } - - if (absoluteValue >= 1) { - return numericValue.toLocaleString(COMPACT_AMOUNT_LOCALE, { - maximumFractionDigits: 4, - }); - } - - if (absoluteValue >= COMPACT_AMOUNT_MIN_THRESHOLD) { - return numericValue.toLocaleString(COMPACT_AMOUNT_LOCALE, { - maximumSignificantDigits: 4, - }); - } - - return `<${COMPACT_AMOUNT_MIN_THRESHOLD}`; -} +export { formatCompactTokenAmount, formatFullTokenAmount } from '@/hooks/leverage/math'; From ae1b4b54426ea4f2b2089fef0aeee78eafd4a228 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Thu, 26 Feb 2026 12:15:21 +0800 Subject: [PATCH 07/10] fix: switch network clean output --- AGENTS.md | 2 +- src/modals/leverage/leverage-modal.tsx | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e42ed2e3..2a68149e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -138,7 +138,7 @@ When touching transaction and position flows, validation MUST include all releva 3. **Computation-backed previews**: previews must be built from real oracle/quote/conversion paths and match tx-builder inputs. 4. **Stepper/state-machine correctness**: first step must match runtime auth/signature state, and step order must never go backwards. 5. **Post-transaction state hygiene**: on success reset draft inputs and trigger required refetches without loops/unbounded re-renders. -6. **Display formatting discipline**: use shared formatting utilities from `src/utils/token-amount-format.ts` (`formatCompactTokenAmount`, `formatFullTokenAmount`) and existing readable-amount helpers consistently. +6. **Display formatting discipline**: use shared formatting utilities from `src/hooks/leverage/math.ts` (`formatCompactTokenAmount`, `formatFullTokenAmount`, `formatTokenAmountPreview`) and existing readable-amount helpers consistently (`src/utils/token-amount-format.ts` is a re-export layer). 7. **UI clarity and duplication checks**: remove duplicate/redundant/low-signal data and keep only decision-critical information. 8. **Null/data-corruption resilience**: guard null/undefined/stale API/contract fields so malformed data fails gracefully. 9. **Runtime guards on optional config/routes**: avoid unsafe non-null assertions in tx-critical paths; unsupported routes/config must degrade gracefully. diff --git a/src/modals/leverage/leverage-modal.tsx b/src/modals/leverage/leverage-modal.tsx index bc1021c5..945fc8e9 100644 --- a/src/modals/leverage/leverage-modal.tsx +++ b/src/modals/leverage/leverage-modal.tsx @@ -115,9 +115,7 @@ export function LeverageModal({ } /> - {support.isLoading ? ( -
Checking leverage route support...
- ) : support.isSupported ? ( + {support.isSupported ? ( effectiveMode === 'leverage' ? ( ) + ) : support.isLoading ? ( +
Checking leverage route support...
) : (
{support.reason ?? 'This market is not supported by the V2 leverage routes.'} From 7a0c0457e3eea174d5b66b09de385c1c3efac186 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Thu, 26 Feb 2026 14:59:52 +0800 Subject: [PATCH 08/10] feat: rate preview --- src/data-sources/morpho-api/market.ts | 4 +- .../components/table/market-table-body.tsx | 5 - src/hooks/leverage/math.ts | 70 ++++++ src/hooks/use4626VaultAPR.ts | 202 ++++++++++++++++++ .../add-collateral-and-leverage.tsx | 67 +++++- src/modals/leverage/leverage-modal-global.tsx | 5 +- src/modals/leverage/leverage-modal.tsx | 31 ++- src/utils/types.ts | 4 +- 8 files changed, 365 insertions(+), 23 deletions(-) create mode 100644 src/hooks/use4626VaultAPR.ts diff --git a/src/data-sources/morpho-api/market.ts b/src/data-sources/morpho-api/market.ts index 4fe936f9..5bdeb4c1 100644 --- a/src/data-sources/morpho-api/market.ts +++ b/src/data-sources/morpho-api/market.ts @@ -3,7 +3,7 @@ import type { SupportedNetworks } from '@/utils/networks'; import { blacklistTokens } from '@/utils/tokens'; import type { Market } from '@/utils/types'; import { morphoGraphqlFetcher } from './fetchers'; -import { zeroAddress } from 'viem'; +import { type Address, zeroAddress } from 'viem'; // API response type - matches the new Morpho API shape where oracleAddress is nested type MorphoApiMarket = Omit & { @@ -39,7 +39,7 @@ const processMarketData = (market: MorphoApiMarket): Market => { const { oracle, listed, ...rest } = market; return { ...rest, - oracleAddress: oracle?.address ?? zeroAddress, + oracleAddress: (oracle?.address ?? zeroAddress) as Address, whitelisted: listed, hasUSDPrice: true, }; diff --git a/src/features/markets/components/table/market-table-body.tsx b/src/features/markets/components/table/market-table-body.tsx index bc70cafc..d5ba55a6 100644 --- a/src/features/markets/components/table/market-table-body.tsx +++ b/src/features/markets/components/table/market-table-body.tsx @@ -9,7 +9,6 @@ import { MarketRiskIndicators } from '@/features/markets/components/market-risk- import OracleVendorBadge from '@/features/markets/components/oracle-vendor-badge'; import { TrustedByCell } from '@/features/autovault/components/trusted-vault-badges'; import { getVaultKey, type TrustedVault } from '@/constants/vaults/known_vaults'; -import { useMarketMetricsMap, getMetricsKey } from '@/hooks/queries/useMarketMetricsQuery'; import { useRateLabel } from '@/hooks/useRateLabel'; import { useStyledToast } from '@/hooks/useStyledToast'; import { useMarketPreferences } from '@/stores/useMarketPreferences'; @@ -29,7 +28,6 @@ type MarketTableBodyProps = { export function MarketTableBody({ currentEntries, expandedRowId, setExpandedRowId, trustedVaultMap }: MarketTableBodyProps) { const { columnVisibility, starredMarkets, starMarket, unstarMarket } = useMarketPreferences(); const { success: toastSuccess } = useStyledToast(); - const { metricsMap } = useMarketMetricsMap(); const { label: supplyRateLabel } = useRateLabel({ prefix: 'Supply' }); const { label: borrowRateLabel } = useRateLabel({ prefix: 'Borrow' }); @@ -93,9 +91,6 @@ export function MarketTableBody({ currentEntries, expandedRowId, setExpandedRowI { - const key = getMetricsKey(item.morphoBlue.chain.id, item.uniqueKey); - const metrics = metricsMap.get(key); - console.log('[Metrics]', key, metrics ?? 'NOT FOUND'); setExpandedRowId(item.uniqueKey === expandedRowId ? null : item.uniqueKey); }} className={`hover:cursor-pointer ${item.uniqueKey === expandedRowId ? 'table-body-focused ' : ''}`} diff --git a/src/hooks/leverage/math.ts b/src/hooks/leverage/math.ts index 834cd11e..90736088 100644 --- a/src/hooks/leverage/math.ts +++ b/src/hooks/leverage/math.ts @@ -4,9 +4,17 @@ import { LEVERAGE_MAX_MULTIPLIER_BPS, LEVERAGE_MIN_MULTIPLIER_BPS, LEVERAGE_MULT export const LEVERAGE_SLIPPAGE_BUFFER_BPS = 9_950n; // 0.50% tolerance const COMPACT_AMOUNT_LOCALE = 'en-US'; const COMPACT_AMOUNT_MIN_THRESHOLD = 0.000001; +const APY_RATIO_SCALE = 1_000_000_000n; +const SECONDS_PER_YEAR = 365 * 24 * 60 * 60; const minBigInt = (a: bigint, b: bigint): bigint => (a < b ? a : b); const floorSub = (value: bigint, subtract: bigint): bigint => (value > subtract ? value - subtract : 0n); +const toScaledRatio = (numerator: bigint, denominator: bigint): number | null => { + if (denominator <= 0n) return null; + const scaledRatio = (numerator * APY_RATIO_SCALE) / denominator; + const ratio = Number(scaledRatio) / Number(APY_RATIO_SCALE); + return Number.isFinite(ratio) ? ratio : null; +}; export const clampMultiplierBps = (value: bigint): bigint => { if (value < LEVERAGE_MIN_MULTIPLIER_BPS) return LEVERAGE_MIN_MULTIPLIER_BPS; @@ -105,6 +113,68 @@ export const computeDeleverageProjectedPosition = ({ }; }; +export const computeAnnualizedApyFromGrowth = ({ + currentValue, + pastValue, + periodSeconds, +}: { + currentValue: bigint; + pastValue: bigint; + periodSeconds: number; +}): number | null => { + if (currentValue <= 0n || pastValue <= 0n || periodSeconds <= 0) return null; + + const growthRatio = toScaledRatio(currentValue, pastValue); + if (growthRatio == null || growthRatio <= 0) return null; + + const annualizationFactor = SECONDS_PER_YEAR / periodSeconds; + const annualizedApy = growthRatio ** annualizationFactor - 1; + + return Number.isFinite(annualizedApy) ? annualizedApy : null; +}; + +export const convertVaultSharesToUnderlyingAssets = ({ + shares, + sharePriceInUnderlying, + oneShareUnit, +}: { + shares: bigint; + sharePriceInUnderlying: bigint; + oneShareUnit: bigint; +}): bigint => { + if (shares <= 0n || sharePriceInUnderlying <= 0n || oneShareUnit <= 0n) return 0n; + return (shares * sharePriceInUnderlying) / oneShareUnit; +}; + +export const computeExpectedNetCarryApy = ({ + collateralShares, + borrowAssets, + sharePriceInUnderlying, + oneShareUnit, + vaultApy, + borrowApy, +}: { + collateralShares: bigint; + borrowAssets: bigint; + sharePriceInUnderlying: bigint; + oneShareUnit: bigint; + vaultApy: number; + borrowApy: number; +}): number | null => { + const collateralUnderlyingAssets = convertVaultSharesToUnderlyingAssets({ + shares: collateralShares, + sharePriceInUnderlying, + oneShareUnit, + }); + if (collateralUnderlyingAssets <= 0n) return null; + + const debtToCollateralRatio = toScaledRatio(borrowAssets, collateralUnderlyingAssets); + if (debtToCollateralRatio == null) return null; + + const netCarryApy = vaultApy - debtToCollateralRatio * borrowApy; + return Number.isFinite(netCarryApy) ? netCarryApy : null; +}; + export function formatFullTokenAmount(value: bigint, decimals: number): string { const formattedUnits = formatUnits(value, decimals); const [integerPart, fractionalPart = ''] = formattedUnits.split('.'); diff --git a/src/hooks/use4626VaultAPR.ts b/src/hooks/use4626VaultAPR.ts new file mode 100644 index 00000000..5caae572 --- /dev/null +++ b/src/hooks/use4626VaultAPR.ts @@ -0,0 +1,202 @@ +import { useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import type { Address, Hex } from 'viem'; +import { erc4626Abi } from '@/abis/erc4626'; +import morphoAbi from '@/abis/morpho'; +import { useCustomRpcContext } from '@/components/providers/CustomRpcProvider'; +import { computeAnnualizedApyFromGrowth, computeExpectedNetCarryApy } from '@/hooks/leverage/math'; +import { estimateBlockAtTimestamp } from '@/utils/blockEstimation'; +import { getMorphoAddress } from '@/utils/morpho'; +import { getClient } from '@/utils/rpc'; +import type { Market } from '@/utils/types'; + +const DEFAULT_LOOKBACK_DAYS = 3; +const BORROW_INDEX_SCALE = 10n ** 18n; +const SECONDS_PER_DAY = 24 * 60 * 60; + +type Use4626VaultAPRParams = { + market: Market; + vaultAddress: Address | undefined; + projectedCollateralShares: bigint; + projectedBorrowAssets: bigint; + lookbackDays?: number; + enabled?: boolean; +}; + +type QueryResult = { + vaultApy3d: number | null; + borrowApy3d: number | null; + sharePriceNow: bigint | null; + periodSeconds: number | null; +}; + +type Use4626VaultAPRResult = QueryResult & { + expectedNetApy: number | null; + isLoading: boolean; + error: string | null; +}; + +const asBigIntArray = (value: unknown): readonly bigint[] | null => { + if (!Array.isArray(value)) return null; + if (!value.every((entry) => typeof entry === 'bigint')) return null; + return value as readonly bigint[]; +}; + +const readBorrowIndex = (marketState: readonly bigint[] | null): bigint | null => { + if (!marketState) return null; + const totalBorrowAssets = marketState[2]; + const totalBorrowShares = marketState[3]; + if (typeof totalBorrowAssets !== 'bigint' || typeof totalBorrowShares !== 'bigint') return null; + if (totalBorrowAssets <= 0n || totalBorrowShares <= 0n) return null; + // WHY: Morpho borrow index is implied by assets/shares growth over time. + return (totalBorrowAssets * BORROW_INDEX_SCALE) / totalBorrowShares; +}; + +export function use4626VaultAPR({ + market, + vaultAddress, + projectedCollateralShares, + projectedBorrowAssets, + lookbackDays = DEFAULT_LOOKBACK_DAYS, + enabled = true, +}: Use4626VaultAPRParams): Use4626VaultAPRResult { + const { customRpcUrls } = useCustomRpcContext(); + const chainId = market.morphoBlue.chain.id; + const customRpcUrl = customRpcUrls[chainId]; + const oneShareUnit = useMemo(() => 10n ** BigInt(market.collateralAsset.decimals), [market.collateralAsset.decimals]); + + const query = useQuery({ + queryKey: ['vault-4626-apr', market.uniqueKey, chainId, vaultAddress, lookbackDays, customRpcUrl], + enabled: enabled && !!vaultAddress, + staleTime: 2 * 60 * 1000, + gcTime: 5 * 60 * 1000, + refetchOnWindowFocus: false, + retry: 1, + queryFn: async () => { + if (!vaultAddress) { + return { + vaultApy3d: null, + borrowApy3d: null, + sharePriceNow: null, + periodSeconds: null, + }; + } + + const client = getClient(chainId, customRpcUrl); + const currentBlock = await client.getBlockNumber(); + const currentBlockData = await client.getBlock({ blockNumber: currentBlock }); + const currentTimestamp = Number(currentBlockData.timestamp); + + const targetTimestamp = currentTimestamp - lookbackDays * SECONDS_PER_DAY; + // WHY: estimate a historical block close to the target window, then annualize using real block timestamps. + const estimatedPastBlock = estimateBlockAtTimestamp(chainId, targetTimestamp, Number(currentBlock), currentTimestamp); + const pastBlockData = await client.getBlock({ blockNumber: BigInt(estimatedPastBlock) }); + const pastTimestamp = Number(pastBlockData.timestamp); + const periodSeconds = currentTimestamp - pastTimestamp; + + if (periodSeconds <= 0) { + return { + vaultApy3d: null, + borrowApy3d: null, + sharePriceNow: null, + periodSeconds: null, + }; + } + + const morphoAddress = getMorphoAddress(chainId); + const contracts = [ + { + address: vaultAddress, + abi: erc4626Abi, + functionName: 'previewRedeem' as const, + args: [oneShareUnit] as const, + }, + { + address: morphoAddress as Address, + abi: morphoAbi, + functionName: 'market' as const, + args: [market.uniqueKey as Hex] as const, + }, + ] as const; + + const currentResults = await client.multicall({ + contracts, + allowFailure: true, + }); + + let pastResults: typeof currentResults | null = null; + try { + pastResults = await client.multicall({ + contracts, + allowFailure: true, + blockNumber: BigInt(estimatedPastBlock), + }); + } catch { + // Some RPCs are non-archive and cannot serve historical eth_call at past blocks. + pastResults = null; + } + + const currentSharePrice = + currentResults[0].status === 'success' && typeof currentResults[0].result === 'bigint' ? currentResults[0].result : null; + const currentBorrowIndex = currentResults[1].status === 'success' ? readBorrowIndex(asBigIntArray(currentResults[1].result)) : null; + + const pastSharePrice = + pastResults?.[0]?.status === 'success' && typeof pastResults[0].result === 'bigint' ? pastResults[0].result : null; + const pastBorrowIndex = pastResults?.[1]?.status === 'success' ? readBorrowIndex(asBigIntArray(pastResults[1].result)) : null; + + const vaultApy3d = + currentSharePrice && pastSharePrice + ? computeAnnualizedApyFromGrowth({ + currentValue: currentSharePrice, + pastValue: pastSharePrice, + periodSeconds, + }) + : null; + + const borrowApy3d = + currentBorrowIndex && pastBorrowIndex + ? computeAnnualizedApyFromGrowth({ + currentValue: currentBorrowIndex, + pastValue: pastBorrowIndex, + periodSeconds, + }) + : null; + + return { + vaultApy3d, + borrowApy3d, + sharePriceNow: currentSharePrice, + periodSeconds, + }; + }, + }); + + const expectedNetApy = useMemo(() => { + if (!query.data?.sharePriceNow || query.data.vaultApy3d == null || query.data.borrowApy3d == null) return null; + return computeExpectedNetCarryApy({ + collateralShares: projectedCollateralShares, + borrowAssets: projectedBorrowAssets, + sharePriceInUnderlying: query.data.sharePriceNow, + oneShareUnit, + vaultApy: query.data.vaultApy3d, + borrowApy: query.data.borrowApy3d, + }); + }, [ + query.data?.sharePriceNow, + query.data?.vaultApy3d, + query.data?.borrowApy3d, + projectedCollateralShares, + projectedBorrowAssets, + oneShareUnit, + ]); + + return { + vaultApy3d: query.data?.vaultApy3d ?? null, + borrowApy3d: query.data?.borrowApy3d ?? null, + sharePriceNow: query.data?.sharePriceNow ?? null, + periodSeconds: query.data?.periodSeconds ?? null, + expectedNetApy, + isLoading: query.isLoading || query.isFetching, + error: query.error instanceof Error ? query.error.message : null, + }; +} diff --git a/src/modals/leverage/components/add-collateral-and-leverage.tsx b/src/modals/leverage/components/add-collateral-and-leverage.tsx index f9e33dc2..4940acb0 100644 --- a/src/modals/leverage/components/add-collateral-and-leverage.tsx +++ b/src/modals/leverage/components/add-collateral-and-leverage.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { erc20Abi } from 'viem'; import { useConnection, useReadContract } from 'wagmi'; import { BorrowPositionRiskCard } from '@/modals/borrow/components/borrow-position-risk-card'; -import { computeLtv, formatLtvPercent, getLTVColor } from '@/modals/borrow/components/helpers'; +import { computeLtv } from '@/modals/borrow/components/helpers'; import Input from '@/components/Input/Input'; import { LTVWarning } from '@/components/shared/ltv-warning'; import { TokenIcon } from '@/components/shared/token-icon'; @@ -18,10 +18,12 @@ import { parseMultiplierToBps, } from '@/hooks/leverage/math'; import { LEVERAGE_DEFAULT_MULTIPLIER_BPS } from '@/hooks/leverage/types'; +import { use4626VaultAPR } from '@/hooks/use4626VaultAPR'; import { useLeverageQuote } from '@/hooks/useLeverageQuote'; import { useLeverageTransaction } from '@/hooks/useLeverageTransaction'; import { useAppSettings } from '@/stores/useAppSettings'; import { formatBalance } from '@/utils/balance'; +import { convertApyToApr } from '@/utils/rateMath'; import type { LeverageSupport } from '@/hooks/leverage/types'; import type { Market, MarketPosition } from '@/utils/types'; @@ -48,7 +50,7 @@ export function AddCollateralAndLeverage({ }: AddCollateralAndLeverageProps): JSX.Element { const route = support.route; const { address: account } = useConnection(); - const { usePermit2: usePermit2Setting } = useAppSettings(); + const { usePermit2: usePermit2Setting, isAprDisplay } = useAppSettings(); const [collateralAmount, setCollateralAmount] = useState(0n); const [collateralInputError, setCollateralInputError] = useState(null); @@ -130,6 +132,16 @@ export function AddCollateralAndLeverage({ ); const lltv = BigInt(market.lltv); const marketLiquidity = BigInt(market.state.liquidityAssets); + const rateLabel = isAprDisplay ? 'APR' : 'APY'; + + const vaultRateInsight = use4626VaultAPR({ + market, + vaultAddress: route?.kind === 'erc4626' ? route.collateralVault : undefined, + projectedCollateralShares: projectedCollateralAssets, + projectedBorrowAssets, + enabled: isErc4626Route, + lookbackDays: 3, + }); const projectedLTV = useMemo( () => @@ -209,6 +221,33 @@ export function AddCollateralAndLeverage({ () => formatTokenAmountPreview(quote.totalAddedCollateral, market.collateralAsset.decimals), [quote.totalAddedCollateral, market.collateralAsset.decimals], ); + const renderRateValue = useCallback( + (apy: number | null): JSX.Element => { + if (apy == null || !Number.isFinite(apy)) return -; + const displayRate = isAprDisplay ? convertApyToApr(apy) : apy; + if (!Number.isFinite(displayRate)) return -; + + const isNegative = displayRate < 0; + const absolutePercent = Math.abs(displayRate * 100).toFixed(2); + + return ( + <> + {isNegative && -} + {absolutePercent}% + + ); + }, + [isAprDisplay], + ); + const expectedNetRateClass = useMemo(() => { + if (vaultRateInsight.expectedNetApy == null) return 'text-secondary'; + return vaultRateInsight.expectedNetApy >= 0 ? 'text-emerald-500' : 'text-red-500'; + }, [vaultRateInsight.expectedNetApy]); + const previewBorrowApy = useMemo(() => { + // Prefer route-specific observed borrow carry for ERC4626 when available, fallback to market live borrow APY. + if (isErc4626Route && vaultRateInsight.borrowApy3d != null) return vaultRateInsight.borrowApy3d; + return market.state.borrowApy; + }, [isErc4626Route, vaultRateInsight.borrowApy3d, market.state.borrowApy]); return (
@@ -327,12 +366,32 @@ export function AddCollateralAndLeverage({
- Projected LTV - {formatLtvPercent(projectedLTV)}% + Borrow {rateLabel} + {renderRateValue(previewBorrowApy)}
+ {isErc4626Route && ( + <> +
+
+ Vault Only {rateLabel} + + {vaultRateInsight.isLoading ? '...' : renderRateValue(vaultRateInsight.vaultApy3d)} + +
+
+ Net {rateLabel} + + {vaultRateInsight.isLoading ? '...' : renderRateValue(vaultRateInsight.expectedNetApy)} + +
+ + )}
{conversionErrorMessage &&

{conversionErrorMessage}

} {quote.error &&

{quote.error}

} + {isErc4626Route && vaultRateInsight.error && ( +

Failed to fetch 3-day vault/borrow rates: {vaultRateInsight.error}

+ )} {insufficientLiquidity && (

Flash loan repayment borrow exceeds market liquidity ({formatBalance(marketLiquidity, market.loanAsset.decimals)}{' '} diff --git a/src/modals/leverage/leverage-modal-global.tsx b/src/modals/leverage/leverage-modal-global.tsx index 4c0cb4a7..5297b5b6 100644 --- a/src/modals/leverage/leverage-modal-global.tsx +++ b/src/modals/leverage/leverage-modal-global.tsx @@ -4,7 +4,6 @@ import { useCallback } from 'react'; import { useConnection } from 'wagmi'; import { useOraclePrice } from '@/hooks/useOraclePrice'; import useUserPosition from '@/hooks/useUserPosition'; -import type { SupportedNetworks } from '@/utils/networks'; import type { Market } from '@/utils/types'; import { LeverageModal } from './leverage-modal'; @@ -28,10 +27,10 @@ export function LeverageModalGlobal({ onOpenChange, }: LeverageModalGlobalProps): JSX.Element { const { address } = useConnection(); - const chainId = market.morphoBlue.chain.id as SupportedNetworks; + const chainId = market.morphoBlue.chain.id; const { price: oraclePrice } = useOraclePrice({ - oracle: market.oracleAddress as `0x${string}`, + oracle: market.oracleAddress, chainId, }); diff --git a/src/modals/leverage/leverage-modal.tsx b/src/modals/leverage/leverage-modal.tsx index 945fc8e9..6a979afe 100644 --- a/src/modals/leverage/leverage-modal.tsx +++ b/src/modals/leverage/leverage-modal.tsx @@ -4,6 +4,7 @@ import { useConnection, useReadContract } from 'wagmi'; import { Modal, ModalBody, ModalHeader } from '@/components/common/Modal'; import { ModalIntentSwitcher } from '@/components/common/Modal/ModalIntentSwitcher'; import { TokenIcon } from '@/components/shared/token-icon'; +import { Badge } from '@/components/ui/badge'; import { useLeverageSupport } from '@/hooks/useLeverageSupport'; import type { Market, MarketPosition } from '@/utils/types'; import { AddCollateralAndLeverage } from './components/add-collateral-and-leverage'; @@ -33,6 +34,7 @@ export function LeverageModal({ const [mode, setMode] = useState<'leverage' | 'deleverage'>(defaultMode); const { address: account } = useConnection(); const support = useLeverageSupport({ market }); + const isErc4626Route = support.route?.kind === 'erc4626'; const effectiveMode = mode; const modeOptions: { value: string; label: string }[] = toggleLeverageDeleverage @@ -102,16 +104,31 @@ export function LeverageModal({ mainIcon={mainIcon} onClose={() => onOpenChange(false)} title={ - setMode(nextMode as 'leverage' | 'deleverage')} - /> +

+ setMode(nextMode as 'leverage' | 'deleverage')} + /> + {isErc4626Route && ( + + #ERC4626 + + )} +
} description={ effectiveMode === 'leverage' - ? `Leverage your ${market.collateralAsset.symbol} exposure by looping.` - : `Reduce leveraged ${market.collateralAsset.symbol} exposure by unwinding your loop.` + ? isErc4626Route + ? `Leverage ERC4626 vault exposure by looping ${market.loanAsset.symbol} into ${market.collateralAsset.symbol}.` + : `Leverage your ${market.collateralAsset.symbol} exposure by looping.` + : isErc4626Route + ? `Reduce ERC4626 leveraged exposure by unwinding your ${market.collateralAsset.symbol} loop.` + : `Reduce leveraged ${market.collateralAsset.symbol} exposure by unwinding your loop.` } /> diff --git a/src/utils/types.ts b/src/utils/types.ts index ea6172a9..0f733b9d 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -298,13 +298,13 @@ export type Market = { lltv: string; uniqueKey: string; irmAddress: string; - oracleAddress: string; + oracleAddress: Address; whitelisted: boolean; morphoBlue: { id: string; address: string; chain: { - id: number; + id: SupportedNetworks; }; }; loanAsset: TokenInfo; From dbf8207e8c852485eea6a362ab420bd3fff00354 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Thu, 26 Feb 2026 18:02:41 +0800 Subject: [PATCH 09/10] chore: review fix --- src/features/market-detail/market-view.tsx | 2 +- src/hooks/leverage/math.ts | 3 ++- src/hooks/useOraclePrice.ts | 9 +++++++-- src/modals/borrow/borrow-modal-global.tsx | 5 ++--- 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/features/market-detail/market-view.tsx b/src/features/market-detail/market-view.tsx index b71c954e..b6b6432f 100644 --- a/src/features/market-detail/market-view.tsx +++ b/src/features/market-detail/market-view.tsx @@ -70,7 +70,7 @@ function MarketContent() { // 5. Oracle price hook - safely handle undefined market const { price: oraclePrice } = useOraclePrice({ - oracle: market?.oracleAddress as `0x${string}`, + oracle: market?.oracleAddress, chainId: market?.morphoBlue.chain.id, }); diff --git a/src/hooks/leverage/math.ts b/src/hooks/leverage/math.ts index 90736088..06195d19 100644 --- a/src/hooks/leverage/math.ts +++ b/src/hooks/leverage/math.ts @@ -271,6 +271,7 @@ export const computeRepaySharesWithBuffer = ({ }): bigint => { if (repayAssets <= 0n || totalBorrowAssets <= 0n || totalBorrowShares <= 0n) return 0n; - const expectedShares = (repayAssets * totalBorrowShares) / totalBorrowAssets; + const numerator = repayAssets * totalBorrowShares; + const expectedShares = (numerator + totalBorrowAssets - 1n) / totalBorrowAssets; // round up return expectedShares + expectedShares / 200n + 1n; }; diff --git a/src/hooks/useOraclePrice.ts b/src/hooks/useOraclePrice.ts index 40953b5e..2bd7ca0b 100644 --- a/src/hooks/useOraclePrice.ts +++ b/src/hooks/useOraclePrice.ts @@ -1,10 +1,11 @@ import type { Address } from 'abitype'; +import { zeroAddress } from 'viem'; import { useReadContract } from 'wagmi'; import { abi } from '@/abis/chainlinkOraclev2'; import type { SupportedNetworks } from '@/utils/networks'; type Props = { - oracle: Address; + oracle?: Address; chainId?: SupportedNetworks; }; @@ -12,11 +13,15 @@ type Props = { * @param oracle Address of the oracle contract */ export function useOraclePrice({ oracle, chainId = 1 }: Props) { + const hasOracle = oracle != null; const { data } = useReadContract({ abi: abi, functionName: 'price', - address: oracle, + address: oracle ?? zeroAddress, chainId, + query: { + enabled: hasOracle, + }, }); return { price: typeof data === 'bigint' ? data : BigInt(0) }; diff --git a/src/modals/borrow/borrow-modal-global.tsx b/src/modals/borrow/borrow-modal-global.tsx index 5d3fe236..129ff635 100644 --- a/src/modals/borrow/borrow-modal-global.tsx +++ b/src/modals/borrow/borrow-modal-global.tsx @@ -6,7 +6,6 @@ import type { Market } from '@/utils/types'; import type { LiquiditySourcingResult } from '@/hooks/useMarketLiquiditySourcing'; import { useOraclePrice } from '@/hooks/useOraclePrice'; import useUserPosition from '@/hooks/useUserPosition'; -import type { SupportedNetworks } from '@/utils/networks'; import { BorrowModal } from './borrow-modal'; type BorrowModalGlobalProps = { @@ -31,10 +30,10 @@ export function BorrowModalGlobal({ onOpenChange, }: BorrowModalGlobalProps): JSX.Element { const { address } = useConnection(); - const chainId = market.morphoBlue.chain.id as SupportedNetworks; + const chainId = market.morphoBlue.chain.id; const { price: oraclePrice } = useOraclePrice({ - oracle: market.oracleAddress as `0x${string}`, + oracle: market.oracleAddress, chainId, }); From bc34f769d23407e313dd0357e7254e79bd4502a7 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Thu, 26 Feb 2026 21:27:48 +0800 Subject: [PATCH 10/10] chore: wording --- src/modals/leverage/components/add-collateral-and-leverage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modals/leverage/components/add-collateral-and-leverage.tsx b/src/modals/leverage/components/add-collateral-and-leverage.tsx index 4940acb0..bf5b2874 100644 --- a/src/modals/leverage/components/add-collateral-and-leverage.tsx +++ b/src/modals/leverage/components/add-collateral-and-leverage.tsx @@ -373,7 +373,7 @@ export function AddCollateralAndLeverage({ <>
- Vault Only {rateLabel} + Vault Token {rateLabel} {vaultRateInsight.isLoading ? '...' : renderRateValue(vaultRateInsight.vaultApy3d)}