diff --git a/AGENTS.md b/AGENTS.md index 11bfcfb4..d6463f31 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -143,7 +143,7 @@ When touching transaction and position flows, validation MUST include all releva 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. 10. **Bundler authorization and transfer-authority chokepoint**: every Morpho bundler transaction path (supply, borrow, repay, rebalance, leverage/deleverage) must route through `useBundlerAuthorizationStep` rather than implementing ad hoc authorization logic per hook; Permit2/ERC20 spender scope must target the contract that actually pulls the token (never Bundler3 unless it is the transfer executor), readiness must fail closed, and auth helpers must preserve original wallet/chain errors. -11. **Locale-safe decimal inputs**: transaction-critical amount/slippage inputs must accept both `,` and `.`, preserve transient edit states (e.g. `''`, `.`) during typing, and only normalize/clamp on commit (`blur`/submit) so delete-and-retype flows never lock users into stale values. +11. **Locale-safe decimal inputs**: transaction-critical amount/slippage inputs (including compact inline edit controls) must accept both `,` and `.`, preserve transient edit states (e.g. `''`, `.`) during typing, and only normalize/clamp on commit (`blur`/submit) so delete-and-retype flows never lock users into stale values. 12. **Aggregator API contract integrity**: quote-only request params must never be forwarded to transaction-build endpoints (e.g. Velora `version` on `/prices` but not `/transactions/:network`); enforce endpoint-specific payload/query builders, normalize fetch/network failures into typed API errors, verify returned route token addresses match requested canonical token addresses before using previews/tx payloads, and ensure aggregator `userAddress` / taker fields always match the actual on-chain swap executor (adapter contract for adapter-executed swaps, never an unrelated EOA). 13. **User-rejection error normalization**: transaction hooks must map wallet rejection payloads (EIP-1193 `4001`, `ACTION_REJECTED`, viem request-argument dumps) to a short canonical UI message (`User rejected transaction.`) and never render raw payload text in inline UI/error boxes. 14. **Input/state integrity in tx-critical UIs**: never strip unsupported numeric syntax into a different value (e.g. `1e-6` must be rejected, not rewritten), and after any balance refetch re-derive selected token objects from refreshed data before allowing `Max`/submit. @@ -153,6 +153,8 @@ When touching transaction and position flows, validation MUST include all releva 18. **Smart-rebalance constraint integrity**: treat user max-allocation limits (especially `0%`) as hard constraints in planner output and previews, not soft objective hints; when liquidity/capacity permits, planner targets must not leave avoidable residual allocation above cap, and full-exit targets must use tx-construction-compatible withdrawal semantics so previewed and executed allocations stay aligned. 19. **Monotonic transaction-step updates**: never call `tracking.update(...)` unconditionally in tx hooks; compute step order for the active flow and only advance when the target step is strictly later than the current runtime step, so auth/permit pre-satisfied states cannot regress the stepper backwards. 20. **Share-based full-exit withdrawals**: when a rebalance target leaves only dust in a source market, tx builders must switch to share-based `morphoWithdraw` (full shares burn with expected-assets guard) instead of asset-amount withdraws, so "empty market" intent cannot strand residual dust due rounding. +21. **Preview stability during quote refresh**: transaction-critical risk previews (LTV text, warning banners, risk bars, submit gating hints) must not be driven by transient placeholder/intermediate quote values while quote/route resolution is loading or refetching; display the last settled preview state (or a neutral loading state) until fresh executable quote data is available. +22. **Bigint-safe input echo formatting**: transaction-critical amount inputs must never round-trip through JavaScript `Number` when syncing bigint state back to text fields; use exact bigint/string unit formatters so typed values (for example `100000`) never mutate into precision-drifted decimals. ### REQUIRED: Regression Rule Capture diff --git a/src/components/Input/Input.tsx b/src/components/Input/Input.tsx index 081cbfe2..9cf9b71a 100644 --- a/src/components/Input/Input.tsx +++ b/src/components/Input/Input.tsx @@ -1,7 +1,6 @@ import { useCallback, useState, useEffect } from 'react'; -import { parseUnits } from 'viem'; -import { formatBalance } from '@/utils/balance'; +import { formatUnits, parseUnits } from 'viem'; import { isValidDecimalInput, sanitizeDecimalInput, toParseableDecimalInput } from '@/utils/decimal-input'; import { Button } from '@/components/ui/button'; @@ -19,6 +18,13 @@ type InputProps = { inputClassName?: string; }; +const formatInputAmount = (value: bigint, decimals: number): string => { + const formatted = formatUnits(value, decimals); + if (!formatted.includes('.')) return formatted; + const trimmed = formatted.replace(/\.?0+$/, ''); + return trimmed.length > 0 ? trimmed : '0'; +}; + export default function Input({ decimals, max, @@ -33,16 +39,18 @@ export default function Input({ inputClassName, }: InputProps): JSX.Element { // State for the input text - const [inputAmount, setInputAmount] = useState(value ? formatBalance(value, decimals).toString() : '0'); + const [inputAmount, setInputAmount] = useState(value ? formatInputAmount(value, decimals) : '0'); + const [isFocused, setIsFocused] = useState(false); // Track if max check is bypassed const [bypassMax, setBypassMax] = useState(false); // Update input text when value prop changes useEffect(() => { + if (isFocused) return; if (value !== undefined) { - setInputAmount(formatBalance(value, decimals).toString()); + setInputAmount(formatInputAmount(value, decimals)); } - }, [value, decimals]); + }, [value, decimals, isFocused]); const onInputChange = useCallback( (e: React.ChangeEvent) => { @@ -104,11 +112,18 @@ export default function Input({ if (max) { setValue(max); // set readable input - setInputAmount(formatBalance(max, decimals).toString()); + setInputAmount(formatInputAmount(max, decimals)); } if (onMaxClick) onMaxClick(); }, [max, decimals, setInputAmount, setValue, onMaxClick]); + const handleBlur = useCallback(() => { + setIsFocused(false); + if (value !== undefined) { + setInputAmount(formatInputAmount(value, decimals)); + } + }, [value, decimals]); + return (
@@ -117,6 +132,8 @@ export default function Input({ inputMode="decimal" lang="en-US" value={inputAmount} + onFocus={() => setIsFocused(true)} + onBlur={handleBlur} onChange={onInputChange} className={`bg-hovered h-10 w-full rounded p-2 focus:border-primary focus:outline-none ${ endAdornment != null && max !== undefined && max !== BigInt(0) diff --git a/src/features/swap/components/SlippageInlineEditor.tsx b/src/features/swap/components/SlippageInlineEditor.tsx new file mode 100644 index 00000000..787acda5 --- /dev/null +++ b/src/features/swap/components/SlippageInlineEditor.tsx @@ -0,0 +1,140 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Pencil1Icon } from '@radix-ui/react-icons'; +import { + MAX_SLIPPAGE_PERCENT, + MIN_SLIPPAGE_PERCENT, + clampSlippagePercent, +} from '@/features/swap/constants'; +import { + isValidDecimalInput, + sanitizeDecimalInput, + toParseableDecimalInput, +} from '@/utils/decimal-input'; +import { formatSlippagePercent } from '../utils/quote-preview'; + +type SlippageInlineEditorProps = { + value: number; + onChange: (value: number) => void; + min?: number; + max?: number; + disabled?: boolean; +}; + +const clampToBounds = (value: number, min: number, max: number): number => { + return Math.min(max, Math.max(min, value)); +}; + +const formatInputValue = (value: number, min: number, max: number): string => { + const bounded = clampToBounds(clampSlippagePercent(value), min, max); + return formatSlippagePercent(bounded); +}; + +export function SlippageInlineEditor({ + value, + onChange, + min = MIN_SLIPPAGE_PERCENT, + max = MAX_SLIPPAGE_PERCENT, + disabled = false, +}: SlippageInlineEditorProps): JSX.Element { + const [isEditing, setIsEditing] = useState(false); + const [draftValue, setDraftValue] = useState(formatInputValue(value, min, max)); + const skipBlurCommitRef = useRef(false); + + useEffect(() => { + if (isEditing) return; + setDraftValue(formatInputValue(value, min, max)); + }, [value, min, max, isEditing]); + + const handleBeginEdit = useCallback(() => { + if (disabled) return; + skipBlurCommitRef.current = false; + setDraftValue(formatInputValue(value, min, max)); + setIsEditing(true); + }, [value, min, max, disabled]); + + const handleCancel = useCallback(() => { + setDraftValue(formatInputValue(value, min, max)); + setIsEditing(false); + }, [value, min, max]); + + const handleCommit = useCallback(() => { + const parseableInput = toParseableDecimalInput(draftValue); + if (!parseableInput) { + setDraftValue(formatInputValue(value, min, max)); + setIsEditing(false); + return; + } + + const parsed = Number(parseableInput); + if (!Number.isFinite(parsed)) { + setDraftValue(formatInputValue(value, min, max)); + setIsEditing(false); + return; + } + + const bounded = clampToBounds(clampSlippagePercent(parsed), min, max); + onChange(bounded); + setDraftValue(formatInputValue(bounded, min, max)); + skipBlurCommitRef.current = false; + setIsEditing(false); + }, [draftValue, value, min, max, onChange]); + + const handleDraftChange = useCallback((event: React.ChangeEvent) => { + const normalizedInput = sanitizeDecimalInput(event.target.value); + if (!isValidDecimalInput(normalizedInput)) { + return; + } + setDraftValue(normalizedInput); + }, []); + + if (isEditing) { + return ( + + { + if (skipBlurCommitRef.current) { + skipBlurCommitRef.current = false; + return; + } + handleCommit(); + }} + onKeyDown={(event) => { + if (event.key === 'Enter') { + handleCommit(); + return; + } + if (event.key === 'Escape') { + skipBlurCommitRef.current = true; + handleCancel(); + } + }} + className="h-6 w-14 rounded-sm bg-surface px-1.5 text-right text-[11px] tabular-nums focus:border-primary focus:outline-none" + aria-label="Slippage percentage" + disabled={disabled} + /> + % + + ); + } + + return ( + + {formatInputValue(value, min, max)}% + + + ); +} diff --git a/src/features/swap/components/SwapModal.tsx b/src/features/swap/components/SwapModal.tsx index 146e42ff..ad15f8f7 100644 --- a/src/features/swap/components/SwapModal.tsx +++ b/src/features/swap/components/SwapModal.tsx @@ -1,9 +1,9 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; -import { ArrowDownIcon, ChevronDownIcon } from '@radix-ui/react-icons'; +import { ArrowDownIcon } from '@radix-ui/react-icons'; import { IoIosSwap } from 'react-icons/io'; import { formatUnits, isAddress, parseUnits, zeroAddress } from 'viem'; import { useConnection } from 'wagmi'; -import { AnimatePresence, motion } from 'framer-motion'; +import { motion } from 'framer-motion'; import { Modal, ModalBody, ModalFooter, ModalHeader } from '@/components/common/Modal'; import { Button } from '@/components/ui/button'; import { ExecuteTransactionButton } from '@/components/ui/ExecuteTransactionButton'; @@ -16,11 +16,12 @@ import { formatBalance } from '@/utils/balance'; import { isValidDecimalInput, sanitizeDecimalInput, toParseableDecimalInput } from '@/utils/decimal-input'; import { formatCompactTokenAmount } from '@/utils/token-amount-format'; import { useVeloraSwap } from '../hooks/useVeloraSwap'; +import { SlippageInlineEditor } from './SlippageInlineEditor'; import { TokenNetworkDropdown } from './TokenNetworkDropdown'; import { SwapTokenAmountField } from './SwapTokenAmountField'; import { VELORA_SWAP_CHAINS, type SwapToken } from '../types'; -import { DEFAULT_SLIPPAGE_PERCENT } from '../constants'; -import { formatSlippagePercent, formatSwapRatePreview } from '../utils/quote-preview'; +import { DEFAULT_SLIPPAGE_PERCENT, slippagePercentToBps } from '../constants'; +import { formatSwapRatePreview } from '../utils/quote-preview'; type SwapModalProps = { isOpen: boolean; @@ -28,12 +29,7 @@ type SwapModalProps = { defaultTargetToken?: SwapToken; }; -const MIN_SLIPPAGE_PERCENT = 0.1; -const MAX_SLIPPAGE_PERCENT = 5; const DEFAULT_CHAIN_ID = 1; -const clampSlippagePercent = (value: number): number => { - return Math.min(MAX_SLIPPAGE_PERCENT, Math.max(MIN_SLIPPAGE_PERCENT, value)); -}; export function SwapModal({ isOpen, onClose, defaultTargetToken }: SwapModalProps) { const { address: account } = useConnection(); @@ -42,8 +38,6 @@ export function SwapModal({ isOpen, onClose, defaultTargetToken }: SwapModalProp const [inputAmount, setInputAmount] = useState('0'); const [amount, setAmount] = useState(BigInt(0)); const [slippage, setSlippage] = useState(DEFAULT_SLIPPAGE_PERCENT); - const [slippageInput, setSlippageInput] = useState(formatSlippagePercent(DEFAULT_SLIPPAGE_PERCENT)); - const [isSettingsOpen, setIsSettingsOpen] = useState(false); const [isRateInverted, setIsRateInverted] = useState(false); const amountInputClassName = 'h-10 w-full rounded bg-hovered px-3 pr-44 text-lg font-medium tabular-nums focus:border-primary focus:outline-none'; @@ -152,7 +146,7 @@ export function SwapModal({ isOpen, onClose, defaultTargetToken }: SwapModalProp sourceToken, targetToken, amount, - slippageBps: Math.round(slippage * 100), + slippageBps: slippagePercentToBps(slippage), onSwapConfirmed: handleSwapConfirmed, }); @@ -212,44 +206,6 @@ export function SwapModal({ isOpen, onClose, defaultTargetToken }: SwapModalProp } }; - const handleSlippageChange = (e: React.ChangeEvent) => { - const normalizedInput = sanitizeDecimalInput(e.target.value); - if (!isValidDecimalInput(normalizedInput)) { - return; - } - setSlippageInput(normalizedInput); - - const parseableInput = toParseableDecimalInput(normalizedInput); - if (!parseableInput) { - return; - } - - const parsed = Number(parseableInput); - if (Number.isNaN(parsed)) { - return; - } - - setSlippage(clampSlippagePercent(parsed)); - }; - - const handleSlippageBlur = () => { - const parseableInput = toParseableDecimalInput(slippageInput); - if (!parseableInput) { - setSlippageInput(formatSlippagePercent(slippage)); - return; - } - - const parsed = Number(parseableInput); - if (Number.isNaN(parsed)) { - setSlippageInput(formatSlippagePercent(slippage)); - return; - } - - const normalized = clampSlippagePercent(parsed); - setSlippage(normalized); - setSlippageInput(formatSlippagePercent(normalized)); - }; - const handleMaxClick = () => { if (sourceToken?.balance) { setAmount(sourceToken.balance); @@ -424,47 +380,14 @@ export function SwapModal({ isOpen, onClose, defaultTargetToken }: SwapModalProp {/* Slippage */}
-
- - - {isSettingsOpen && ( - -
-
- Max slippage -
- - % -
-
-
-
- )} -
+
+
+ Max slippage + +
diff --git a/src/features/swap/constants.ts b/src/features/swap/constants.ts index 1952bb8f..31742f4e 100644 --- a/src/features/swap/constants.ts +++ b/src/features/swap/constants.ts @@ -17,3 +17,17 @@ export const VELORA_PRICES_API_VERSION = '6.2'; * Default slippage tolerance as a percentage (0.5 = 0.5%) */ export const DEFAULT_SLIPPAGE_PERCENT = 0.5; + +/** + * Slippage tolerance input bounds as percentages. + */ +export const MIN_SLIPPAGE_PERCENT = 0.01; +export const MAX_SLIPPAGE_PERCENT = 5; + +export const clampSlippagePercent = (value: number): number => { + return Math.min(MAX_SLIPPAGE_PERCENT, Math.max(MIN_SLIPPAGE_PERCENT, value)); +}; + +export const slippagePercentToBps = (value: number): number => { + return Math.round(clampSlippagePercent(value) * 100); +}; diff --git a/src/hooks/leverage/bundler3.ts b/src/hooks/leverage/bundler3.ts index 1cfb3e0f..1ed50b98 100644 --- a/src/hooks/leverage/bundler3.ts +++ b/src/hooks/leverage/bundler3.ts @@ -1,6 +1,5 @@ import { type Address, encodeAbiParameters } from 'viem'; -const PARASWAP_SWAP_EXACT_AMOUNT_IN_SELECTOR = '0xe3ead59e'; const PARASWAP_SELL_EXACT_AMOUNT_OFFSET = 100n; const PARASWAP_SELL_MIN_DEST_AMOUNT_OFFSET = 132n; const PARASWAP_SELL_QUOTED_DEST_AMOUNT_OFFSET = 164n; @@ -31,9 +30,10 @@ export const encodeBundler3Calls = (bundle: Bundler3Call[]): `0x${string}` => { }; export const getParaswapSellOffsets = (augustusCallData: `0x${string}`) => { - const selector = augustusCallData.slice(0, 10).toLowerCase(); - if (selector !== PARASWAP_SWAP_EXACT_AMOUNT_IN_SELECTOR) { - throw new Error('Unsupported Velora swap method for Paraswap adapter route.'); + // Guard only for malformed calldata; supported Velora/Paraswap sell methods can vary by selector + // while retaining the same amount fields layout required by the adapter offsets below. + if (augustusCallData.length < 10) { + throw new Error('Invalid Paraswap calldata for swap-backed route.'); } return { diff --git a/src/hooks/leverage/math.ts b/src/hooks/leverage/math.ts index 91bc4c02..572a5c88 100644 --- a/src/hooks/leverage/math.ts +++ b/src/hooks/leverage/math.ts @@ -1,14 +1,33 @@ import { formatUnits } from 'viem'; -import { LEVERAGE_MAX_MULTIPLIER_BPS, LEVERAGE_MIN_MULTIPLIER_BPS, LEVERAGE_MULTIPLIER_SCALE_BPS } from './types'; +import { LEVERAGE_MIN_MULTIPLIER_BPS, LEVERAGE_MULTIPLIER_SCALE_BPS } from './types'; export const LEVERAGE_SLIPPAGE_BUFFER_BPS = 9_950n; // 0.50% tolerance +export const BPS_SCALE = 10_000n; +export const WAD_TO_BPS_SCALE = 100_000_000_000_000n; +const DEFAULT_SLIPPAGE_TOLERANCE_BPS = BPS_SCALE - LEVERAGE_SLIPPAGE_BUFFER_BPS; +const MAX_TARGET_LTV_BPS = BPS_SCALE - 1n; 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 UNSIGNED_INTEGER_REGEX = /^\d+$/; const minBigInt = (a: bigint, b: bigint): bigint => (a < b ? a : b); const floorSub = (value: bigint, subtract: bigint): bigint => (value > subtract ? value - subtract : 0n); +const getSlippageToleranceBps = (slippageBps?: number): bigint => { + if (slippageBps == null || !Number.isFinite(slippageBps)) return DEFAULT_SLIPPAGE_TOLERANCE_BPS; + const rounded = BigInt(Math.round(slippageBps)); + if (rounded <= 0n) return 0n; + if (rounded >= BPS_SCALE) return BPS_SCALE - 1n; + return rounded; +}; + +const getSlippageFloorBps = (slippageBps?: number): bigint => { + const toleranceBps = getSlippageToleranceBps(slippageBps); + const floorBps = BPS_SCALE - toleranceBps; + return floorBps > 0n ? floorBps : 1n; +}; + const toScaledRatio = (numerator: bigint, denominator: bigint): number | null => { if (denominator <= 0n) return null; const scaledRatio = (numerator * APY_RATIO_SCALE) / denominator; @@ -16,39 +35,122 @@ const toScaledRatio = (numerator: bigint, denominator: bigint): number | null => return Number.isFinite(ratio) ? ratio : null; }; -export const clampMultiplierBps = (value: bigint): bigint => { +export const clampMultiplierBps = (value: bigint, maxMultiplierBps?: bigint): bigint => { if (value < LEVERAGE_MIN_MULTIPLIER_BPS) return LEVERAGE_MIN_MULTIPLIER_BPS; - if (value > LEVERAGE_MAX_MULTIPLIER_BPS) return LEVERAGE_MAX_MULTIPLIER_BPS; + if (maxMultiplierBps && maxMultiplierBps >= LEVERAGE_MIN_MULTIPLIER_BPS && value > maxMultiplierBps) { + return maxMultiplierBps; + } return value; }; -export const parseMultiplierToBps = (value: string): bigint => { +export const clampTargetLtvBps = (value: bigint, maxTargetLtvBps?: bigint): bigint => { + if (value <= 0n) return 0n; + const boundedMax = + maxTargetLtvBps === undefined ? MAX_TARGET_LTV_BPS : minBigInt(maxTargetLtvBps > 0n ? maxTargetLtvBps : 0n, MAX_TARGET_LTV_BPS); + return value > boundedMax ? boundedMax : value; +}; + +export const parseUnsignedBigInt = (value: unknown): bigint | null => { + if (typeof value === 'bigint') return value >= 0n ? value : null; + if (typeof value === 'number') { + if (!Number.isFinite(value) || !Number.isInteger(value) || value < 0) return null; + return BigInt(value); + } + if (typeof value === 'string') { + const normalized = value.trim(); + if (!UNSIGNED_INTEGER_REGEX.test(normalized)) return null; + try { + return BigInt(normalized); + } catch { + return null; + } + } + return null; +}; + +export const clampPercentBps = (value: bigint, maxPercentBps?: bigint): bigint => { + if (value <= 0n) return 0n; + if (maxPercentBps !== undefined && maxPercentBps >= 0n && value > maxPercentBps) { + return maxPercentBps; + } + return value; +}; + +export const parseMultiplierToBps = (value: string, maxMultiplierBps?: bigint): 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))); + const scaled = parsed * Number(BPS_SCALE); + const rounded = Math.round(scaled); + if (!Number.isFinite(scaled) || !Number.isSafeInteger(rounded)) { + return clampMultiplierBps(maxMultiplierBps ?? LEVERAGE_MIN_MULTIPLIER_BPS, maxMultiplierBps); + } + + return clampMultiplierBps(BigInt(rounded), maxMultiplierBps); }; -export const formatMultiplierBps = (value: bigint): string => { - const safe = clampMultiplierBps(value); - return (Number(safe) / 10_000).toFixed(2); +export const formatMultiplierBps = (value: bigint, maxMultiplierBps?: bigint): string => { + const safe = clampMultiplierBps(value, maxMultiplierBps); + return (Number(safe) / Number(BPS_SCALE)).toFixed(2); }; +export const parsePercentToBps = (value: string, maxPercentBps?: bigint): bigint => { + const normalized = value.trim().replace(',', '.'); + if (normalized.length === 0) return 0n; + + const parsed = Number.parseFloat(normalized); + if (!Number.isFinite(parsed) || parsed <= 0) return 0n; + const scaled = parsed * 100; + const rounded = Math.round(scaled); + if (!Number.isFinite(scaled) || !Number.isSafeInteger(rounded)) { + return clampPercentBps(maxPercentBps ?? 0n, maxPercentBps); + } + + return clampPercentBps(BigInt(rounded), maxPercentBps); +}; + +export const formatPercentFromBps = (value: bigint): string => { + const safe = value > 0n ? value : 0n; + return (Number(safe) / 100).toFixed(2); +}; + +export const targetLtvBpsFromMultiplier = (multiplierBps: bigint): bigint => { + const safeMultiplier = clampMultiplierBps(multiplierBps); + if (safeMultiplier <= BPS_SCALE) return 0n; + return clampTargetLtvBps(((safeMultiplier - BPS_SCALE) * BPS_SCALE) / safeMultiplier); +}; + +export const multiplierBpsFromTargetLtv = (targetLtvBps: bigint, maxMultiplierBps?: bigint): bigint => { + const safeTargetLtvBps = clampTargetLtvBps(targetLtvBps); + if (safeTargetLtvBps <= 0n) return LEVERAGE_MIN_MULTIPLIER_BPS; + const denominator = BPS_SCALE - safeTargetLtvBps; + if (denominator <= 0n) return clampMultiplierBps(maxMultiplierBps ?? LEVERAGE_MIN_MULTIPLIER_BPS, maxMultiplierBps); + const derived = (BPS_SCALE * BPS_SCALE) / denominator; + return clampMultiplierBps(derived, maxMultiplierBps); +}; + +export const ltvWadToBps = (ltvWad: bigint): bigint => { + if (ltvWad <= 0n) return 0n; + return clampTargetLtvBps(ltvWad / WAD_TO_BPS_SCALE); +}; + +export const computeMaxMultiplierBpsForTargetLtv = (targetLtvBps: bigint): bigint => multiplierBpsFromTargetLtv(targetLtvBps); + /** * Converts user collateral and desired multiplier into extra collateral required * via flash liquidity. */ -export const computeLeveragedExtraAmount = (baseAmount: bigint, multiplierBps: bigint): bigint => { +export const computeLeveragedExtraAmount = (baseAmount: bigint, multiplierBps: bigint, maxMultiplierBps?: bigint): bigint => { if (baseAmount <= 0n) return 0n; - const safeMultiplier = clampMultiplierBps(multiplierBps); + const safeMultiplier = clampMultiplierBps(multiplierBps, maxMultiplierBps); const leveragedAmount = (baseAmount * safeMultiplier) / LEVERAGE_MULTIPLIER_SCALE_BPS; return leveragedAmount > baseAmount ? leveragedAmount - baseAmount : 0n; }; -export const computeFlashCollateralAmount = (userCollateralAmount: bigint, multiplierBps: bigint): bigint => { - return computeLeveragedExtraAmount(userCollateralAmount, multiplierBps); +export const computeFlashCollateralAmount = (userCollateralAmount: bigint, multiplierBps: bigint, maxMultiplierBps?: bigint): bigint => { + return computeLeveragedExtraAmount(userCollateralAmount, multiplierBps, maxMultiplierBps); }; export const computeLeverageProjectedPosition = ({ @@ -86,6 +188,7 @@ export const computeDeleverageProjectedPosition = ({ maxCollateralForDebtRepay, closeRouteAvailable, closeBoundIsInputCap, + slippageBps, }: { currentCollateralAssets: bigint; currentBorrowAssets: bigint; @@ -95,12 +198,13 @@ export const computeDeleverageProjectedPosition = ({ maxCollateralForDebtRepay: bigint; closeRouteAvailable: boolean; closeBoundIsInputCap: boolean; + slippageBps?: number; }): DeleverageProjectedPosition => { const maxWithdrawCollateral = closeRouteAvailable && closeBoundIsInputCap ? minBigInt(maxCollateralForDebtRepay, currentCollateralAssets) : currentCollateralAssets; const boundedWithdrawCollateral = minBigInt(withdrawCollateralAmount, currentCollateralAssets); const projectedCollateralAfterInput = floorSub(currentCollateralAssets, boundedWithdrawCollateral); - const bufferedBorrowAssets = withSlippageCeil(currentBorrowAssets); + const bufferedBorrowAssets = withSlippageCeil(currentBorrowAssets, slippageBps); // WHY: full-close execution depends on the dedicated close bound, not the optimistic/pessimistic // characteristics of the preview repay leg. This keeps repay-by-shares aligned with the real // executable close path and avoids leaving debt dust on swap-backed unwinds. @@ -247,15 +351,16 @@ export const formatTokenAmountPreview = (value: bigint, decimals: number): { com full: formatFullTokenAmount(value, decimals), }); -export const withSlippageFloor = (value: bigint): bigint => { +export const withSlippageFloor = (value: bigint, slippageBps?: number): bigint => { if (value <= 0n) return 0n; - const floored = (value * LEVERAGE_SLIPPAGE_BUFFER_BPS) / LEVERAGE_MULTIPLIER_SCALE_BPS; + const floorBps = getSlippageFloorBps(slippageBps); + const floored = (value * floorBps) / LEVERAGE_MULTIPLIER_SCALE_BPS; return floored > 0n ? floored : 1n; }; -export const withSlippageCeil = (value: bigint): bigint => { +export const withSlippageCeil = (value: bigint, slippageBps?: number): bigint => { if (value <= 0n) return 0n; - const ceilBps = LEVERAGE_MULTIPLIER_SCALE_BPS + (LEVERAGE_MULTIPLIER_SCALE_BPS - LEVERAGE_SLIPPAGE_BUFFER_BPS); + const ceilBps = LEVERAGE_MULTIPLIER_SCALE_BPS + getSlippageToleranceBps(slippageBps); return (value * ceilBps + LEVERAGE_MULTIPLIER_SCALE_BPS - 1n) / LEVERAGE_MULTIPLIER_SCALE_BPS; }; diff --git a/src/hooks/leverage/types.ts b/src/hooks/leverage/types.ts index 313b182e..64906b2d 100644 --- a/src/hooks/leverage/types.ts +++ b/src/hooks/leverage/types.ts @@ -18,4 +18,3 @@ export type LeverageRoute = Erc4626LeverageRoute | SwapLeverageRoute; 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 index 5caae572..2b2cc75a 100644 --- a/src/hooks/use4626VaultAPR.ts +++ b/src/hooks/use4626VaultAPR.ts @@ -7,6 +7,7 @@ import { useCustomRpcContext } from '@/components/providers/CustomRpcProvider'; import { computeAnnualizedApyFromGrowth, computeExpectedNetCarryApy } from '@/hooks/leverage/math'; import { estimateBlockAtTimestamp } from '@/utils/blockEstimation'; import { getMorphoAddress } from '@/utils/morpho'; +import type { SupportedNetworks } from '@/utils/networks'; import { getClient } from '@/utils/rpc'; import type { Market } from '@/utils/types'; @@ -62,7 +63,7 @@ export function use4626VaultAPR({ }: Use4626VaultAPRParams): Use4626VaultAPRResult { const { customRpcUrls } = useCustomRpcContext(); const chainId = market.morphoBlue.chain.id; - const customRpcUrl = customRpcUrls[chainId]; + const customRpcUrl = customRpcUrls[chainId as SupportedNetworks]; const oneShareUnit = useMemo(() => 10n ** BigInt(market.collateralAsset.decimals), [market.collateralAsset.decimals]); const query = useQuery({ diff --git a/src/hooks/useBorrowTransaction.ts b/src/hooks/useBorrowTransaction.ts index 9ccee9e8..74759338 100644 --- a/src/hooks/useBorrowTransaction.ts +++ b/src/hooks/useBorrowTransaction.ts @@ -4,6 +4,7 @@ import { useConnection } from 'wagmi'; import morphoBundlerAbi from '@/abis/bundlerV2'; import { formatBalance } from '@/utils/balance'; import { getBundlerV2, MONARCH_TX_IDENTIFIER } from '@/utils/morpho'; +import { isUserRejectedTransactionError, toUserFacingTransactionErrorMessage } from '@/utils/transaction-errors'; import type { Market } from '@/utils/types'; import { useERC20Approval } from './useERC20Approval'; import { useBundlerAuthorizationStep } from './useBundlerAuthorizationStep'; @@ -317,8 +318,10 @@ export function useBorrowTransaction({ market, collateralAmount, borrowAmount, o } catch (error: unknown) { tracking.fail(); console.error('Error during borrow execution:', error); - if (error instanceof Error && !error.message.toLowerCase().includes('rejected')) { - toast.error('Borrow Failed', 'An unexpected error occurred during borrow.'); + const isUserRejected = isUserRejectedTransactionError(error); + if (!isUserRejected) { + const userFacingMessage = toUserFacingTransactionErrorMessage(error, 'An unexpected error occurred during borrow.'); + toast.error('Borrow Failed', userFacingMessage); } } }, [ @@ -332,6 +335,7 @@ export function useBorrowTransaction({ market, collateralAmount, borrowAmount, o permit2Authorized, authorizePermit2, ensureBundlerAuthorization, + isBundlerAuthorized, signForBundlers, isApproved, approve, @@ -368,14 +372,10 @@ export function useBorrowTransaction({ market, collateralAmount, borrowAmount, o } catch (error: unknown) { console.error('Error in approveAndBorrow:', 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 transaction'); - } - } else { - toast.error('Error', 'An unexpected error occurred'); + const isUserRejected = isUserRejectedTransactionError(error); + if (!isUserRejected) { + const userFacingMessage = toUserFacingTransactionErrorMessage(error, 'Failed to process transaction'); + toast.error('Error', userFacingMessage); } } }, [ @@ -416,14 +416,10 @@ export function useBorrowTransaction({ market, collateralAmount, borrowAmount, o } catch (error: unknown) { console.error('Error in signAndBorrow:', 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 transaction'); - } - } else { - toast.error('Transaction Error', 'An unexpected error occurred'); + const isUserRejected = isUserRejectedTransactionError(error); + if (!isUserRejected) { + const userFacingMessage = toUserFacingTransactionErrorMessage(error, 'Failed to process transaction'); + toast.error('Transaction Error', userFacingMessage); } } }, [ diff --git a/src/hooks/useDeleverageQuote.ts b/src/hooks/useDeleverageQuote.ts index f9775b78..36c3c407 100644 --- a/src/hooks/useDeleverageQuote.ts +++ b/src/hooks/useDeleverageQuote.ts @@ -12,6 +12,7 @@ type UseDeleverageQuoteParams = { withdrawCollateralAmount: bigint; currentBorrowAssets: bigint; currentBorrowShares: bigint; + slippageBps: number; loanTokenAddress: string; loanTokenDecimals: number; collateralTokenAddress: string; @@ -44,13 +45,14 @@ export function useDeleverageQuote({ withdrawCollateralAmount, currentBorrowAssets, currentBorrowShares, + slippageBps, loanTokenAddress, loanTokenDecimals, collateralTokenAddress, collateralTokenDecimals, userAddress, }: UseDeleverageQuoteParams): DeleverageQuote { - const bufferedBorrowAssets = withSlippageCeil(currentBorrowAssets); + const bufferedBorrowAssets = withSlippageCeil(currentBorrowAssets, slippageBps); const swapExecutionAddress = route?.kind === 'swap' ? route.paraswapAdapterAddress : null; const { @@ -94,6 +96,7 @@ export function useDeleverageQuote({ loanTokenDecimals, swapExecutionAddress, withdrawCollateralAmount.toString(), + slippageBps, userAddress ?? null, ], enabled: route?.kind === 'swap' && withdrawCollateralAmount > 0n && !!userAddress, @@ -115,7 +118,7 @@ export function useDeleverageQuote({ } return { - rawRouteRepayAmount: withSlippageFloor(BigInt(sellRoute.destAmount)), + rawRouteRepayAmount: withSlippageFloor(BigInt(sellRoute.destAmount), slippageBps), priceRoute: sellRoute, }; }, @@ -132,6 +135,7 @@ export function useDeleverageQuote({ loanTokenDecimals, swapExecutionAddress, bufferedBorrowAssets.toString(), + slippageBps, userAddress ?? null, ], enabled: route?.kind === 'swap' && bufferedBorrowAssets > 0n && !!userAddress, diff --git a/src/hooks/useDeleverageTransaction.ts b/src/hooks/useDeleverageTransaction.ts index f1fa09e5..d0f1639a 100644 --- a/src/hooks/useDeleverageTransaction.ts +++ b/src/hooks/useDeleverageTransaction.ts @@ -1,4 +1,4 @@ -import { useCallback, useMemo } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { type Address, encodeAbiParameters, encodeFunctionData, isAddress, isAddressEqual, keccak256, maxUint256, zeroHash } from 'viem'; import { useConnection } from 'wagmi'; import morphoBundlerAbi from '@/abis/bundlerV2'; @@ -6,7 +6,6 @@ import { bundlerV3Abi } from '@/abis/bundlerV3'; import { morphoGeneralAdapterV1Abi } from '@/abis/morphoGeneralAdapterV1'; import { paraswapAdapterAbi } from '@/abis/paraswapAdapter'; import { buildVeloraTransactionPayload, isVeloraRateChangedError, type VeloraPriceRoute } from '@/features/swap/api/velora'; -import { DEFAULT_SLIPPAGE_PERCENT } from '@/features/swap/constants'; import { useBundlerAuthorizationStep } from '@/hooks/useBundlerAuthorizationStep'; import { useStyledToast } from '@/hooks/useStyledToast'; import { useTransactionWithToast } from '@/hooks/useTransactionWithToast'; @@ -35,11 +34,10 @@ type UseDeleverageTransactionProps = { autoWithdrawCollateralAmount: bigint; maxCollateralForDebtRepay: bigint; swapSellPriceRoute: VeloraPriceRoute | null; + slippageBps: number; onSuccess?: () => void; }; -const DELEVERAGE_SWAP_SLIPPAGE_BPS = Math.round(DEFAULT_SLIPPAGE_PERCENT * 100); - /** * Executes deleverage transactions for: * - ERC4626 deterministic loops on Bundler V2 @@ -56,12 +54,14 @@ export function useDeleverageTransaction({ autoWithdrawCollateralAmount, maxCollateralForDebtRepay, swapSellPriceRoute, + slippageBps, onSuccess, }: UseDeleverageTransactionProps) { const { usePermit2: usePermit2Setting } = useAppSettings(); const tracking = useTransactionTracking('deleverage'); const { address: account, chainId } = useConnection(); const toast = useStyledToast(); + const [executionError, setExecutionError] = useState(null); const isSwapRoute = route?.kind === 'swap'; const useSignatureAuthorization = usePermit2Setting && !isSwapRoute; const bundlerAddress = useMemo
(() => { @@ -95,6 +95,7 @@ export function useDeleverageTransaction({ pendingDescription: `Executing deleverage on market ${market.uniqueKey.slice(2, 8)}...`, successDescription: 'Position delevered successfully', onSuccess: () => { + setExecutionError(null); void refetchIsBundlerAuthorized(); if (onSuccess) void onSuccess(); }, @@ -111,7 +112,7 @@ export function useDeleverageTransaction({ { id: 'execute', title: 'Confirm Deleverage', - description: 'Confirm the Bundler3 deleverage transaction in your wallet.', + description: 'Confirm the deleverage transaction in your wallet.', }, ]; } @@ -213,6 +214,9 @@ export function useDeleverageTransaction({ } if (route.kind === 'swap') { + if (!Number.isFinite(slippageBps) || slippageBps <= 0) { + throw new Error('Invalid slippage tolerance. Please set a positive slippage value.'); + } const swapExecutionAddress = route.paraswapAdapterAddress; if (useCloseRoute) { if (maxCollateralForDebtRepay <= 0n) { @@ -240,7 +244,7 @@ export function useDeleverageTransaction({ network: market.morphoBlue.chain.id, userAddress: swapExecutionAddress, priceRoute: activePriceRoute, - slippageBps: DELEVERAGE_SWAP_SLIPPAGE_BPS, + slippageBps, ignoreChecks, }); @@ -292,7 +296,7 @@ export function useDeleverageTransaction({ } const swapCallData = swapTxPayload.data; - const minLoanOut = withSlippageFloor(quotedLoanOut); + const minLoanOut = withSlippageFloor(quotedLoanOut, slippageBps); if (isCloseSwap) { if (minLoanOut < flashLoanAmount) { throw new Error('Deleverage quote changed. Please review the updated preview and try again.'); @@ -499,6 +503,7 @@ export function useDeleverageTransaction({ tracking.fail(); console.error('Error during deleverage execution:', error); const userFacingMessage = toUserFacingTransactionErrorMessage(error, 'An unexpected error occurred during deleverage.'); + setExecutionError(userFacingMessage === 'User rejected transaction.' ? null : userFacingMessage); if (userFacingMessage !== 'User rejected transaction.') { toast.error('Deleverage Failed', userFacingMessage); } @@ -515,6 +520,7 @@ export function useDeleverageTransaction({ autoWithdrawCollateralAmount, maxCollateralForDebtRepay, swapSellPriceRoute, + slippageBps, useSignatureAuthorization, isBundlerAuthorized, ensureBundlerAuthorization, @@ -523,8 +529,13 @@ export function useDeleverageTransaction({ batchAddUserMarkets, tracking, toast, + setExecutionError, ]); + const clearExecutionError = useCallback(() => { + setExecutionError(null); + }, []); + const authorizeAndDeleverage = useCallback(async () => { if (!account) { toast.info('No account connected', 'Please connect your wallet.'); @@ -536,6 +547,7 @@ export function useDeleverageTransaction({ } try { + setExecutionError(null); const initialStep: DeleverageStepType = isBundlerAuthorized ? 'execute' : useSignatureAuthorization @@ -558,6 +570,7 @@ export function useDeleverageTransaction({ console.error('Error in authorizeAndDeleverage:', error); tracking.fail(); const userFacingMessage = toUserFacingTransactionErrorMessage(error, 'Failed to process deleverage transaction'); + setExecutionError(userFacingMessage === 'User rejected transaction.' ? null : userFacingMessage); if (userFacingMessage !== 'User rejected transaction.') { toast.error('Error', userFacingMessage); } @@ -575,6 +588,7 @@ export function useDeleverageTransaction({ withdrawCollateralAmount, executeDeleverage, toast, + setExecutionError, ]); const isAuthorizationStatusLoading = @@ -588,6 +602,8 @@ export function useDeleverageTransaction({ deleveragePending, isLoading, isBundlerAuthorized, + executionError, + clearExecutionError, authorizeAndDeleverage, }; } diff --git a/src/hooks/useLeverageQuote.ts b/src/hooks/useLeverageQuote.ts index 64469125..d6fc6075 100644 --- a/src/hooks/useLeverageQuote.ts +++ b/src/hooks/useLeverageQuote.ts @@ -11,6 +11,7 @@ type UseLeverageQuoteParams = { route: LeverageRoute | null; userInputAmount: bigint; inputMode: 'collateral' | 'loan'; + slippageBps: number; multiplierBps: bigint; loanTokenAddress: string; loanTokenDecimals: number; @@ -40,6 +41,7 @@ export function useLeverageQuote({ route, userInputAmount, inputMode, + slippageBps, multiplierBps, loanTokenAddress, loanTokenDecimals, @@ -106,6 +108,7 @@ export function useLeverageQuote({ collateralTokenDecimals, swapExecutionAddress, targetFlashCollateralAmount.toString(), + slippageBps, userAddress ?? null, ], enabled: route?.kind === 'swap' && !isLoanAssetInput && targetFlashCollateralAmount > 0n && !!userAddress, @@ -146,7 +149,7 @@ export function useLeverageQuote({ return { flashLoanAmount: borrowAssets, - flashCollateralAmount: withSlippageFloor(BigInt(sellRoute.destAmount)), + flashCollateralAmount: withSlippageFloor(BigInt(sellRoute.destAmount), slippageBps), priceRoute: sellRoute, }; }, @@ -165,6 +168,7 @@ export function useLeverageQuote({ swapExecutionAddress, userInputAmount.toString(), multiplierBps.toString(), + slippageBps, userAddress ?? null, ], enabled: route?.kind === 'swap' && isLoanAssetInput && userInputAmount > 0n && !!userAddress, @@ -194,7 +198,7 @@ export function useLeverageQuote({ throw new Error('Failed to quote stable Velora swap route for leverage.'); } - const totalAddedCollateral = withSlippageFloor(BigInt(sellRoute.destAmount)); + const totalAddedCollateral = withSlippageFloor(BigInt(sellRoute.destAmount), slippageBps); return { flashLoanAmount, diff --git a/src/hooks/useLeverageTransaction.ts b/src/hooks/useLeverageTransaction.ts index 41c117d7..5b345e28 100644 --- a/src/hooks/useLeverageTransaction.ts +++ b/src/hooks/useLeverageTransaction.ts @@ -8,7 +8,6 @@ import { morphoGeneralAdapterV1Abi } from '@/abis/morphoGeneralAdapterV1'; import { paraswapAdapterAbi } from '@/abis/paraswapAdapter'; import permit2Abi from '@/abis/permit2'; import { buildVeloraTransactionPayload, isVeloraRateChangedError, type VeloraPriceRoute } from '@/features/swap/api/velora'; -import { DEFAULT_SLIPPAGE_PERCENT } from '@/features/swap/constants'; import { useERC20Approval } from '@/hooks/useERC20Approval'; import { useBundlerAuthorizationStep } from '@/hooks/useBundlerAuthorizationStep'; import { usePermit2 } from '@/hooks/usePermit2'; @@ -44,12 +43,11 @@ type UseLeverageTransactionProps = { flashLoanAmount: bigint; totalAddedCollateral: bigint; swapPriceRoute: VeloraPriceRoute | null; + slippageBps: number; useLoanAssetAsInput: boolean; onSuccess?: () => void; }; -const LEVERAGE_SWAP_SLIPPAGE_BPS = Math.round(DEFAULT_SLIPPAGE_PERCENT * 100); - /** * Executes leverage transactions for: * - ERC4626 deterministic loops on Bundler V2 @@ -64,6 +62,7 @@ export function useLeverageTransaction({ flashLoanAmount, totalAddedCollateral, swapPriceRoute, + slippageBps, useLoanAssetAsInput, onSuccess, }: UseLeverageTransactionProps) { @@ -167,7 +166,7 @@ export function useLeverageTransaction({ { id: 'execute', title: 'Confirm Leverage', - description: 'Confirm the Bundler3 leverage transaction in your wallet.', + description: 'Confirm the leverage transaction in your wallet.', }, ]; } @@ -187,7 +186,7 @@ export function useLeverageTransaction({ { id: 'execute', title: 'Confirm Leverage', - description: 'Confirm the Bundler3 leverage transaction in your wallet.', + description: 'Confirm the leverage transaction in your wallet.', }, ]; } @@ -347,6 +346,9 @@ export function useLeverageTransaction({ }; if (route.kind === 'swap') { + if (!Number.isFinite(slippageBps) || slippageBps <= 0) { + throw new Error('Invalid slippage tolerance. Please set a positive slippage value.'); + } if (!swapPriceRoute) { throw new Error('Missing Velora swap quote for leverage.'); } @@ -370,7 +372,7 @@ export function useLeverageTransaction({ network: market.morphoBlue.chain.id, userAddress: swapExecutionAddress, priceRoute: activePriceRoute, - slippageBps: LEVERAGE_SWAP_SLIPPAGE_BPS, + slippageBps, ignoreChecks, }); @@ -660,6 +662,7 @@ export function useLeverageTransaction({ flashLoanAmount, totalAddedCollateral, swapPriceRoute, + slippageBps, usePermit2ForRoute, permit2Authorized, isBundlerAuthorized, diff --git a/src/modals/leverage/components/add-collateral-and-leverage.tsx b/src/modals/leverage/components/add-collateral-and-leverage.tsx index eaa3ed5c..78438ccf 100644 --- a/src/modals/leverage/components/add-collateral-and-leverage.tsx +++ b/src/modals/leverage/components/add-collateral-and-leverage.tsx @@ -2,7 +2,11 @@ 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 { + clampEditablePercent, + computeLtv, + formatEditableLtvPercent, +} 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'; @@ -10,19 +14,28 @@ import { ExecuteTransactionButton } from '@/components/ui/ExecuteTransactionButt import { IconSwitch } from '@/components/ui/icon-switch'; import { Tooltip } from '@/components/ui/tooltip'; import { + clampTargetLtvBps, clampMultiplierBps, + computeMaxMultiplierBpsForTargetLtv, computeLeverageProjectedPosition, + formatPercentFromBps, formatMultiplierBps, formatTokenAmountPreview, + ltvWadToBps, + multiplierBpsFromTargetLtv, + parsePercentToBps, parseMultiplierToBps, + parseUnsignedBigInt, + targetLtvBpsFromMultiplier, } 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 { DEFAULT_SLIPPAGE_PERCENT } from '@/features/swap/constants'; -import { formatSlippagePercent, formatSwapRatePreview } from '@/features/swap/utils/quote-preview'; +import { SlippageInlineEditor } from '@/features/swap/components/SlippageInlineEditor'; +import { DEFAULT_SLIPPAGE_PERCENT, slippagePercentToBps } from '@/features/swap/constants'; +import { formatSwapRatePreview } from '@/features/swap/utils/quote-preview'; import { formatBalance } from '@/utils/balance'; import { convertApyToApr } from '@/utils/rateMath'; import type { LeverageRoute } from '@/hooks/leverage/types'; @@ -38,7 +51,8 @@ type AddCollateralAndLeverageProps = { isRefreshing?: boolean; }; -const MULTIPLIER_INPUT_REGEX = /^\d*\.?\d*$/; +const EDITABLE_DECIMAL_INPUT_REGEX = /^\d*[.,]?\d*$/; +const LEVERAGE_SAFE_LTV_BUFFER_BPS = 100n; // keep a 1% buffer below liquidation LTV export function AddCollateralAndLeverage({ market, @@ -51,16 +65,32 @@ export function AddCollateralAndLeverage({ }: AddCollateralAndLeverageProps): JSX.Element { const { address: account } = useConnection(); const { usePermit2: usePermit2Setting, isAprDisplay } = useAppSettings(); + const lltv = useMemo(() => parseUnsignedBigInt(market.lltv) ?? 0n, [market.lltv]); + const lltvBps = useMemo(() => ltvWadToBps(lltv), [lltv]); + const maxTargetLtvBps = useMemo(() => (lltvBps > LEVERAGE_SAFE_LTV_BUFFER_BPS ? lltvBps - LEVERAGE_SAFE_LTV_BUFFER_BPS : 0n), [lltvBps]); + const maxMultiplierBps = useMemo(() => computeMaxMultiplierBpsForTargetLtv(maxTargetLtvBps), [maxTargetLtvBps]); + const defaultMultiplierBps = useMemo(() => clampMultiplierBps(LEVERAGE_DEFAULT_MULTIPLIER_BPS, maxMultiplierBps), [maxMultiplierBps]); const [collateralAmount, setCollateralAmount] = useState(0n); const [collateralInputError, setCollateralInputError] = useState(null); - const [multiplierInput, setMultiplierInput] = useState(formatMultiplierBps(LEVERAGE_DEFAULT_MULTIPLIER_BPS)); + const [multiplierInput, setMultiplierInput] = useState(formatMultiplierBps(LEVERAGE_DEFAULT_MULTIPLIER_BPS, maxMultiplierBps)); + const [targetLtvInput, setTargetLtvInput] = useState( + formatPercentFromBps(clampTargetLtvBps(targetLtvBpsFromMultiplier(LEVERAGE_DEFAULT_MULTIPLIER_BPS), maxTargetLtvBps)), + ); + const [useTargetLtvInput, setUseTargetLtvInput] = useState(true); const [useLoanAssetInput, setUseLoanAssetInput] = useState(false); + const [targetMultiplierBps, setTargetMultiplierBps] = useState(defaultMultiplierBps); + const [swapSlippagePercent, setSwapSlippagePercent] = useState(DEFAULT_SLIPPAGE_PERCENT); - const multiplierBps = useMemo(() => clampMultiplierBps(parseMultiplierToBps(multiplierInput)), [multiplierInput]); + const multiplierBps = useMemo(() => clampMultiplierBps(targetMultiplierBps, maxMultiplierBps), [targetMultiplierBps, maxMultiplierBps]); + const targetLtvBps = useMemo( + () => clampTargetLtvBps(targetLtvBpsFromMultiplier(multiplierBps), maxTargetLtvBps), + [multiplierBps, maxTargetLtvBps], + ); + const swapSlippageBps = useMemo(() => slippagePercentToBps(swapSlippagePercent), [swapSlippagePercent]); + const maxTargetLtvPercent = useMemo(() => Number(maxTargetLtvBps) / 100, [maxTargetLtvBps]); const isErc4626Route = route?.kind === 'erc4626'; const isSwapRoute = route?.kind === 'swap'; - const routeLabel = isSwapRoute ? 'Route: Swap (Bundler3 + Velora)' : isErc4626Route ? 'Route: Vault (ERC4626)' : 'Route: Unsupported'; const canUseLoanAssetInput = isErc4626Route || isSwapRoute; const { data: loanTokenBalance, refetch: refetchLoanTokenBalance } = useReadContract({ @@ -85,6 +115,14 @@ export function AddCollateralAndLeverage({ setUseLoanAssetInput(false); }, [canUseLoanAssetInput]); + useEffect(() => { + const clampedMultiplier = clampMultiplierBps(targetMultiplierBps, maxMultiplierBps); + if (clampedMultiplier === targetMultiplierBps) return; + setTargetMultiplierBps(clampedMultiplier); + setMultiplierInput(formatMultiplierBps(clampedMultiplier, maxMultiplierBps)); + setTargetLtvInput(formatPercentFromBps(clampTargetLtvBps(targetLtvBpsFromMultiplier(clampedMultiplier), maxTargetLtvBps))); + }, [targetMultiplierBps, maxMultiplierBps, maxTargetLtvBps]); + const quote = useLeverageQuote({ chainId: market.morphoBlue.chain.id, route, @@ -96,6 +134,7 @@ export function AddCollateralAndLeverage({ collateralTokenAddress: market.collateralAsset.address, collateralTokenDecimals: market.collateralAsset.decimals, userAddress: account as `0x${string}` | undefined, + slippageBps: swapSlippageBps, }); const currentCollateralAssets = BigInt(currentPosition?.state.collateral ?? 0); @@ -110,7 +149,6 @@ export function AddCollateralAndLeverage({ }), [currentCollateralAssets, currentBorrowAssets, quote.totalAddedCollateral, quote.flashLoanAmount], ); - const lltv = BigInt(market.lltv); const marketLiquidity = BigInt(market.state.liquidityAssets); const rateLabel = isAprDisplay ? 'APR' : 'APY'; @@ -143,16 +181,28 @@ export function AddCollateralAndLeverage({ [currentBorrowAssets, currentCollateralAssets, oraclePrice], ); + const syncInputFieldsFromMultiplier = useCallback( + (nextMultiplierBps: bigint) => { + const clampedMultiplier = clampMultiplierBps(nextMultiplierBps, maxMultiplierBps); + const derivedTargetLtvBps = clampTargetLtvBps(targetLtvBpsFromMultiplier(clampedMultiplier), maxTargetLtvBps); + + setTargetMultiplierBps(clampedMultiplier); + setMultiplierInput(formatMultiplierBps(clampedMultiplier, maxMultiplierBps)); + setTargetLtvInput(formatPercentFromBps(derivedTargetLtvBps)); + }, + [maxMultiplierBps, maxTargetLtvBps], + ); + 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)); + syncInputFieldsFromMultiplier(defaultMultiplierBps); if (useLoanAssetInput) { void refetchLoanTokenBalance(); } if (onSuccess) onSuccess(); - }, [onSuccess, refetchLoanTokenBalance, useLoanAssetInput]); + }, [defaultMultiplierBps, onSuccess, refetchLoanTokenBalance, syncInputFieldsFromMultiplier, useLoanAssetInput]); const { transaction, @@ -172,18 +222,48 @@ export function AddCollateralAndLeverage({ totalAddedCollateral: quote.totalAddedCollateral, swapPriceRoute: quote.swapPriceRoute, useLoanAssetAsInput: useLoanAssetInput, + slippageBps: swapSlippageBps, onSuccess: handleTransactionSuccess, }); - const handleMultiplierInputChange = useCallback((value: string) => { - const normalized = value.replace(',', '.'); - if (!MULTIPLIER_INPUT_REGEX.test(normalized)) return; - setMultiplierInput(normalized); - }, []); + const handleMultiplierInputChange = useCallback( + (value: string) => { + if (!EDITABLE_DECIMAL_INPUT_REGEX.test(value)) return; + setMultiplierInput(value); + }, + [], + ); const handleMultiplierInputBlur = useCallback(() => { - setMultiplierInput(formatMultiplierBps(clampMultiplierBps(parseMultiplierToBps(multiplierInput)))); - }, [multiplierInput]); + syncInputFieldsFromMultiplier(parseMultiplierToBps(multiplierInput, maxMultiplierBps)); + }, [multiplierInput, maxMultiplierBps, syncInputFieldsFromMultiplier]); + + const handleTargetLtvInputChange = useCallback( + (value: string) => { + if (!EDITABLE_DECIMAL_INPUT_REGEX.test(value)) return; + setTargetLtvInput(value); + }, + [], + ); + + const handleTargetLtvInputBlur = useCallback(() => { + const parsedPercent = Number.parseFloat(targetLtvInput.replace(',', '.')); + if (!Number.isFinite(parsedPercent)) { + setTargetLtvInput(formatEditableLtvPercent(Number(targetLtvBps) / 100, maxTargetLtvPercent)); + return; + } + const clampedPercent = clampEditablePercent(parsedPercent, maxTargetLtvPercent); + const clampedTargetLtvBps = clampTargetLtvBps(parsePercentToBps(clampedPercent.toString(), maxTargetLtvBps), maxTargetLtvBps); + syncInputFieldsFromMultiplier(multiplierBpsFromTargetLtv(clampedTargetLtvBps, maxMultiplierBps)); + }, [targetLtvInput, targetLtvBps, maxMultiplierBps, maxTargetLtvBps, maxTargetLtvPercent, syncInputFieldsFromMultiplier]); + + const handleTargetInputModeChange = useCallback( + (nextUseTargetLtvInput: boolean) => { + setUseTargetLtvInput(nextUseTargetLtvInput); + syncInputFieldsFromMultiplier(targetMultiplierBps); + }, + [syncInputFieldsFromMultiplier, targetMultiplierBps], + ); const handleLeverage = useCallback(() => { const usePermit2Flow = usePermit2Setting; @@ -261,7 +341,6 @@ export function AddCollateralAndLeverage({ ]); const shouldShowSwapPreviewDetails = isSwapRoute && quote.swapPriceRoute != null && swapRatePreviewText != null; const shouldShowInputConversionPreview = isErc4626Route && useLoanAssetInput && quote.initialCollateralAmount > 0n; - const swapSlippagePreviewText = `${formatSlippagePercent(DEFAULT_SLIPPAGE_PERCENT)}%`; const renderRateValue = useCallback( (apy: number | null): JSX.Element => { if (apy == null || !Number.isFinite(apy)) return -; @@ -295,7 +374,6 @@ export function AddCollateralAndLeverage({ {!transaction?.isModalVisible && (

Leverage Preview

-

{routeLabel}

-

Target Multiplier

+
+

+ {useTargetLtvInput ? 'Target LTV' : 'Target Multiplier'} +

+
+
Use LTV
+ +
+
- 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 + {useTargetLtvInput ? ( + <> + handleTargetLtvInputChange(event.target.value)} + onBlur={handleTargetLtvInputBlur} + 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" + /> + % + + ) : ( + <> + 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 + + )}
@@ -431,7 +545,10 @@ export function AddCollateralAndLeverage({
Max Slippage - {swapSlippagePreviewText} +
)} diff --git a/src/modals/leverage/components/remove-collateral-and-deleverage.tsx b/src/modals/leverage/components/remove-collateral-and-deleverage.tsx index 4ca15ef4..a1c308fe 100644 --- a/src/modals/leverage/components/remove-collateral-and-deleverage.tsx +++ b/src/modals/leverage/components/remove-collateral-and-deleverage.tsx @@ -1,18 +1,31 @@ -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useConnection } from 'wagmi'; import Input from '@/components/Input/Input'; import { ExecuteTransactionButton } from '@/components/ui/ExecuteTransactionButton'; import { Tooltip } from '@/components/ui/tooltip'; +import { IconSwitch } from '@/components/ui/icon-switch'; import { LTVWarning } from '@/components/shared/ltv-warning'; import { TokenIcon } from '@/components/shared/token-icon'; -import { DEFAULT_SLIPPAGE_PERCENT } from '@/features/swap/constants'; -import { formatSlippagePercent, formatSwapRatePreview } from '@/features/swap/utils/quote-preview'; -import { computeDeleverageProjectedPosition, formatTokenAmountPreview } from '@/hooks/leverage/math'; +import { SlippageInlineEditor } from '@/features/swap/components/SlippageInlineEditor'; +import { DEFAULT_SLIPPAGE_PERCENT, slippagePercentToBps } from '@/features/swap/constants'; +import { formatSwapRatePreview } from '@/features/swap/utils/quote-preview'; +import { computeDeleverageProjectedPosition, formatTokenAmountPreview, parseUnsignedBigInt } from '@/hooks/leverage/math'; import { useDeleverageQuote } from '@/hooks/useDeleverageQuote'; import { useDeleverageTransaction } from '@/hooks/useDeleverageTransaction'; import type { Market, MarketPosition } from '@/utils/types'; import type { LeverageRoute } from '@/hooks/leverage/types'; -import { computeLtv, formatLtvPercent, getLTVColor } from '@/modals/borrow/components/helpers'; +import { + clampEditablePercent, + clampTargetLtv, + computeLtv, + formatEditableLtvPercent, + formatLtvPercent, + getCollateralValueInLoan, + getLTVColor, + ltvWadToPercent, + normalizeEditablePercentInput, + percentToLtvWad, +} from '@/modals/borrow/components/helpers'; import { BorrowPositionRiskCard } from '@/modals/borrow/components/borrow-position-risk-card'; type RemoveCollateralAndDeleverageProps = { @@ -24,23 +37,102 @@ type RemoveCollateralAndDeleverageProps = { isRefreshing?: boolean; }; -const UNSIGNED_DIGITS_REGEX = /^\d+$/; -const parseUnsignedBigInt = (value: unknown): bigint | null => { - if (typeof value === 'bigint') return value >= 0n ? value : null; - if (typeof value === 'string') { - const normalized = value.trim(); - if (!UNSIGNED_DIGITS_REGEX.test(normalized)) return null; - try { - return BigInt(normalized); - } catch { - return null; - } +const TARGET_LTV_SEARCH_STEPS = 40n; + +const absDiffBigInt = (a: bigint, b: bigint): bigint => (a >= b ? a - b : b - a); + +const estimateRepayAmountFromWithdraw = ({ + withdrawCollateralAmount, + currentBorrowAssets, + referenceWithdrawAmount, + referenceRepayAmount, + oraclePrice, +}: { + withdrawCollateralAmount: bigint; + currentBorrowAssets: bigint; + referenceWithdrawAmount: bigint; + referenceRepayAmount: bigint; + oraclePrice: bigint; +}): bigint => { + if (withdrawCollateralAmount <= 0n || currentBorrowAssets <= 0n) return 0n; + if (referenceWithdrawAmount > 0n && referenceRepayAmount > 0n) { + const estimatedRepayFromRatio = (withdrawCollateralAmount * referenceRepayAmount) / referenceWithdrawAmount; + return estimatedRepayFromRatio > currentBorrowAssets ? currentBorrowAssets : estimatedRepayFromRatio; } - if (typeof value === 'number') { - if (!Number.isFinite(value) || !Number.isInteger(value) || value < 0) return null; - return BigInt(value); + const estimatedRepayFromOracle = getCollateralValueInLoan(withdrawCollateralAmount, oraclePrice); + return estimatedRepayFromOracle > currentBorrowAssets ? currentBorrowAssets : estimatedRepayFromOracle; +}; + +const estimateWithdrawAmountForTargetLtv = ({ + targetLtv, + currentCollateralAssets, + currentBorrowAssets, + currentBorrowShares, + maxWithdrawCollateral, + referenceWithdrawAmount, + referenceRepayAmount, + maxCollateralForDebtRepay, + closeRouteAvailable, + closeBoundIsInputCap, + slippageBps, + oraclePrice, +}: { + targetLtv: bigint; + currentCollateralAssets: bigint; + currentBorrowAssets: bigint; + currentBorrowShares: bigint; + maxWithdrawCollateral: bigint; + referenceWithdrawAmount: bigint; + referenceRepayAmount: bigint; + maxCollateralForDebtRepay: bigint; + closeRouteAvailable: boolean; + closeBoundIsInputCap: boolean; + slippageBps: number; + oraclePrice: bigint; +}): bigint => { + if (targetLtv <= 0n || maxWithdrawCollateral <= 0n || currentCollateralAssets <= 0n) return 0n; + + const evaluateProjectedLtv = (candidateWithdrawAmount: bigint): bigint => { + const estimatedRepayAmount = estimateRepayAmountFromWithdraw({ + withdrawCollateralAmount: candidateWithdrawAmount, + currentBorrowAssets, + referenceWithdrawAmount, + referenceRepayAmount, + oraclePrice, + }); + const projectedPosition = computeDeleverageProjectedPosition({ + currentCollateralAssets, + currentBorrowAssets, + currentBorrowShares, + withdrawCollateralAmount: candidateWithdrawAmount, + repayAmount: estimatedRepayAmount, + maxCollateralForDebtRepay, + closeRouteAvailable, + closeBoundIsInputCap, + slippageBps, + }); + + return computeLtv({ + borrowAssets: projectedPosition.projectedBorrowAssets, + collateralAssets: projectedPosition.projectedCollateralAssets, + oraclePrice, + }); + }; + + let bestCandidate = 0n; + let bestDistance = absDiffBigInt(evaluateProjectedLtv(0n), targetLtv); + + for (let step = 1n; step <= TARGET_LTV_SEARCH_STEPS; step += 1n) { + const candidateWithdrawAmount = (maxWithdrawCollateral * step) / TARGET_LTV_SEARCH_STEPS; + const projectedLtv = evaluateProjectedLtv(candidateWithdrawAmount); + const distance = absDiffBigInt(projectedLtv, targetLtv); + if (distance < bestDistance) { + bestDistance = distance; + bestCandidate = candidateWithdrawAmount; + } } - return null; + + return bestCandidate; }; export function RemoveCollateralAndDeleverage({ @@ -53,9 +145,11 @@ export function RemoveCollateralAndDeleverage({ }: RemoveCollateralAndDeleverageProps): JSX.Element { const { address: account } = useConnection(); const isSwapRoute = route?.kind === 'swap'; - const isErc4626Route = route?.kind === 'erc4626'; - const routeLabel = isSwapRoute ? 'Route: Swap (Bundler3 + Velora)' : isErc4626Route ? 'Route: Vault (ERC4626)' : 'Route: Unsupported'; const [withdrawCollateralAmount, setWithdrawCollateralAmount] = useState(0n); + const [useTargetLtvInput, setUseTargetLtvInput] = useState(true); + const [targetLtvInput, setTargetLtvInput] = useState('0'); + const [isEditingTargetLtvInput, setIsEditingTargetLtvInput] = useState(false); + const [swapSlippagePercent, setSwapSlippagePercent] = useState(DEFAULT_SLIPPAGE_PERCENT); const [withdrawInputError, setWithdrawInputError] = useState(null); const currentCollateralAssetsRaw = parseUnsignedBigInt(currentPosition?.state.collateral); @@ -68,7 +162,9 @@ export function RemoveCollateralAndDeleverage({ const currentBorrowAssets = currentBorrowAssetsRaw ?? 0n; const currentBorrowShares = currentBorrowSharesRaw ?? 0n; const lltv = lltvRaw ?? 0n; + const maxTargetLtvPercent = useMemo(() => Math.min(100, ltvWadToPercent(clampTargetLtv(lltv, lltv))), [lltv]); const quoteWithdrawCollateralAmount = hasInvalidPositionData ? 0n : withdrawCollateralAmount; + const swapSlippageBps = useMemo(() => slippagePercentToBps(swapSlippagePercent), [swapSlippagePercent]); const quote = useDeleverageQuote({ chainId: market.morphoBlue.chain.id, @@ -81,6 +177,7 @@ export function RemoveCollateralAndDeleverage({ collateralTokenAddress: market.collateralAsset.address, collateralTokenDecimals: market.collateralAsset.decimals, userAddress: account as `0x${string}` | undefined, + slippageBps: swapSlippageBps, }); const closeRoutePendingResolution = route?.kind === 'swap' && quote.closeRouteRequiresResolution && quote.canCurrentSellCloseDebt; @@ -98,6 +195,7 @@ export function RemoveCollateralAndDeleverage({ maxCollateralForDebtRepay: closeBoundForPreview, closeRouteAvailable: closeRouteAvailableForPreview, closeBoundIsInputCap: route?.kind !== 'swap', + slippageBps: swapSlippageBps, }), [ currentCollateralAssets, @@ -107,6 +205,7 @@ export function RemoveCollateralAndDeleverage({ quote.repayAmount, closeBoundForPreview, closeRouteAvailableForPreview, + swapSlippageBps, route, ], ); @@ -130,6 +229,12 @@ export function RemoveCollateralAndDeleverage({ }), [projection.projectedBorrowAssets, projection.projectedCollateralAssets, oraclePrice], ); + const settledProjectedLtvRef = useRef(projectedLTV); + + useEffect(() => { + if (quote.isLoading || quote.error) return; + settledProjectedLtvRef.current = projectedLTV; + }, [quote.isLoading, quote.error, projectedLTV]); const handleTransactionSuccess = useCallback(() => { // WHY: clear unwind draft after confirmation so users see the refreshed live position, not stale input. @@ -142,6 +247,8 @@ export function RemoveCollateralAndDeleverage({ transaction, isLoading: deleverageFlowLoading, authorizeAndDeleverage, + executionError, + clearExecutionError, } = useDeleverageTransaction({ market, route, @@ -153,6 +260,7 @@ export function RemoveCollateralAndDeleverage({ autoWithdrawCollateralAmount: projection.autoWithdrawCollateralAmount, maxCollateralForDebtRepay: quote.maxCollateralForDebtRepay, swapSellPriceRoute: quote.swapSellPriceRoute, + slippageBps: swapSlippageBps, onSuccess: handleTransactionSuccess, }); @@ -177,8 +285,11 @@ export function RemoveCollateralAndDeleverage({ // Treat user input as an intent change immediately so the preview card updates as soon as the amount changes. const hasChanges = withdrawCollateralAmount > 0n; + const previewHasTransientState = hasChanges && (quote.isLoading || quote.error !== null); + const displayProjectedLTV = previewHasTransientState ? settledProjectedLtvRef.current : projectedLTV; + const shouldShowProjectedRisk = hasChanges && !previewHasTransientState; const exceedsMaxWithdraw = withdrawCollateralAmount > projection.maxWithdrawCollateral; - const projectedOverLimit = projectedLTV >= lltv; + const projectedOverLimit = displayProjectedLTV >= lltv; const flashBorrowPreview = useMemo( () => formatTokenAmountPreview(projection.flashLoanAmountForTx, market.loanAsset.decimals), [projection.flashLoanAmountForTx, market.loanAsset.decimals], @@ -192,6 +303,95 @@ export function RemoveCollateralAndDeleverage({ [withdrawCollateralAmount, market.collateralAsset.decimals], ); const collateralFlowLabel = isSwapRoute ? 'Collateral Sold' : 'Collateral Unwound'; + const ltvInputClassName = + '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'; + + const handleWithdrawAmountChange = useCallback((nextWithdrawAmount: bigint) => { + clearExecutionError(); + setWithdrawCollateralAmount(nextWithdrawAmount); + }, [clearExecutionError]); + + const handleSwapSlippageChange = useCallback( + (nextSlippagePercent: number) => { + clearExecutionError(); + setSwapSlippagePercent(nextSlippagePercent); + }, + [clearExecutionError], + ); + + const handleTargetLtvInputChange = useCallback( + (value: string) => { + clearExecutionError(); + const normalizedInput = normalizeEditablePercentInput(value); + if (normalizedInput == null) return; + setTargetLtvInput(normalizedInput); + if (normalizedInput === '') return; + const parsedPercent = Number.parseFloat(normalizedInput); + if (!Number.isFinite(parsedPercent)) return; + const clampedPercent = clampEditablePercent(parsedPercent, maxTargetLtvPercent); + const clampedTargetLtv = clampTargetLtv(percentToLtvWad(clampedPercent), lltv); + if (clampedTargetLtv <= 0n) { + setWithdrawCollateralAmount(0n); + setWithdrawInputError(null); + return; + } + const nextWithdrawAmount = estimateWithdrawAmountForTargetLtv({ + targetLtv: clampedTargetLtv, + currentCollateralAssets, + currentBorrowAssets, + currentBorrowShares, + maxWithdrawCollateral: projection.maxWithdrawCollateral, + referenceWithdrawAmount: withdrawCollateralAmount, + referenceRepayAmount: quote.repayAmount, + maxCollateralForDebtRepay: quote.maxCollateralForDebtRepay, + closeRouteAvailable: quote.closeRouteAvailable, + closeBoundIsInputCap: route?.kind !== 'swap', + slippageBps: swapSlippageBps, + oraclePrice, + }); + setWithdrawCollateralAmount(nextWithdrawAmount); + setWithdrawInputError(nextWithdrawAmount > projection.maxWithdrawCollateral ? 'Exceeds deleverageable collateral' : null); + }, + [ + maxTargetLtvPercent, + lltv, + currentCollateralAssets, + currentBorrowAssets, + currentBorrowShares, + projection.maxWithdrawCollateral, + withdrawCollateralAmount, + quote.repayAmount, + quote.maxCollateralForDebtRepay, + quote.closeRouteAvailable, + swapSlippageBps, + route, + oraclePrice, + clearExecutionError, + ], + ); + + const handleTargetLtvInputBlur = useCallback(() => { + setIsEditingTargetLtvInput(false); + const parsedPercent = Number.parseFloat(targetLtvInput.replace(',', '.')); + if (!Number.isFinite(parsedPercent)) { + setTargetLtvInput(formatEditableLtvPercent(ltvWadToPercent(displayProjectedLTV), maxTargetLtvPercent)); + return; + } + const clampedPercent = clampEditablePercent(parsedPercent, maxTargetLtvPercent); + const clampedTargetLtv = clampTargetLtv(percentToLtvWad(clampedPercent), lltv); + setTargetLtvInput(formatEditableLtvPercent(ltvWadToPercent(clampedTargetLtv), maxTargetLtvPercent)); + }, [targetLtvInput, displayProjectedLTV, maxTargetLtvPercent, lltv]); + + const handleInputModeChange = useCallback((nextUseTargetLtvInput: boolean) => { + clearExecutionError(); + setUseTargetLtvInput(nextUseTargetLtvInput); + setWithdrawInputError(null); + }, [clearExecutionError]); + + useEffect(() => { + if (isEditingTargetLtvInput) return; + setTargetLtvInput(formatEditableLtvPercent(ltvWadToPercent(displayProjectedLTV), maxTargetLtvPercent)); + }, [isEditingTargetLtvInput, displayProjectedLTV, maxTargetLtvPercent]); const swapRatePreviewText = useMemo(() => { if (!isSwapRoute || !quote.swapSellPriceRoute) return null; @@ -221,52 +421,85 @@ export function RemoveCollateralAndDeleverage({ market.loanAsset.symbol, ]); const shouldShowSwapPreviewDetails = isSwapRoute && quote.swapSellPriceRoute != null && swapRatePreviewText != null; - const swapSlippagePreviewText = `${formatSlippagePercent(DEFAULT_SLIPPAGE_PERCENT)}%`; - return (
{!transaction?.isModalVisible && (

Deleverage Preview

-

{routeLabel}

-

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

- +

+ {useTargetLtvInput ? 'Target LTV' : `Collateral To Unwind ${market.collateralAsset.symbol}`} +

+
+
Use LTV
+ - } - /> - {withdrawInputError &&

{withdrawInputError}

} - {!withdrawInputError && exceedsMaxWithdraw && ( -

Exceeds deleverageable collateral

+
+
+ {useTargetLtvInput ? ( +
+ setIsEditingTargetLtvInput(true)} + onChange={(event) => handleTargetLtvInputChange(event.target.value)} + onBlur={handleTargetLtvInputBlur} + className={ltvInputClassName} + /> + % +
+ ) : ( + <> + + } + /> + {withdrawInputError &&

{withdrawInputError}

} + {!withdrawInputError && exceedsMaxWithdraw && ( +

Exceeds deleverageable collateral

+ )} + )}
@@ -326,16 +559,20 @@ export function RemoveCollateralAndDeleverage({
Max Slippage - {swapSlippagePreviewText} +
)}
Projected LTV - {formatLtvPercent(projectedLTV)}% + {formatLtvPercent(displayProjectedLTV)}%
{quote.error &&

{quote.error}

} + {executionError &&

{executionError}

} {hasInvalidPositionData && (

Unable to read valid position data. Refresh balances and try again.

)} @@ -371,10 +608,10 @@ export function RemoveCollateralAndDeleverage({
- {hasChanges && projectedOverLimit && ( + {shouldShowProjectedRisk && projectedOverLimit && ( )} diff --git a/src/modals/leverage/leverage-modal-global.tsx b/src/modals/leverage/leverage-modal-global.tsx index 5297b5b6..12cc98a1 100644 --- a/src/modals/leverage/leverage-modal-global.tsx +++ b/src/modals/leverage/leverage-modal-global.tsx @@ -30,7 +30,7 @@ export function LeverageModalGlobal({ const chainId = market.morphoBlue.chain.id; const { price: oraclePrice } = useOraclePrice({ - oracle: market.oracleAddress, + oracle: market.oracleAddress as `0x${string}`, chainId, }); diff --git a/src/modals/leverage/leverage-modal.tsx b/src/modals/leverage/leverage-modal.tsx index 6b852d53..c0f229c6 100644 --- a/src/modals/leverage/leverage-modal.tsx +++ b/src/modals/leverage/leverage-modal.tsx @@ -241,12 +241,12 @@ export function LeverageModal({ ? isErc4626Route ? `Leverage ERC4626 vault exposure by looping ${market.loanAsset.symbol} into ${market.collateralAsset.symbol}.` : isSwapRoute - ? `Leverage ${market.collateralAsset.symbol} exposure through Bundler3 + Velora swap routing.` + ? `Leverage ${market.collateralAsset.symbol} exposure by swapping borrowed ${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.` : isSwapRoute - ? `Reduce leveraged exposure by swapping withdrawn ${market.collateralAsset.symbol} back into ${market.loanAsset.symbol} via Bundler3 + Velora.` + ? `Reduce leveraged exposure by swapping withdrawn ${market.collateralAsset.symbol} into ${market.loanAsset.symbol}.` : `Reduce leveraged ${market.collateralAsset.symbol} exposure by unwinding your loop.` } />