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)}
>