diff --git a/src/features/autovault/components/vault-detail/settings/EditCaps.tsx b/src/features/autovault/components/vault-detail/settings/EditCaps.tsx index 3263d33a..5716089d 100644 --- a/src/features/autovault/components/vault-detail/settings/EditCaps.tsx +++ b/src/features/autovault/components/vault-detail/settings/EditCaps.tsx @@ -33,6 +33,26 @@ type MarketCapInfo = { existingCapId?: string; }; +function areMarketCapsEqual(left: Map, right: Map): boolean { + if (left.size !== right.size) return false; + + for (const [key, leftValue] of left.entries()) { + const rightValue = right.get(key); + if (!rightValue) return false; + + if ( + leftValue.market.uniqueKey !== rightValue.market.uniqueKey || + leftValue.relativeCap !== rightValue.relativeCap || + leftValue.absoluteCap !== rightValue.absoluteCap || + leftValue.existingCapId !== rightValue.existingCapId + ) { + return false; + } + } + + return true; +} + export function EditCaps({ existingCaps, vaultAsset, chainId, isOwner, isUpdating, adapterAddress, onBack, onSave }: EditCapsProps) { const [marketCaps, setMarketCaps] = useState>(new Map()); const [removedMarketIds, setRemovedMarketIds] = useState>(new Set()); @@ -77,12 +97,7 @@ export function EditCaps({ existingCaps, vaultAsset, chainId, isOwner, isUpdatin return markets.filter((m) => m.loanAsset.address.toLowerCase() === vaultAsset.toLowerCase() && m.morphoBlue.chain.id === chainId); }, [markets, vaultAsset, chainId]); - // Initialize from existing caps (only on first load, not after user edits) - useEffect(() => { - // Don't reset state if user has made edits - prevents losing work on background refetch - if (hasUserEditsRef.current) return; - if (availableMarkets.length === 0) return; - + const initialMarketCaps = useMemo(() => { const marketCapsMap = new Map(); for (const cap of existingCaps?.marketCaps ?? []) { const parsed = parseCapIdParams(cap.idParams); @@ -105,9 +120,23 @@ export function EditCaps({ existingCaps, vaultAsset, chainId, isOwner, isUpdatin }); } } - setMarketCaps(marketCapsMap); + return marketCapsMap; }, [availableMarkets, existingCaps, vaultAssetDecimals]); + // Initialize from existing caps (only on first load, not after user edits) + useEffect(() => { + // Don't reset state if user has made edits - prevents losing work on background refetch + if (hasUserEditsRef.current) return; + + setMarketCaps((prev) => { + if (areMarketCapsEqual(prev, initialMarketCaps)) { + return prev; + } + + return initialMarketCaps; + }); + }, [initialMarketCaps]); + const handleAddMarkets = useCallback((newMarkets: Market[]) => { hasUserEditsRef.current = true; setMarketCaps((prev) => { diff --git a/src/features/positions/components/rebalance/rebalance-modal.tsx b/src/features/positions/components/rebalance/rebalance-modal.tsx index f9de142f..14efccd5 100644 --- a/src/features/positions/components/rebalance/rebalance-modal.tsx +++ b/src/features/positions/components/rebalance/rebalance-modal.tsx @@ -91,6 +91,32 @@ function parseMaxAllocationInput(raw: string): number | null { return parsed; } +function getSmartPlannerMarketSignature(market: Market): string { + return [ + market.uniqueKey, + market.loanAsset.address.toLowerCase(), + market.collateralAsset.address.toLowerCase(), + market.oracleAddress?.toLowerCase() ?? '', + market.irmAddress?.toLowerCase() ?? '', + market.lltv ?? '', + market.state.rateAtTarget, + ].join(':'); +} + +function getSmartPlannerConstraintSignature(constraints: Record): string { + return Object.entries(constraints) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, value]) => `${key}:${value}`) + .join('|'); +} + +function getSmartPlannerGroupedPositionSignature(groupedPosition: GroupedPosition): string { + return groupedPosition.markets + .map((position) => `${position.market.uniqueKey}:${position.state.supplyAssets}:${position.state.supplyShares}`) + .sort() + .join('|'); +} + type PreviewRow = { id: string; label: ReactNode; @@ -137,6 +163,7 @@ export function RebalanceModal({ groupedPosition, isOpen, onOpenChange, refetch, const calcIdRef = useRef(0); const wasOpenRef = useRef(false); const syncIndicatorTimeoutRef = useRef | null>(null); + const smartPlannerEligibleMarketsRef = useRef([]); const toast = useStyledToast(); const { isAprDisplay, rebalanceDefaultMode } = useAppSettings(); @@ -175,6 +202,21 @@ export function RebalanceModal({ groupedPosition, isOpen, onOpenChange, refetch, ); }, [markets, groupedPosition.loanAssetAddress, groupedPosition.chainId]); + const smartPlannerEligibleMarketsSignature = useMemo( + () => eligibleMarkets.map(getSmartPlannerMarketSignature).sort().join('|'), + [eligibleMarkets], + ); + const smartPlannerSelectedMarketsSignature = useMemo(() => [...smartSelectedMarketKeys].sort().join('|'), [smartSelectedMarketKeys]); + const smartPlannerConstraintSignature = useMemo( + () => getSmartPlannerConstraintSignature(debouncedSmartMaxAllocationBps), + [debouncedSmartMaxAllocationBps], + ); + const smartPlannerGroupedPositionSignature = useMemo(() => getSmartPlannerGroupedPositionSignature(groupedPosition), [groupedPosition]); + + useEffect(() => { + smartPlannerEligibleMarketsRef.current = eligibleMarkets; + }, [eligibleMarkets, smartPlannerEligibleMarketsSignature]); + const currentSupplyByMarket = useMemo( () => new Map(groupedPosition.markets.map((position) => [position.market.uniqueKey, BigInt(position.state.supplyAssets)])), [groupedPosition.markets], @@ -244,7 +286,7 @@ export function RebalanceModal({ groupedPosition, isOpen, onOpenChange, refetch, void calculateSmartRebalancePlan({ groupedPosition, chainId: groupedPosition.chainId as SupportedNetworks, - candidateMarkets: eligibleMarkets, + candidateMarkets: smartPlannerEligibleMarketsRef.current, includedMarketKeys: smartSelectedMarketKeys, constraints, }) @@ -256,13 +298,29 @@ export function RebalanceModal({ groupedPosition, isOpen, onOpenChange, refetch, if (id !== calcIdRef.current) return; setSmartPlan(null); const message = error instanceof Error ? error.message : 'Failed to calculate smart rebalance plan.'; + console.error('[smart-rebalance] plan calculation failed', { + calcId: id, + chainId: groupedPosition.chainId, + message, + error, + }); setSmartCalculationError(message); }) .finally(() => { if (id !== calcIdRef.current) return; setIsSmartCalculating(false); }); - }, [debouncedSmartMaxAllocationBps, eligibleMarkets, groupedPosition, isOpen, mode, smartSelectedMarketKeys]); + }, [ + debouncedSmartMaxAllocationBps, + groupedPosition, + isOpen, + mode, + smartPlannerConstraintSignature, + smartPlannerEligibleMarketsSignature, + smartPlannerGroupedPositionSignature, + smartPlannerSelectedMarketsSignature, + smartSelectedMarketKeys, + ]); const fmtAmount = useCallback( (value: bigint) => `${formatReadable(formatBalance(value, groupedPosition.loanAssetDecimals))} ${groupedPosition.loanAssetSymbol}`,