diff --git a/AGENTS.md b/AGENTS.md index 4782fead..76ac6776 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -133,7 +133,7 @@ Running `tsc` and lint is NOT validation — those are mechanical checks. Valida When touching transaction and position flows, validation MUST include all relevant checks below (not just the changed line): -1. **Canonical identity matching**: use canonical IDs/addresses for logic, routing, and comparisons; never use symbol/name as identity. +1. **Canonical identity matching**: use canonical IDs/addresses **and chainId** for logic, routing, and comparisons (for example `chainId + market.uniqueKey` or `chainId + tokenAddress`); never use symbol/name as identity. 2. **Shared domain helpers only**: use shared math, conversion, and transaction helpers; avoid ad hoc formulas duplicated inside UI/hooks. 3. **Rate display consistency**: use shared APY/APR display primitives (for example `src/components/shared/rate-formatted.tsx`, `src/features/positions/components/preview/apy-preview.tsx`, and shared rate-label logic) instead of per-component conversion/label logic. 4. **Post-action rate preview parity**: transaction modals that change position yield/rates must show current -> projected post-action APY/APR, and preview mode (APR vs APY) must match the global setting used by execution summaries. @@ -154,7 +154,9 @@ When touching transaction and position flows, validation MUST include all releva 19. **APR/APY unit homogeneity**: in any reward/carry/net-rate calculation, normalize every term (base rate, each reward component, aggregates, and displayed subtotals) to the same selected mode before combining, so displayed formulas remain numerically consistent in both APR and APY modes. 20. **Rebalance objective integrity**: stepwise smart-rebalance planners must evaluate each candidate move by resulting **global weighted objective** (portfolio-level APY/APR), not by local/post-move market APR alone, and must fail safe (no-op) when projected objective is below current objective. 21. **Modal UX integrity**: transaction-modal help/fee tooltips must render above modal layers via shared tooltip z-index chokepoints, and per-flow input mode toggles (for example target LTV vs amount) must persist through shared settings across modal reopen. -22. **Bundler residual-asset integrity**: any flash-loan transaction path that routes assets through Bundler/adapter balances (especially ERC4626 unwind paths) must end with explicit trailing asset sweeps to the intended recipient and must keep execute-time slippage bounds consistent with quote-time slippage settings. +22. **Chain-scoped identity integrity**: all market/token/route identity checks must be chain-scoped and use canonical identifiers (`chainId + market.uniqueKey` or `chainId + address`), including matching, dedupe keys, routing, and trust/allowlist gates. +23. **Bundler residual-asset integrity**: any flash-loan transaction path that routes assets through Bundler/adapter balances (especially ERC4626 unwind paths) must end with explicit trailing asset sweeps to the intended recipient and must keep execute-time slippage bounds consistent with quote-time slippage settings. + ### REQUIRED: Regression Rule Capture After fixing any user-reported bug in a high-impact flow: @@ -178,6 +180,11 @@ After fixing any user-reported bug in a high-impact flow: - Write code that is **accessible, performant, type-safe, and maintainable** - Focus on clarity and explicit intent over brevity +### State Persistence +- Do not use direct `window.localStorage` reads/writes in components or hooks for user preferences/dismissals. +- Follow existing persisted Zustand patterns (`useAppSettings` or a dedicated persisted store) as the default chokepoint for preference/state persistence. +- If direct storage access is unavoidable, isolate it in a single shared utility/store layer and document why. + ### Type Safety & Explicitness - Use explicit types for function parameters and return values when they enhance clarity - Prefer `unknown` over `any` when the type is genuinely unknown diff --git a/src/config/leverage.ts b/src/config/leverage.ts index c490805e..4ea852e9 100644 --- a/src/config/leverage.ts +++ b/src/config/leverage.ts @@ -1,3 +1,6 @@ +import { type Address, isAddressEqual, zeroAddress } from 'viem'; +import { getBundlerV2 } from '@/utils/morpho'; +import type { SupportedNetworks } from '@/utils/networks'; import { MONARCH_FEE_RECIPIENT } from './smart-rebalance'; /** @@ -7,3 +10,43 @@ import { MONARCH_FEE_RECIPIENT } from './smart-rebalance'; * a single address. */ export const LEVERAGE_FEE_RECIPIENT = MONARCH_FEE_RECIPIENT; + +type SpecialErc4626LeverageConfig = { + marketUniqueKey: string; + bundler: Address; + warningStorageKey: string; + warningMessage: string; +}; + +const SPECIAL_ERC4626_LEVERAGE_CONFIG_BY_CHAIN: Partial> = { + 1: { + marketUniqueKey: '0xacc49fbf58feb1ac971acce68f8adc177c43682d6a7087bbd4991a05cb7a2c67', + bundler: '0xaB27431E62ead49A40848958A6BaDA040BA2264f', + warningStorageKey: 'monarch_special_erc4626_bundler_warning_ack_mainnet', + warningMessage: + 'Leveraging this market through ERC4626 deposit uses a whitelist-based bundler developed by Royco. Proceed only if you trust this contract.', + }, +}; + +export const getSpecialErc4626LeverageConfig = (chainId: SupportedNetworks): SpecialErc4626LeverageConfig | null => + SPECIAL_ERC4626_LEVERAGE_CONFIG_BY_CHAIN[chainId] ?? null; + +export const isSpecialErc4626LeverageMarket = (chainId: SupportedNetworks, marketUniqueKey: string): boolean => { + const specialConfig = getSpecialErc4626LeverageConfig(chainId); + if (!specialConfig) return false; + return marketUniqueKey.toLowerCase() === specialConfig.marketUniqueKey; +}; + +export const resolveErc4626RouteBundler = (chainId: SupportedNetworks, marketUniqueKey: string): Address => { + const specialConfig = getSpecialErc4626LeverageConfig(chainId); + const resolvedBundler = + specialConfig && marketUniqueKey.toLowerCase() === specialConfig.marketUniqueKey + ? specialConfig.bundler + : (getBundlerV2(chainId) as Address); + + if (isAddressEqual(resolvedBundler, zeroAddress)) { + throw new Error(`Bundler not configured for leverage route (chainId=${chainId}, market=${marketUniqueKey.toLowerCase()}).`); + } + + return resolvedBundler; +}; diff --git a/src/hooks/useDeleverageTransaction.ts b/src/hooks/useDeleverageTransaction.ts index 9d05c355..de86ae44 100644 --- a/src/hooks/useDeleverageTransaction.ts +++ b/src/hooks/useDeleverageTransaction.ts @@ -5,6 +5,7 @@ import morphoBundlerAbi from '@/abis/bundlerV2'; import { bundlerV3Abi } from '@/abis/bundlerV3'; import { morphoGeneralAdapterV1Abi } from '@/abis/morphoGeneralAdapterV1'; import { paraswapAdapterAbi } from '@/abis/paraswapAdapter'; +import { resolveErc4626RouteBundler } from '@/config/leverage'; import { buildVeloraTransactionPayload, isVeloraRateChangedError, type VeloraPriceRoute } from '@/features/swap/api/velora'; import { useBundlerAuthorizationStep } from '@/hooks/useBundlerAuthorizationStep'; import { useStyledToast } from '@/hooks/useStyledToast'; @@ -13,7 +14,7 @@ import { useTransactionTracking } from '@/hooks/useTransactionTracking'; import { useUserMarketsCache } from '@/stores/useUserMarketsCache'; import { useAppSettings } from '@/stores/useAppSettings'; import { formatBalance } from '@/utils/balance'; -import { getBundlerV2, MONARCH_TX_IDENTIFIER } from '@/utils/morpho'; +import { MONARCH_TX_IDENTIFIER } from '@/utils/morpho'; import { toUserFacingTransactionErrorMessage } from '@/utils/transaction-errors'; import type { Market } from '@/utils/types'; import { type Bundler3Call, encodeBundler3Calls, getParaswapSellOffsets, readCalldataUint256 } from './leverage/bundler3'; @@ -64,14 +65,17 @@ export function useDeleverageTransaction({ const [executionError, setExecutionError] = useState(null); const isSwapRoute = route?.kind === 'swap'; const useSignatureAuthorization = usePermit2Setting && !isSwapRoute; - const bundlerAddress = useMemo
(() => { - if (route?.kind === 'swap') return route.bundler3Address; - return getBundlerV2(market.morphoBlue.chain.id) as Address; - }, [route, market.morphoBlue.chain.id]); - const authorizationTarget = useMemo
(() => { - if (route?.kind === 'swap') return route.generalAdapterAddress; - return bundlerAddress; - }, [route, bundlerAddress]); + const bundlerAddress = useMemo
(() => { + if (!route) return undefined; + if (route.kind === 'swap') return route.bundler3Address; + try { + const resolvedBundler = resolveErc4626RouteBundler(market.morphoBlue.chain.id, market.uniqueKey); + return isAddress(resolvedBundler) ? resolvedBundler : undefined; + } catch { + return undefined; + } + }, [route, market.uniqueKey, market.morphoBlue.chain.id]); + const authorizationTarget = route?.kind === 'swap' ? route.generalAdapterAddress : bundlerAddress; const { batchAddUserMarkets } = useUserMarketsCache(account); const { @@ -83,7 +87,7 @@ export function useDeleverageTransaction({ refetchIsBundlerAuthorized, } = useBundlerAuthorizationStep({ chainId: market.morphoBlue.chain.id, - bundlerAddress: authorizationTarget, + bundlerAddress: authorizationTarget as Address, }); const { isConfirming: deleveragePending, sendTransactionAsync } = useTransactionWithToast({ @@ -154,6 +158,9 @@ export function useDeleverageTransaction({ if (!route) { throw new Error('This market is not supported for deleverage.'); } + if (!bundlerAddress) { + throw new Error('Deleverage route data is unavailable. Please refresh and try again.'); + } if (withdrawCollateralAmount <= 0n || flashLoanAmount <= 0n) { throw new Error('Invalid deleverage inputs. Set a collateral unwind amount above zero.'); @@ -548,6 +555,10 @@ export function useDeleverageTransaction({ toast.info('No account connected', 'Please connect your wallet.'); return; } + if (!route || !bundlerAddress) { + toast.info('Quote unavailable', 'Deleverage route data is unavailable. Please refresh and try again.'); + return; + } if ((useSignatureAuthorization && !isBundlerAuthorizationReady) || (!useSignatureAuthorization && !isBundlerAuthorizationStatusReady)) { toast.info('Authorization status loading', 'Please wait a moment and try again.'); return; @@ -584,6 +595,8 @@ export function useDeleverageTransaction({ } }, [ account, + route, + bundlerAddress, isBundlerAuthorized, isBundlerAuthorizationReady, isBundlerAuthorizationStatusReady, diff --git a/src/hooks/useLeverageTransaction.ts b/src/hooks/useLeverageTransaction.ts index 9073e96d..20b68e01 100644 --- a/src/hooks/useLeverageTransaction.ts +++ b/src/hooks/useLeverageTransaction.ts @@ -8,7 +8,7 @@ import { morphoGeneralAdapterV1Abi } from '@/abis/morphoGeneralAdapterV1'; import { paraswapAdapterAbi } from '@/abis/paraswapAdapter'; import permit2Abi from '@/abis/permit2'; import { getLeverageFee } from '@/config/fees'; -import { LEVERAGE_FEE_RECIPIENT } from '@/config/leverage'; +import { LEVERAGE_FEE_RECIPIENT, resolveErc4626RouteBundler } from '@/config/leverage'; import { buildVeloraTransactionPayload, isVeloraRateChangedError, type VeloraPriceRoute } from '@/features/swap/api/velora'; import { useERC20Approval } from '@/hooks/useERC20Approval'; import { useBundlerAuthorizationStep } from '@/hooks/useBundlerAuthorizationStep'; @@ -19,7 +19,7 @@ import { useTransactionTracking } from '@/hooks/useTransactionTracking'; import { useUserMarketsCache } from '@/stores/useUserMarketsCache'; import { useAppSettings } from '@/stores/useAppSettings'; import { formatBalance } from '@/utils/balance'; -import { getBundlerV2, getMorphoAddress, MONARCH_TX_IDENTIFIER } from '@/utils/morpho'; +import { getMorphoAddress, MONARCH_TX_IDENTIFIER } from '@/utils/morpho'; import { PERMIT2_ADDRESS } from '@/utils/permit2'; import { toUserFacingTransactionErrorMessage } from '@/utils/transaction-errors'; import type { Market } from '@/utils/types'; @@ -81,8 +81,8 @@ export function useLeverageTransaction({ if (route?.kind === 'swap') { return route.bundler3Address; } - return getBundlerV2(market.morphoBlue.chain.id) as Address; - }, [route, market.morphoBlue.chain.id]); + return resolveErc4626RouteBundler(market.morphoBlue.chain.id, market.uniqueKey); + }, [route, market.uniqueKey, market.morphoBlue.chain.id]); const authorizationTarget = useMemo
(() => { if (route?.kind === 'swap') { return route.generalAdapterAddress; diff --git a/src/modals/leverage/leverage-modal.tsx b/src/modals/leverage/leverage-modal.tsx index a1637063..ee96a513 100644 --- a/src/modals/leverage/leverage-modal.tsx +++ b/src/modals/leverage/leverage-modal.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { ChevronDownIcon } from '@radix-ui/react-icons'; +import { IoWarningOutline } from 'react-icons/io5'; import { RiSparklingFill } from 'react-icons/ri'; import { type Address, erc20Abi } from 'viem'; import { useConnection, useReadContract } from 'wagmi'; @@ -8,7 +9,9 @@ import { ModalIntentSwitcher } from '@/components/common/Modal/ModalIntentSwitch import { TokenIcon } from '@/components/shared/token-icon'; import { Badge } from '@/components/ui/badge'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; +import { getSpecialErc4626LeverageConfig, isSpecialErc4626LeverageMarket } from '@/config/leverage'; import { useLeverageRouteAvailability } from '@/hooks/leverage/useLeverageRouteAvailability'; +import { useAppSettings } from '@/stores/useAppSettings'; import type { LeverageRoute } from '@/hooks/leverage/types'; import { cn } from '@/utils/components'; import type { Market, MarketPosition } from '@/utils/types'; @@ -102,7 +105,11 @@ export function LeverageModal({ }: LeverageModalProps): JSX.Element { const [mode, setMode] = useState<'leverage' | 'deleverage'>(defaultMode); const [routeMode, setRouteMode] = useState('erc4626'); + const specialBundlerWarningAcknowledgements = useAppSettings((state) => state.specialBundlerWarningAcknowledgements); + const setSpecialBundlerWarningAcknowledged = useAppSettings((state) => state.setSpecialBundlerWarningAcknowledged); const { address: account } = useConnection(); + const specialErc4626LeverageConfig = getSpecialErc4626LeverageConfig(market.morphoBlue.chain.id); + const isSpecialErc4626BundlerMarket = isSpecialErc4626LeverageMarket(market.morphoBlue.chain.id, market.uniqueKey); const { swapRoute, isErc4626ModeAvailable, availableRouteModes, isErc4626ProbeLoading, isErc4626ProbeRefetching } = useLeverageRouteAvailability({ @@ -147,6 +154,17 @@ export function LeverageModal({ ]); const isErc4626Route = route?.kind === 'erc4626'; const isSwapRoute = route?.kind === 'swap'; + const hasAcknowledgedSpecialBundlerWarning = + !isSpecialErc4626BundlerMarket || !specialErc4626LeverageConfig + ? true + : (specialBundlerWarningAcknowledgements[specialErc4626LeverageConfig.warningStorageKey] ?? false); + const shouldShowSpecialBundlerWarning = isSpecialErc4626BundlerMarket && isErc4626Route && !hasAcknowledgedSpecialBundlerWarning; + + const acknowledgeSpecialBundlerWarning = useCallback(() => { + if (!specialErc4626LeverageConfig) return; + setSpecialBundlerWarningAcknowledged(specialErc4626LeverageConfig.warningStorageKey, true); + }, [specialErc4626LeverageConfig, setSpecialBundlerWarningAcknowledged]); + const displayedRouteMode = useMemo(() => { if (route?.kind) return route.kind; if (availableRouteModes.length === 1) return availableRouteModes[0]; @@ -191,6 +209,35 @@ export function LeverageModal({ const isRefreshingAnyData = isRefreshing || isFetchingCollateralTokenBalance; + const routeContent = useMemo((): JSX.Element | null => { + if (!route) return null; + + if (effectiveMode === 'leverage') { + return ( + + ); + } + + return ( + + ); + }, [route, effectiveMode, market, position, collateralTokenBalance, oraclePrice, handleRefreshAll, isRefreshingAnyData]); + const mainIcon = (
- {route ? ( - effectiveMode === 'leverage' ? ( - - ) : ( - - ) + {routeContent ? ( + routeContent ) : isErc4626ProbeLoading || isErc4626ProbeRefetching ? (
Checking available leverage routes...
) : ( @@ -287,6 +315,23 @@ export function LeverageModal({ Swap route configuration is unavailable for this network.
)} + {shouldShowSpecialBundlerWarning && ( +
+
+ +
+

{specialErc4626LeverageConfig?.warningMessage}

+ +
+
+
+ )} ); diff --git a/src/stores/useAppSettings.ts b/src/stores/useAppSettings.ts index 809c7fa4..2803cad6 100644 --- a/src/stores/useAppSettings.ts +++ b/src/stores/useAppSettings.ts @@ -15,6 +15,7 @@ type AppSettingsState = { // UI dismissals trustedVaultsWarningDismissed: boolean; + specialBundlerWarningAcknowledgements: Record; // Developer options showDeveloperOptions: boolean; @@ -37,6 +38,7 @@ type AppSettingsActions = { setShowFullRewardAPY: (show: boolean) => void; setIsAprDisplay: (isApr: boolean) => void; setTrustedVaultsWarningDismissed: (dismissed: boolean) => void; + setSpecialBundlerWarningAcknowledged: (warningStorageKey: string, acknowledged: boolean) => void; setShowDeveloperOptions: (show: boolean) => void; setUsePublicAllocator: (show: boolean) => void; setLeverageUseTargetLtvInput: (useTargetLtvInput: boolean) => void; @@ -68,6 +70,7 @@ export const useAppSettings = create()( showFullRewardAPY: true, isAprDisplay: false, trustedVaultsWarningDismissed: false, + specialBundlerWarningAcknowledgements: {}, showDeveloperOptions: false, usePublicAllocator: true, leverageUseTargetLtvInput: true, @@ -81,6 +84,13 @@ export const useAppSettings = create()( setShowFullRewardAPY: (show) => set({ showFullRewardAPY: show }), setIsAprDisplay: (isApr) => set({ isAprDisplay: isApr }), setTrustedVaultsWarningDismissed: (dismissed) => set({ trustedVaultsWarningDismissed: dismissed }), + setSpecialBundlerWarningAcknowledged: (warningStorageKey, acknowledged) => + set((state) => ({ + specialBundlerWarningAcknowledgements: { + ...state.specialBundlerWarningAcknowledgements, + [warningStorageKey]: acknowledged, + }, + })), setShowDeveloperOptions: (show) => set({ showDeveloperOptions: show }), setUsePublicAllocator: (show) => set({ usePublicAllocator: show }), setLeverageUseTargetLtvInput: (useTargetLtvInput) => set({ leverageUseTargetLtvInput: useTargetLtvInput }),