diff --git a/AGENTS.md b/AGENTS.md index 8a2dbfef..2a68149e 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/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. + +### 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/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/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/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/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/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/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 new file mode 100644 index 00000000..06195d19 --- /dev/null +++ b/src/hooks/leverage/math.ts @@ -0,0 +1,277 @@ +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 +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; + 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 computeLeverageProjectedPosition = ({ + currentBorrowAssets, + currentCollateralAssets, + addedBorrowAssets, + addedCollateralAssets, +}: { + currentBorrowAssets: bigint; + currentCollateralAssets: bigint; + 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; +}; + +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 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('.'); + 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; + 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 numerator = repayAssets * totalBorrowShares; + const expectedShares = (numerator + totalBorrowAssets - 1n) / totalBorrowAssets; // round up + 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..d1b8f816 --- /dev/null +++ b/src/hooks/leverage/types.ts @@ -0,0 +1,23 @@ +import type { Address } from 'viem'; + +export type Erc4626LeverageRoute = { + kind: 'erc4626'; + collateralVault: Address; + underlyingLoanToken: Address; +}; + +export type LeverageRoute = Erc4626LeverageRoute; + +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/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/hooks/useDeleverageQuote.ts b/src/hooks/useDeleverageQuote.ts new file mode 100644 index 00000000..5523a552 --- /dev/null +++ b/src/hooks/useDeleverageQuote.ts @@ -0,0 +1,97 @@ +import { useMemo } from 'react'; +import { useReadContract } from 'wagmi'; +import { erc4626Abi } from '@/abis/erc4626'; +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 bufferedBorrowAssets = withSlippageCeil(currentBorrowAssets); + + const { + data: erc4626PreviewRedeem, + isLoading: isLoadingRedeem, + error: redeemError, + } = useReadContract({ + address: route?.collateralVault, + abi: erc4626Abi, + functionName: 'previewRedeem', + args: [withdrawCollateralAmount], + chainId, + query: { + enabled: !!route && withdrawCollateralAmount > 0n, + }, + }); + + const { + data: erc4626PreviewWithdrawForDebt, + isLoading: isLoadingWithdraw, + error: withdrawError, + } = useReadContract({ + address: route?.collateralVault, + abi: erc4626Abi, + functionName: 'previewWithdraw', + args: [bufferedBorrowAssets], + chainId, + query: { + enabled: !!route && bufferedBorrowAssets > 0n, + }, + }); + + const rawRouteRepayAmount = useMemo(() => { + if (!route || withdrawCollateralAmount <= 0n) return 0n; + return (erc4626PreviewRedeem as bigint | undefined) ?? 0n; + }, [route, withdrawCollateralAmount, erc4626PreviewRedeem]); + + const repayAmount = useMemo(() => { + if (rawRouteRepayAmount <= 0n) return 0n; + return rawRouteRepayAmount > currentBorrowAssets ? currentBorrowAssets : rawRouteRepayAmount; + }, [rawRouteRepayAmount, currentBorrowAssets]); + + const maxCollateralForDebtRepay = useMemo(() => { + if (!route || currentBorrowAssets <= 0n) return 0n; + return (erc4626PreviewWithdrawForDebt as bigint | undefined) ?? 0n; + }, [route, currentBorrowAssets, erc4626PreviewWithdrawForDebt]); + + const error = useMemo(() => { + if (!route) return null; + const routeError = redeemError ?? withdrawError; + if (!routeError) return null; + return routeError instanceof Error ? routeError.message : 'Failed to quote deleverage route'; + }, [route, redeemError, withdrawError]); + + const isLoading = !!route && (isLoadingRedeem || isLoadingWithdraw); + + return { + repayAmount, + rawRouteRepayAmount, + maxCollateralForDebtRepay, + isLoading, + error, + }; +} diff --git a/src/hooks/useDeleverageTransaction.ts b/src/hooks/useDeleverageTransaction.ts new file mode 100644 index 00000000..2eeb8641 --- /dev/null +++ b/src/hooks/useDeleverageTransaction.ts @@ -0,0 +1,315 @@ +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 | null; + 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 (!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; + } + + 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, + ], + }), + ]; + + 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. + // 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..0944658f --- /dev/null +++ b/src/hooks/useLeverageQuote.ts @@ -0,0 +1,75 @@ +import { useMemo } from 'react'; +import { useReadContract } from 'wagmi'; +import { erc4626Abi } from '@/abis/erc4626'; +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 + */ +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?.collateralVault, + abi: erc4626Abi, + functionName: 'previewMint', + args: [targetFlashCollateralAmount], + chainId, + query: { + enabled: !!route && targetFlashCollateralAmount > 0n, + }, + }); + + const flashCollateralAmount = useMemo(() => { + if (!route) return 0n; + return targetFlashCollateralAmount; + }, [route, targetFlashCollateralAmount]); + + const flashLoanAmount = useMemo(() => { + if (!route) return 0n; + return (erc4626PreviewMint as bigint | undefined) ?? 0n; + }, [route, erc4626PreviewMint]); + + const error = useMemo(() => { + if (!route) return null; + const e = erc4626Error; + if (!e) return null; + return e instanceof Error ? e.message : 'Failed to quote leverage route'; + }, [route, erc4626Error]); + + const isLoading = !!route && isLoadingErc4626; + + 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..05f64c30 --- /dev/null +++ b/src/hooks/useLeverageSupport.ts @@ -0,0 +1,69 @@ +import { useMemo } from 'react'; +import { type Address, isAddressEqual, zeroAddress } from 'viem'; +import { useReadContracts } from 'wagmi'; +import { erc4626Abi } from '@/abis/erc4626'; +import type { Market } from '@/utils/types'; +import type { Erc4626LeverageRoute, LeverageSupport } from './leverage/types'; + +type UseLeverageSupportParams = { + market: Market; +}; + +/** + * Detects whether a market can be levered/delevered with deterministic V2 routes. + * + * Supported route: + * - ERC4626 collateral where `vault.asset() == loanToken` + */ +export function useLeverageSupport({ market }: UseLeverageSupportParams): LeverageSupport { + const loanToken = market.loanAsset.address as Address; + const collateralToken = market.collateralAsset.address as Address; + const chainId = market.morphoBlue.chain.id; + + 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; + + 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, + }; + } + + return { + isSupported: false, + supportsLeverage: false, + supportsDeleverage: false, + isLoading: isLoading || isRefetching, + route: null, + reason: 'Leverage is currently available only for ERC4626-underlying routes on Bundler V2.', + }; + }, [collateralToken, loanToken, data, isLoading, isRefetching]); +} diff --git a/src/hooks/useLeverageTransaction.ts b/src/hooks/useLeverageTransaction.ts new file mode 100644 index 00000000..290b5cbe --- /dev/null +++ b/src/hooks/useLeverageTransaction.ts @@ -0,0 +1,463 @@ +import { useCallback } from 'react'; +import { type Address, encodeAbiParameters, encodeFunctionData, maxUint256 } 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 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 | null; + collateralAmount: bigint; + collateralAmountInCollateralToken: bigint; + flashCollateralAmount: bigint; + flashLoanAmount: bigint; + useLoanAssetAsInput: boolean; + onSuccess?: () => void; +}; + +/** + * Executes an ERC4626 leverage loop in Bundler V2. + * + * 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, + route, + collateralAmount, + collateralAmountInCollateralToken, + flashCollateralAmount, + flashLoanAmount, + 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 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( + { + 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, inputTokenDecimals)} ${inputTokenSymbol}`, + 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( + (isPermit2: boolean) => { + 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 (!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; + } + + try { + const txs: `0x${string}`[] = []; + + if (usePermit2Setting) { + if (!permit2Authorized) { + tracking.update('approve_permit2'); + await authorizePermit2(); + await new Promise((resolve) => setTimeout(resolve, 800)); + } + + if (!isBundlerAuthorized) { + 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)); + } + } + + if (inputTokenAmountForTransfer > 0n) { + txs.push( + encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: usePermit2Setting ? 'transferFrom2' : 'erc20TransferFrom', + args: [inputTokenAddress, inputTokenAmountForTransfer], + }), + ); + } + + 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, + functionName: 'erc4626Deposit', + args: [ + route.collateralVault, + collateralAmount, + withSlippageFloor(collateralAmountInCollateralToken), + bundlerAddress as Address, + ], + }), + ); + } + + const callbackTxs: `0x${string}`[] = [ + encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'erc4626Deposit', + args: [route.collateralVault, flashLoanAmount, withSlippageFloor(flashCollateralAmount), bundlerAddress as Address], + }), + 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, + ], + }), + ); + + 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: 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, + isLoanAssetInput, + flashCollateralAmount, + flashLoanAmount, + usePermit2Setting, + permit2Authorized, + isBundlerAuthorized, + 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 = usePermit2Setting ? 'approve_permit2' : 'authorize_bundler_tx'; + tracking.start( + getStepsForFlow(usePermit2Setting), + { + title: 'Leverage', + description: `${market.collateralAsset.symbol} leveraged using ${market.loanAsset.symbol} debt`, + tokenSymbol: inputTokenSymbol, + 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, usePermit2Setting, tracking, getStepsForFlow, market, inputTokenSymbol, collateralAmount, executeLeverage, toast]); + + const signAndLeverage = useCallback(async () => { + if (!account) { + toast.info('No account connected', 'Please connect your wallet.'); + return; + } + + try { + const initialStep: LeverageStepType = permit2Authorized + ? isBundlerAuthorized + ? 'sign_permit' + : 'authorize_bundler_sig' + : 'approve_permit2'; + + tracking.start( + getStepsForFlow(usePermit2Setting), + { + title: 'Leverage', + description: `${market.collateralAsset.symbol} leveraged using ${market.loanAsset.symbol} debt`, + tokenSymbol: inputTokenSymbol, + amount: collateralAmount, + marketId: market.uniqueKey, + }, + initialStep, + ); + + 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, + tracking, + getStepsForFlow, + usePermit2Setting, + permit2Authorized, + isBundlerAuthorized, + market, + inputTokenSymbol, + 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/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 dd66653e..129ff635 100644 --- a/src/modals/borrow/borrow-modal-global.tsx +++ b/src/modals/borrow/borrow-modal-global.tsx @@ -1,11 +1,11 @@ 'use client'; +import { useCallback } from 'react'; import { useConnection } from 'wagmi'; 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 = { @@ -30,19 +30,19 @@ 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, }); 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 { open: openModal } = useModal(); + const leverageSupport = useLeverageSupport({ market }); useEffect(() => { setMode(defaultMode); }, [defaultMode]); + 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', { + market, + refetch, + defaultMode: leverageModalMode, + }); + onOpenChange(false); + }, [openModal, market, refetch, leverageModalMode, onOpenChange]); + // Get token balances const { data: loanTokenBalance, @@ -131,22 +156,34 @@ 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 ? ( + canOpenLeverageModal ? ( ) : undefined } 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..bf5b2874 --- /dev/null +++ b/src/modals/leverage/components/add-collateral-and-leverage.tsx @@ -0,0 +1,441 @@ +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 } 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 { IconSwitch } from '@/components/ui/icon-switch'; +import { Tooltip } from '@/components/ui/tooltip'; +import { erc4626Abi } from '@/abis/erc4626'; +import { + clampMultiplierBps, + computeLeverageProjectedPosition, + formatMultiplierBps, + formatTokenAmountPreview, + 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'; + +type AddCollateralAndLeverageProps = { + market: Market; + support: LeverageSupport; + currentPosition: MarketPosition | null; + collateralTokenBalance: bigint | undefined; + oraclePrice: bigint; + onSuccess?: () => void; + isRefreshing?: boolean; +}; + +const MULTIPLIER_INPUT_REGEX = /^\d*\.?\d*$/; + +export function AddCollateralAndLeverage({ + market, + support, + currentPosition, + collateralTokenBalance, + oraclePrice, + onSuccess, + isRefreshing = false, +}: AddCollateralAndLeverageProps): JSX.Element { + const route = support.route; + const { address: account } = useConnection(); + const { usePermit2: usePermit2Setting, isAprDisplay } = useAppSettings(); + + const [collateralAmount, setCollateralAmount] = useState(0n); + const [collateralInputError, setCollateralInputError] = useState(null); + const [multiplierInput, setMultiplierInput] = useState(formatMultiplierBps(LEVERAGE_DEFAULT_MULTIPLIER_BPS)); + const [useLoanAssetInput, setUseLoanAssetInput] = useState(false); + + const multiplierBps = useMemo(() => clampMultiplierBps(parseMultiplierToBps(multiplierInput)), [multiplierInput]); + const isErc4626Route = route?.kind === 'erc4626'; + + 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, + }, + }); + + useEffect(() => { + // Underlying and collateral shares use different units. Reset amount when switching input source. + setCollateralAmount(0n); + setCollateralInputError(null); + }, [useLoanAssetInput]); + + useEffect(() => { + if (isErc4626Route) return; + setUseLoanAssetInput(false); + }, [isErc4626Route]); + + 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?.collateralVault, + abi: erc4626Abi, + functionName: 'previewDeposit', + args: [collateralAmount], + chainId: market.morphoBlue.chain.id, + query: { + enabled: isErc4626Route && useLoanAssetInput && collateralAmount > 0n, + }, + }); + + const collateralAmountForLeverageQuote = useMemo(() => { + if (useLoanAssetInput) return (previewCollateralSharesFromUnderlying as bigint | undefined) ?? 0n; + return collateralAmount; + }, [useLoanAssetInput, previewCollateralSharesFromUnderlying, collateralAmount]); + + const conversionErrorMessage = useMemo(() => { + 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, + route, + userCollateralAmount: collateralAmountForLeverageQuote, + multiplierBps, + }); + + const currentCollateralAssets = BigInt(currentPosition?.state.collateral ?? 0); + const currentBorrowAssets = BigInt(currentPosition?.state.borrowAssets ?? 0); + 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); + 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( + () => + computeLtv({ + borrowAssets: projectedBorrowAssets, + collateralAssets: projectedCollateralAssets, + oraclePrice, + }), + [projectedBorrowAssets, projectedCollateralAssets, oraclePrice], + ); + + const currentLTV = useMemo( + () => + computeLtv({ + borrowAssets: currentBorrowAssets, + collateralAssets: currentCollateralAssets, + oraclePrice, + }), + [currentBorrowAssets, currentCollateralAssets, oraclePrice], + ); + + const handleTransactionSuccess = useCallback(() => { + // 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)); + if (onSuccess) onSuccess(); + }, [onSuccess]); + + const { transaction, isLoadingPermit2, isApproved, permit2Authorized, leveragePending, approveAndLeverage, signAndLeverage } = + useLeverageTransaction({ + market, + route, + collateralAmount, + collateralAmountInCollateralToken: collateralAmountForLeverageQuote, + flashCollateralAmount: quote.flashCollateralAmount, + flashLoanAmount: quote.flashLoanAmount, + useLoanAssetAsInput: useLoanAssetInput, + onSuccess: handleTransactionSuccess, + }); + + 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 (usePermit2Setting && permit2Authorized) { + void signAndLeverage(); + return; + } + if (!usePermit2Setting && isApproved) { + void approveAndLeverage(); + return; + } + void approveAndLeverage(); + }, [usePermit2Setting, permit2Authorized, signAndLeverage, isApproved, approveAndLeverage]); + + const projectedOverLimit = projectedLTV >= lltv; + const insufficientLiquidity = quote.flashLoanAmount > marketLiquidity; + const hasChanges = collateralAmountForLeverageQuote > 0n && quote.flashLoanAmount > 0n; + 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; + 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], + ); + 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 ( +
+ {!transaction?.isModalVisible && ( +
+

Leverage Preview

+ + +
+
+
+

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

+ {isErc4626Route && ( +
+
Use {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 + + {flashBorrowPreview.full}}> + {flashBorrowPreview.compact} + + + +
+
+ Total Collateral Added + + {totalCollateralAddedPreview.full}}> + {totalCollateralAddedPreview.compact} + + + +
+
+ Borrow {rateLabel} + {renderRateValue(previewBorrowApy)} +
+ {isErc4626Route && ( + <> +
+
+ Vault Token {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)}{' '} + {market.loanAsset.symbol} available). +

+ )} +
+
+ +
+
+ + 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..610c59bf --- /dev/null +++ b/src/modals/leverage/components/remove-collateral-and-deleverage.tsx @@ -0,0 +1,246 @@ +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 { computeDeleverageProjectedPosition, formatTokenAmountPreview } from '@/hooks/leverage/math'; +import { useDeleverageQuote } from '@/hooks/useDeleverageQuote'; +import { useDeleverageTransaction } from '@/hooks/useDeleverageTransaction'; +import { formatBalance } from '@/utils/balance'; +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 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( + () => + computeLtv({ + borrowAssets: currentBorrowAssets, + collateralAssets: currentCollateralAssets, + oraclePrice, + }), + [currentBorrowAssets, currentCollateralAssets, oraclePrice], + ); + + const projectedLTV = useMemo( + () => + computeLtv({ + borrowAssets: projection.projectedBorrowAssets, + collateralAssets: projection.projectedCollateralAssets, + oraclePrice, + }), + [projection.projectedBorrowAssets, projection.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, + withdrawCollateralAmount, + flashLoanAmount: projection.flashLoanAmountForTx, + repayBySharesAmount: projection.repayBySharesAmount, + autoWithdrawCollateralAmount: projection.autoWithdrawCollateralAmount, + onSuccess: handleTransactionSuccess, + }); + + 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 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 ( +
+ {!transaction?.isModalVisible && ( +
+

Deleverage Preview

+ + +
+
+

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

+ + } + /> +

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

+ {withdrawInputError &&

{withdrawInputError}

} +
+ +
+

Transaction Preview

+
+
+ Flash Borrow + + {flashBorrowPreview.full}}> + {flashBorrowPreview.compact} + + + +
+
+ Debt Repaid + + {debtRepaidPreview.full}}> + {debtRepaidPreview.compact} + + + +
+
+ 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..5297b5b6 --- /dev/null +++ b/src/modals/leverage/leverage-modal-global.tsx @@ -0,0 +1,55 @@ +'use client'; + +import { useCallback } from 'react'; +import { useConnection } from 'wagmi'; +import { useOraclePrice } from '@/hooks/useOraclePrice'; +import useUserPosition from '@/hooks/useUserPosition'; +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; + + const { price: oraclePrice } = useOraclePrice({ + oracle: market.oracleAddress, + chainId, + }); + + const { position, refetch: refetchPosition } = useUserPosition(address, chainId, market.uniqueKey); + + const handleRefetch = useCallback(() => { + refetchPosition(); + externalRefetch?.(); + }, [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..6a979afe --- /dev/null +++ b/src/modals/leverage/leverage-modal.tsx @@ -0,0 +1,170 @@ +import { useCallback, useState } from 'react'; +import { erc20Abi } from 'viem'; +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'; +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 isErc4626Route = support.route?.kind === 'erc4626'; + + const effectiveMode = mode; + 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, + 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={ +
+ setMode(nextMode as 'leverage' | 'deleverage')} + /> + {isErc4626Route && ( + + #ERC4626 + + )} +
+ } + description={ + effectiveMode === 'leverage' + ? 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.` + } + /> + + {support.isSupported ? ( + effectiveMode === 'leverage' ? ( + + ) : support.supportsDeleverage ? ( + + ) : ( +
+ {support.reason ?? 'Deleverage is not available for this route.'} +
+ ) + ) : support.isLoading ? ( +
Checking leverage route support...
+ ) : ( +
+ {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/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)} 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..373d0015 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,17 @@ export type NetworkToken = { /** Token contract address on the network */ address: string; }; + +/** Canonical token addresses used for deterministic route checks. */ +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 getCanonicalWethAddress = (chainId: number): Address | undefined => { + return WETH_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..d345a0da --- /dev/null +++ b/src/utils/token-amount-format.ts @@ -0,0 +1 @@ +export { formatCompactTokenAmount, formatFullTokenAmount } from '@/hooks/leverage/math'; 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;