From 3cebad1ff95a38a70071d06f0c894b98b7f2762e Mon Sep 17 00:00:00 2001 From: antoncoding Date: Tue, 3 Mar 2026 08:42:38 +0800 Subject: [PATCH] feat: preview apr --- AGENTS.md | 1 + .../components/market-header.tsx | 45 +++++++++++++---- .../components/market-actions-dropdown.tsx | 19 +++++++ .../add-collateral-and-leverage.tsx | 50 +++++++++++++++---- 4 files changed, 97 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6551ca30..44458015 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -157,6 +157,7 @@ When touching transaction and position flows, validation MUST include all releva 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. 23. **Max-bound input preview continuity**: transaction-critical amount inputs with `max` constraints must continue propagating parseable user-entered values into preview/risk state even when over max; max violations should surface as validation errors and block execution, but must not freeze preview updates or require bypass actions to keep previews in sync. 24. **Indexer-lag transaction history bridging**: when earnings/APY depends on recent supply/withdraw history, confirmed on-chain receipts must be parsed into a short-lived local transaction cache (scoped by canonical user address + chain, deduped by tx hash + log index, TTL-bounded), merged into reads while indexers lag, and automatically removed as soon as the API returns the same tx hash to prevent double counting. +25. **Leverage preview rate realism**: leverage preview Borrow/Net APR-APY values must be derived from projected post-transaction market state (using IRM `borrowRateView` + projected borrow assets/shares), not static pre-transaction market borrow rates, whenever the user has non-zero leverage input. ### REQUIRED: Regression Rule Capture diff --git a/src/features/market-detail/components/market-header.tsx b/src/features/market-detail/components/market-header.tsx index d67015c9..9567efa9 100644 --- a/src/features/market-detail/components/market-header.tsx +++ b/src/features/market-detail/components/market-header.tsx @@ -10,11 +10,13 @@ import { IoWarningOutline, IoEllipsisVertical } from 'react-icons/io5'; import { MdError } from 'react-icons/md'; import { BsArrowUpCircle, BsArrowDownLeftCircle, BsFillLightningFill } from 'react-icons/bs'; import { GoStarFill, GoStar } from 'react-icons/go'; +import { TbTrendingUp } from 'react-icons/tb'; import { AiOutlineStop } from 'react-icons/ai'; import { FiExternalLink } from 'react-icons/fi'; import { LuCopy, LuArrowDownToLine, LuRefreshCw } from 'react-icons/lu'; import { Button } from '@/components/ui/button'; import { SplitActionButton } from '@/components/ui/split-action-button'; +import { useModal } from '@/hooks/useModal'; import { useMarketPreferences } from '@/stores/useMarketPreferences'; import { useBlacklistedMarkets } from '@/stores/useBlacklistedMarkets'; import { BlacklistConfirmationModal } from '@/features/markets/components/blacklist-confirmation-modal'; @@ -122,19 +124,23 @@ function RiskIcon({ level }: { level: RiskLevel }): React.ReactNode { type ActionButtonsProps = { market: Market; userPosition: MarketPosition | null; + enableExperimentalLeverage: boolean; onSupplyClick: () => void; onWithdrawClick: () => void; onBorrowClick: () => void; onRepayClick: () => void; + onLeverageClick: () => void; }; function ActionButtons({ market, userPosition, + enableExperimentalLeverage, onSupplyClick, onWithdrawClick, onBorrowClick, onRepayClick, + onLeverageClick, }: ActionButtonsProps): React.ReactNode { // Compute position states once const hasSupply = userPosition !== null && BigInt(userPosition.state.supplyShares) > 0n; @@ -204,6 +210,24 @@ function ActionButtons({ ) : undefined; + const borrowDropdownItems = [ + { + label: 'Repay', + icon: , + onClick: onRepayClick, + disabled: !hasBorrow, + }, + ]; + + if (enableExperimentalLeverage) { + borrowDropdownItems.push({ + label: 'Leverage', + icon: , + onClick: onLeverageClick, + disabled: false, + }); + } + return ( <> } onClick={onBorrowClick} indicator={{ show: hasBorrowPosition, tooltip: borrowTooltip }} - dropdownItems={[ - { - label: 'Repay', - icon: , - onClick: onRepayClick, - disabled: !hasBorrow, - }, - ]} + dropdownItems={borrowDropdownItems} /> ); @@ -349,8 +366,9 @@ export function MarketHeader({ }: MarketHeaderProps) { const [isExpanded, setIsExpanded] = useState(false); const [isBlacklistModalOpen, setIsBlacklistModalOpen] = useState(false); + const { open: openModal } = useModal(); const { short: rateLabel } = useRateLabel(); - const { isAprDisplay, showDeveloperOptions } = useAppSettings(); + const { isAprDisplay, showDeveloperOptions, enableExperimentalLeverage } = useAppSettings(); const { starredMarkets, starMarket, unstarMarket } = useMarketPreferences(); const { isBlacklisted, addBlacklistedMarket } = useBlacklistedMarkets(); const toast = useStyledToast(); @@ -384,6 +402,13 @@ export function MarketHeader({ addBlacklistedMarket(market.uniqueKey, market.morphoBlue.chain.id); }; + const handleOpenLeverage = () => { + openModal('leverage', { + market, + defaultMode: 'leverage', + }); + }; + const handleCopyMarketId = async () => { try { await navigator.clipboard.writeText(resolvedMarketId); @@ -569,10 +594,12 @@ export function MarketHeader({ {/* Advanced Options Dropdown */} diff --git a/src/features/markets/components/market-actions-dropdown.tsx b/src/features/markets/components/market-actions-dropdown.tsx index f78a08df..5466a447 100644 --- a/src/features/markets/components/market-actions-dropdown.tsx +++ b/src/features/markets/components/market-actions-dropdown.tsx @@ -7,6 +7,7 @@ import { AiOutlineStop } from 'react-icons/ai'; import { GoStarFill, GoStar, GoGraph } from 'react-icons/go'; import { IoEllipsisVertical } from 'react-icons/io5'; import { BsArrowUpCircle, BsArrowDownLeftCircle } from 'react-icons/bs'; +import { TbTrendingUp } from 'react-icons/tb'; import { Button } from '@/components/ui/button'; import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu'; import type { Market } from '@/utils/types'; @@ -15,6 +16,7 @@ import { useModal } from '@/hooks/useModal'; import { useStyledToast } from '@/hooks/useStyledToast'; import { useMarketPreferences } from '@/stores/useMarketPreferences'; import { useBlacklistedMarkets } from '@/stores/useBlacklistedMarkets'; +import { useAppSettings } from '@/stores/useAppSettings'; type MarketActionsDropdownProps = { market: Market; @@ -23,6 +25,7 @@ type MarketActionsDropdownProps = { export function MarketActionsDropdown({ market }: MarketActionsDropdownProps) { const [isConfirmModalOpen, setIsConfirmModalOpen] = useState(false); const { open: openModal } = useModal(); + const { enableExperimentalLeverage } = useAppSettings(); const { starredMarkets, starMarket, unstarMarket } = useMarketPreferences(); const { isBlacklisted, addBlacklistedMarket } = useBlacklistedMarkets(); const { success: toastSuccess } = useStyledToast(); @@ -54,6 +57,13 @@ export function MarketActionsDropdown({ market }: MarketActionsDropdownProps) { router.push(marketPath); }; + const handleOpenLeverage = () => { + openModal('leverage', { + market, + defaultMode: 'leverage', + }); + }; + return (
+ {enableExperimentalLeverage && ( + } + > + Leverage + + )} + { onMarketClick(); diff --git a/src/modals/leverage/components/add-collateral-and-leverage.tsx b/src/modals/leverage/components/add-collateral-and-leverage.tsx index bfd28766..ab159b59 100644 --- a/src/modals/leverage/components/add-collateral-and-leverage.tsx +++ b/src/modals/leverage/components/add-collateral-and-leverage.tsx @@ -13,6 +13,7 @@ import { clampTargetLtvBps, clampMultiplierBps, computeMaxMultiplierBpsForTargetLtv, + computeExpectedNetCarryApy, computeLeverageProjectedPosition, formatPercentFromBps, formatMultiplierBps, @@ -33,6 +34,7 @@ import { SlippageInlineEditor } from '@/features/swap/components/SlippageInlineE import { DEFAULT_SLIPPAGE_PERCENT, slippagePercentToBps } from '@/features/swap/constants'; import { formatSwapRatePreview } from '@/features/swap/utils/quote-preview'; import { formatBalance } from '@/utils/balance'; +import { previewMarketState } from '@/utils/morpho'; import { convertApyToApr } from '@/utils/rateMath'; import type { LeverageRoute } from '@/hooks/leverage/types'; import type { Market, MarketPosition } from '@/utils/types'; @@ -146,6 +148,7 @@ export function AddCollateralAndLeverage({ [currentCollateralAssets, currentBorrowAssets, quote.totalAddedCollateral, quote.flashLoanAmount], ); const marketLiquidity = BigInt(market.state.liquidityAssets); + const hasChanges = quote.totalAddedCollateral > 0n && quote.flashLoanAmount > 0n; const rateLabel = isAprDisplay ? 'APR' : 'APY'; const vaultRateInsight = use4626VaultAPR({ @@ -268,7 +271,6 @@ export function AddCollateralAndLeverage({ const projectedOverLimit = projectedLTV >= lltv; const insufficientLiquidity = quote.flashLoanAmount > marketLiquidity; - const hasChanges = quote.totalAddedCollateral > 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; @@ -349,15 +351,45 @@ export function AddCollateralAndLeverage({ }, [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. + const fallbackBorrowApy = useMemo(() => { if (isErc4626Route && vaultRateInsight.borrowApy3d != null) return vaultRateInsight.borrowApy3d; return market.state.borrowApy; }, [isErc4626Route, vaultRateInsight.borrowApy3d, market.state.borrowApy]); + const projectedBorrowApy = useMemo(() => { + if (!hasChanges) return null; + const preview = previewMarketState(market, undefined, quote.flashLoanAmount); + return preview?.borrowApy ?? null; + }, [hasChanges, market, quote.flashLoanAmount]); + const previewBorrowApy = projectedBorrowApy ?? fallbackBorrowApy; + const borrowRatePreviewLabel = projectedBorrowApy != null ? `Borrow ${rateLabel} (Est.)` : `Borrow ${rateLabel}`; + const previewExpectedNetApy = useMemo(() => { + if (!isErc4626Route || vaultRateInsight.sharePriceNow == null || vaultRateInsight.vaultApy3d == null) { + return vaultRateInsight.expectedNetApy; + } + + const oneShareUnit = 10n ** BigInt(market.collateralAsset.decimals); + return computeExpectedNetCarryApy({ + collateralShares: projectedCollateralAssets, + borrowAssets: projectedBorrowAssets, + sharePriceInUnderlying: vaultRateInsight.sharePriceNow, + oneShareUnit, + vaultApy: vaultRateInsight.vaultApy3d, + borrowApy: previewBorrowApy, + }); + }, [ + isErc4626Route, + market.collateralAsset.decimals, + previewBorrowApy, + projectedBorrowAssets, + projectedCollateralAssets, + vaultRateInsight.expectedNetApy, + vaultRateInsight.sharePriceNow, + vaultRateInsight.vaultApy3d, + ]); + const expectedNetRateClass = useMemo(() => { + if (previewExpectedNetApy == null) return 'text-secondary'; + return previewExpectedNetApy >= 0 ? 'text-emerald-500' : 'text-red-500'; + }, [previewExpectedNetApy]); return (
@@ -544,7 +576,7 @@ export function AddCollateralAndLeverage({ )}
- Borrow {rateLabel} + {borrowRatePreviewLabel} {renderRateValue(previewBorrowApy)}
{isErc4626Route && ( @@ -559,7 +591,7 @@ export function AddCollateralAndLeverage({
Net {rateLabel} - {vaultRateInsight.isLoading ? '...' : renderRateValue(vaultRateInsight.expectedNetApy)} + {vaultRateInsight.isLoading ? '...' : renderRateValue(previewExpectedNetApy)}