diff --git a/AGENTS.md b/AGENTS.md index 2c8d5d59..11bfcfb4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -150,6 +150,9 @@ When touching transaction and position flows, validation MUST include all releva 15. **Bundler3 swap route integrity**: Bundler3 swap leverage/deleverage must use adapter flashloan callbacks (not pre-swap borrow gating), with `callbackHash`/`reenter` wiring and adapter token flows matching on-chain contracts; before submit, verify aggregator quote/tx parity (trusted target, exact/min calldata offsets, and same-pair combined-sell normalization) so previewed borrow/repay/collateral amounts cannot drift from executed inputs; prefer exact-in close executors that fully consume the withdrawn collateral over max-sell refund paths that can strand shared-adapter balances, and only relax build-time allowance checks for adapter-executed paths when the failure is allowance-specific. 16. **Quote, preview, and route-state integrity**: when a preview depends on one or more aggregator legs, surface failures from every required leg and use conservative fallbacks (`0`, disable submit) instead of optimistic defaults, but optional exact-close quote legs must not block still-valid partial execution paths; if a close-out path depends on a dedicated debt-close bound (for example BUY/max-close quoting) plus a separate execution preview, full-close / repay-by-shares intent must be driven by one explicit close-route flag shared by preview and tx building, the close executor must be satisfiable under the same slippage floor shown in UI, and if the current sell quote can fully close debt while the exact close bound is still unresolved the UI must fail closed rather than silently degrading to a dust-leaving partial path; for exact-in swap deleverage routes, the exact close bound is a threshold for switching into close mode, not a universal input cap, so valid oversell/refund paths must remain available and previews must continue to match the selected exact-in amount; preview rate/slippage must come from the executable quote/config, selected route mode must never execute a different route while capability probes are in-flight, and route controls/entry CTAs must stay consistent with capability probes without duplicate low-signal UI. 17. **Permit2 time-units and adapter balance hygiene**: Permit2 `expiration`/`sigDeadline` values must always be unix seconds (never milliseconds), and every adapter-executed swap leg must sweep leftover source tokens from the adapter before bundle exit so shared-adapter balances cannot accumulate between transactions. +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. ### REQUIRED: Regression Rule Capture diff --git a/src/components/common/ProcessModal.tsx b/src/components/common/ProcessModal.tsx index 694aac1a..98675560 100644 --- a/src/components/common/ProcessModal.tsx +++ b/src/components/common/ProcessModal.tsx @@ -2,7 +2,7 @@ import type React from 'react'; import { Cross2Icon } from '@radix-ui/react-icons'; import { Modal, ModalBody } from '@/components/common/Modal'; import { ProcessStepList } from '@/components/common/ProcessStepList'; -import type { ActiveTransaction } from '@/stores/useTransactionProcessStore'; +import type { ActiveTransaction, TransactionSummaryItem } from '@/stores/useTransactionProcessStore'; type ProcessModalProps = { /** @@ -50,6 +50,33 @@ type ProcessModalProps = { * ); * ``` */ +function SummaryBlock({ items }: { items: TransactionSummaryItem[] }) { + return ( +
+ {items.map((item) => ( +
+ {item.label} + + {item.value} + {item.detail && ( + + {item.detail} + + )} + +
+ ))} +
+ ); +} + export function ProcessModal({ transaction, onDismiss, title, description, children }: ProcessModalProps) { // Don't render if no transaction or modal is hidden if (!transaction?.isModalVisible) return null; @@ -79,6 +106,9 @@ export function ProcessModal({ transaction, onDismiss, title, description, child + {transaction.metadata.summaryItems && transaction.metadata.summaryItems.length > 0 && ( + + )} {children} +
{/* Amount and symbol */} - - {isZero ? '0' : formatReadable(amount)} {symbol} + + {isZero ? '0' : formatReadable(amount)} + {symbol ? ` ${symbol}` : ''} {/* Circular percentage indicator */} @@ -40,29 +44,29 @@ export function AllocationCell({ amount, symbol, percentage }: AllocationCellPro >
{/* Background circle */} {/* Progress circle */} void; }; @@ -17,7 +17,6 @@ export function PositionActionsDropdown({ isOwner, onRebalanceClick }: PositionA }; const handleKeyDown = (e: React.KeyboardEvent) => { - // Stop propagation on keyboard events too e.stopPropagation(); }; @@ -43,7 +42,7 @@ export function PositionActionsDropdown({ isOwner, onRebalanceClick }: PositionA onClick={onRebalanceClick} startContent={} disabled={!isOwner} - className={isOwner ? '' : 'opacity-50 cursor-not-allowed'} + className={isOwner ? '' : 'cursor-not-allowed opacity-50'} > Rebalance diff --git a/src/features/positions/components/rebalance/rebalance-modal.tsx b/src/features/positions/components/rebalance/rebalance-modal.tsx index 7f5cec7c..9836511e 100644 --- a/src/features/positions/components/rebalance/rebalance-modal.tsx +++ b/src/features/positions/components/rebalance/rebalance-modal.tsx @@ -1,18 +1,34 @@ -import { useState, useMemo, useCallback } from 'react'; +import { useState, useMemo, useCallback, useEffect, useRef, type ReactNode } from 'react'; +import { CheckIcon } from '@radix-ui/react-icons'; import { RefetchIcon } from '@/components/ui/refetch-icon'; import { parseUnits, formatUnits } from 'viem'; import { Button } from '@/components/ui/button'; import { Modal, ModalHeader, ModalBody, ModalFooter } from '@/components/common/Modal'; +import { ModalIntentSwitcher } from '@/components/common/Modal/ModalIntentSwitcher'; import { Spinner } from '@/components/ui/spinner'; import { TokenIcon } from '@/components/shared/token-icon'; import { ExecuteTransactionButton } from '@/components/ui/ExecuteTransactionButton'; +import { Badge } from '@/components/ui/badge'; import { useModalStore } from '@/stores/useModalStore'; import { useProcessedMarkets } from '@/hooks/useProcessedMarkets'; import { useRebalance } from '@/hooks/useRebalance'; +import { useSmartRebalance } from '@/hooks/useSmartRebalance'; import { useStyledToast } from '@/hooks/useStyledToast'; +import { useAppSettings } from '@/stores/useAppSettings'; +import { useRateLabel } from '@/hooks/useRateLabel'; import type { Market } from '@/utils/types'; import type { GroupedPosition, RebalanceAction } from '@/utils/types'; +import { formatBalance, formatReadable } from '@/utils/balance'; +import { convertApyToApr } from '@/utils/rateMath'; +import { calculateSmartRebalancePlan, type SmartRebalancePlan } from '@/features/positions/smart-rebalance/planner'; +import type { SmartRebalanceConstraintMap } from '@/features/positions/smart-rebalance/types'; +import type { TransactionSummaryItem } from '@/stores/useTransactionProcessStore'; +import type { SupportedNetworks } from '@/utils/networks'; +import { RiSparklingFill } from 'react-icons/ri'; +import { FiTrash2 } from 'react-icons/fi'; +import { AllocationCell } from '../allocation-cell'; import { FromMarketsTable } from '../from-markets-table'; +import { MarketIdentity, MarketIdentityFocus, MarketIdentityMode } from '@/features/markets/components/market-identity'; import { RebalanceActionInput } from './rebalance-action-input'; import { RebalanceCart } from './rebalance-cart'; @@ -24,25 +40,443 @@ type RebalanceModalProps = { isRefetching: boolean; }; +type RebalanceMode = 'manual' | 'smart'; + +const modeOptions: { value: RebalanceMode; label: string }[] = [ + { value: 'smart', label: 'Smart Rebalance' }, + { value: 'manual', label: 'Manual Rebalance' }, +]; +const SMART_REBALANCE_RECALC_DEBOUNCE_MS = 300; +const MAX_ALLOCATION_PERCENT_MIN = 0; +const MAX_ALLOCATION_PERCENT_MAX = 100; +const MAX_ALLOCATION_PERCENT_STEP = 0.5; +const SMART_REBALANCE_FEE_LABEL = 'Fee (0.004%)'; + +function formatPercent(value: number, digits = 2): string { + return `${value.toFixed(digits)}%`; +} + +function formatRate(apy: number, isAprDisplay: boolean): string { + if (!Number.isFinite(apy)) return '-'; + const displayRate = isAprDisplay ? convertApyToApr(apy) : apy; + if (!Number.isFinite(displayRate)) return '-'; + return formatPercent(displayRate * 100, 2); +} + +function formatMaxAllocationInput(value: number): string { + return Number.isInteger(value) ? String(value) : value.toFixed(1); +} + +function parseMaxAllocationInput(raw: string): number | null { + const trimmed = raw.trim(); + if (trimmed === '' || trimmed === '.' || trimmed === ',') return null; + if (!/^(?:\d+(?:[.,]\d*)?|[.,]\d+)$/.test(trimmed)) return null; + + const parsed = Number(trimmed.replace(',', '.')); + if (!Number.isFinite(parsed)) return null; + + return parsed; +} + +type PreviewRow = { + label: string; + value: ReactNode; + valueClassName?: string; +}; + +function PreviewSection({ title, rows }: { title: string; rows: PreviewRow[] }) { + return ( +
+

{title}

+
+ {rows.map((row) => ( +
+ {row.label} + {row.value} +
+ ))} +
+
+ ); +} + export function RebalanceModal({ groupedPosition, isOpen, onOpenChange, refetch, isRefetching }: RebalanceModalProps) { + const [mode, setMode] = useState('smart'); + const [selectedFromMarketUniqueKey, setSelectedFromMarketUniqueKey] = useState(''); const [selectedToMarketUniqueKey, setSelectedToMarketUniqueKey] = useState(''); const [amount, setAmount] = useState('0'); + + const [smartSelectedMarketKeys, setSmartSelectedMarketKeys] = useState>(new Set()); + const [smartMaxAllocationBps, setSmartMaxAllocationBps] = useState>({}); + const [smartMaxAllocationInputValues, setSmartMaxAllocationInputValues] = useState>({}); + const [debouncedSmartMaxAllocationBps, setDebouncedSmartMaxAllocationBps] = useState>({}); + const [smartPlan, setSmartPlan] = useState(null); + const [isSmartCalculating, setIsSmartCalculating] = useState(false); + const [smartCalculationError, setSmartCalculationError] = useState(null); + const [isManualRefreshing, setIsManualRefreshing] = useState(false); + const [isRefreshSynced, setIsRefreshSynced] = useState(false); + + const calcIdRef = useRef(0); + const wasOpenRef = useRef(false); + const syncIndicatorTimeoutRef = useRef | null>(null); + const toast = useStyledToast(); + const { isAprDisplay } = useAppSettings(); + const { short: rateLabel } = useRateLabel(); const { open: openModal, close: closeModal } = useModalStore(); - // Use computed markets based on user setting const { markets } = useProcessedMarkets(); - const { rebalanceActions, addRebalanceAction, removeRebalanceAction, executeRebalance, isProcessing } = useRebalance(groupedPosition); + const { + rebalanceActions, + addRebalanceAction, + removeRebalanceAction, + executeRebalance, + isProcessing: isManualProcessing, + } = useRebalance(groupedPosition); + + const handleSmartTxSuccess = useCallback(() => { + refetch(() => { + toast.info('Data refreshed', 'Position data updated after smart rebalance.'); + }); + }, [refetch, toast]); + + const { + executeSmartRebalance, + isProcessing: isSmartProcessing, + totalMoved: smartTotalMoved, + feeAmount: smartFeeAmount, + } = useSmartRebalance(groupedPosition, smartPlan, handleSmartTxSuccess); - // Filter eligible markets (same loan asset and chain) - // Fresh state is fetched by MarketsTableWithSameLoanAsset component const eligibleMarkets = useMemo(() => { return markets.filter( (market) => market.loanAsset.address === groupedPosition.loanAssetAddress && market.morphoBlue.chain.id === groupedPosition.chainId, ); }, [markets, groupedPosition.loanAssetAddress, groupedPosition.chainId]); + const currentSupplyByMarket = useMemo( + () => new Map(groupedPosition.markets.map((position) => [position.market.uniqueKey, BigInt(position.state.supplyAssets)])), + [groupedPosition.markets], + ); + + const marketByKey = useMemo(() => new Map(eligibleMarkets.map((market) => [market.uniqueKey, market])), [eligibleMarkets]); + + useEffect(() => { + const wasOpen = wasOpenRef.current; + wasOpenRef.current = isOpen; + + if (!isOpen || wasOpen) return; + const nextDefaultSmartMarketKeys = groupedPosition.markets + .filter((position) => BigInt(position.state.supplyAssets) > 0n) + .map((position) => position.market.uniqueKey); + + setMode('smart'); + setSmartSelectedMarketKeys(new Set(nextDefaultSmartMarketKeys)); + setSmartMaxAllocationBps({}); + setSmartMaxAllocationInputValues({}); + setDebouncedSmartMaxAllocationBps({}); + setSmartPlan(null); + setSmartCalculationError(null); + }, [groupedPosition.markets, isOpen]); + + useEffect(() => { + return () => { + if (syncIndicatorTimeoutRef.current) { + clearTimeout(syncIndicatorTimeoutRef.current); + } + }; + }, []); + + useEffect(() => { + if (!isOpen || mode !== 'smart') return; + + const timeoutId = setTimeout(() => { + setDebouncedSmartMaxAllocationBps(smartMaxAllocationBps); + }, SMART_REBALANCE_RECALC_DEBOUNCE_MS); + + return () => { + clearTimeout(timeoutId); + }; + }, [isOpen, mode, smartMaxAllocationBps]); + + useEffect(() => { + if (!isOpen || mode !== 'smart') return; + + if (smartSelectedMarketKeys.size === 0) { + setSmartPlan(null); + setSmartCalculationError(null); + return; + } + + const id = ++calcIdRef.current; + setIsSmartCalculating(true); + setSmartCalculationError(null); + + const constraints: SmartRebalanceConstraintMap = {}; + for (const key of smartSelectedMarketKeys) { + const maxAllocationBps = debouncedSmartMaxAllocationBps[key]; + if (maxAllocationBps !== undefined) { + constraints[key] = { maxAllocationBps }; + } + } + + void calculateSmartRebalancePlan({ + groupedPosition, + chainId: groupedPosition.chainId as SupportedNetworks, + candidateMarkets: eligibleMarkets, + includedMarketKeys: smartSelectedMarketKeys, + constraints, + }) + .then((plan) => { + if (id !== calcIdRef.current) return; + setSmartPlan(plan); + }) + .catch((error: unknown) => { + if (id !== calcIdRef.current) return; + setSmartPlan(null); + const message = error instanceof Error ? error.message : 'Failed to calculate smart rebalance plan.'; + setSmartCalculationError(message); + }) + .finally(() => { + if (id !== calcIdRef.current) return; + setIsSmartCalculating(false); + }); + }, [debouncedSmartMaxAllocationBps, eligibleMarkets, groupedPosition, isOpen, mode, smartSelectedMarketKeys]); + + const fmtAmount = useCallback( + (value: bigint) => `${formatReadable(formatBalance(value, groupedPosition.loanAssetDecimals))} ${groupedPosition.loanAssetSymbol}`, + [groupedPosition.loanAssetDecimals, groupedPosition.loanAssetSymbol], + ); + + const smartCurrentWeightedApr = useMemo(() => (smartPlan ? convertApyToApr(smartPlan.currentWeightedApy) : 0), [smartPlan]); + + const smartProjectedWeightedApr = useMemo(() => (smartPlan ? convertApyToApr(smartPlan.projectedWeightedApy) : 0), [smartPlan]); + + const smartCurrentWeightedApy = smartPlan?.currentWeightedApy ?? 0; + const smartProjectedWeightedApy = smartPlan?.projectedWeightedApy ?? 0; + const smartCurrentWeightedRate = isAprDisplay ? smartCurrentWeightedApr : smartCurrentWeightedApy; + const smartProjectedWeightedRate = isAprDisplay ? smartProjectedWeightedApr : smartProjectedWeightedApy; + const smartWeightedRateDiff = smartProjectedWeightedRate - smartCurrentWeightedRate; + + const smartSummaryItems = useMemo((): TransactionSummaryItem[] => { + if (!smartPlan) return []; + + const items: TransactionSummaryItem[] = [ + { + id: 'weighted-rate', + label: `Weighted ${rateLabel}`, + value: `${formatPercent(smartCurrentWeightedRate * 100)} → ${formatPercent(smartProjectedWeightedRate * 100)}`, + detail: `(${smartWeightedRateDiff >= 0 ? '+' : ''}${formatPercent(smartWeightedRateDiff * 100)})`, + detailColor: smartWeightedRateDiff >= 0 ? 'positive' : 'negative', + }, + ]; + + if (smartTotalMoved > 0n) { + items.push({ + id: 'capital-moved', + label: 'Capital moved', + value: fmtAmount(smartTotalMoved), + }); + items.push({ + id: 'fee', + label: SMART_REBALANCE_FEE_LABEL, + value: fmtAmount(smartFeeAmount), + }); + } + + return items; + }, [ + fmtAmount, + rateLabel, + smartCurrentWeightedRate, + smartFeeAmount, + smartPlan, + smartProjectedWeightedRate, + smartTotalMoved, + smartWeightedRateDiff, + ]); + + const smartRows = useMemo(() => { + const selectedMarkets = [...smartSelectedMarketKeys] + .map((key) => marketByKey.get(key)) + .filter((market): market is Market => market !== undefined) + .sort((a, b) => { + const aCurrent = currentSupplyByMarket.get(a.uniqueKey) ?? 0n; + const bCurrent = currentSupplyByMarket.get(b.uniqueKey) ?? 0n; + if (bCurrent > aCurrent) return 1; + if (bCurrent < aCurrent) return -1; + return a.collateralAsset.symbol.localeCompare(b.collateralAsset.symbol); + }); + + const totalPool = + smartPlan?.totalPool ?? selectedMarkets.reduce((sum, market) => sum + (currentSupplyByMarket.get(market.uniqueKey) ?? 0n), 0n); + const deltaByMarket = new Map(smartPlan?.deltas.map((delta) => [delta.market.uniqueKey, delta])); + + return selectedMarkets.map((market) => { + const currentAmount = currentSupplyByMarket.get(market.uniqueKey) ?? 0n; + const delta = deltaByMarket.get(market.uniqueKey); + const targetAmount = delta?.targetAmount ?? currentAmount; + const amountDelta = targetAmount - currentAmount; + const projectedApy = delta?.projectedApy ?? delta?.currentApy ?? market.state.supplyApy; + const currentAmountDisplay = formatBalance(currentAmount, groupedPosition.loanAssetDecimals); + const targetAmountDisplay = formatBalance(targetAmount, groupedPosition.loanAssetDecimals); + + const currentShare = totalPool > 0n ? Number((currentAmount * 10_000n) / totalPool) / 100 : 0; + const targetShare = totalPool > 0n ? Number((targetAmount * 10_000n) / totalPool) / 100 : 0; + + return { + market, + currentAmount, + targetAmount, + amountDelta, + currentShare, + targetShare, + projectedApy, + currentAmountDisplay, + targetAmountDisplay, + }; + }); + }, [currentSupplyByMarket, groupedPosition.loanAssetDecimals, marketByKey, smartPlan, smartSelectedMarketKeys]); + + const constraintViolations = useMemo(() => { + if (!smartPlan) return []; + + const deltaByMarket = new Map(smartPlan.deltas.map((delta) => [delta.market.uniqueKey, delta])); + const violations: { uniqueKey: string; maxAllocationBps: number }[] = []; + + for (const [uniqueKey, maxAllocationBps] of Object.entries(debouncedSmartMaxAllocationBps)) { + if (maxAllocationBps >= 10_000) continue; + + const delta = deltaByMarket.get(uniqueKey); + const targetAmount = delta?.targetAmount ?? currentSupplyByMarket.get(uniqueKey) ?? 0n; + const maxAllowedAmount = (smartPlan.totalPool * BigInt(maxAllocationBps)) / 10_000n; + + if (targetAmount > maxAllowedAmount) { + violations.push({ uniqueKey, maxAllocationBps }); + } + } + + return violations; + }, [currentSupplyByMarket, debouncedSmartMaxAllocationBps, smartPlan]); + + const isSmartWithdrawOnly = useMemo(() => { + if (!smartPlan || smartTotalMoved === 0n) return false; + return smartPlan.deltas.every((delta) => delta.delta <= 0n); + }, [smartPlan, smartTotalMoved]); + + const isSmartConstraintsPending = useMemo(() => { + const sourceEntries = Object.entries(smartMaxAllocationBps).sort(([left], [right]) => left.localeCompare(right)); + const debouncedEntries = Object.entries(debouncedSmartMaxAllocationBps).sort(([left], [right]) => left.localeCompare(right)); + + return JSON.stringify(sourceEntries) !== JSON.stringify(debouncedEntries); + }, [debouncedSmartMaxAllocationBps, smartMaxAllocationBps]); + + const smartCanExecute = !isSmartCalculating && !isSmartConstraintsPending && !!smartPlan && smartTotalMoved > 0n; + + const handleDeleteSmartMarket = useCallback( + (uniqueKey: string) => { + const currentAmount = currentSupplyByMarket.get(uniqueKey) ?? 0n; + + if (currentAmount > 0n) { + setSmartMaxAllocationBps((prev) => ({ + ...prev, + [uniqueKey]: 0, + })); + setSmartMaxAllocationInputValues((prev) => ({ + ...prev, + [uniqueKey]: formatMaxAllocationInput(0), + })); + return; + } + + setSmartSelectedMarketKeys((prev) => { + const next = new Set(prev); + next.delete(uniqueKey); + return next; + }); + + setSmartMaxAllocationBps((prev) => { + if (!(uniqueKey in prev)) return prev; + const rest = { ...prev }; + delete rest[uniqueKey]; + return rest; + }); + + setSmartMaxAllocationInputValues((prev) => { + if (!(uniqueKey in prev)) return prev; + const rest = { ...prev }; + delete rest[uniqueKey]; + return rest; + }); + }, + [currentSupplyByMarket], + ); + + const updateMaxAllocation = useCallback((uniqueKey: string, value: number) => { + const clamped = Math.max(MAX_ALLOCATION_PERCENT_MIN, Math.min(MAX_ALLOCATION_PERCENT_MAX, value)); + const snapped = Math.round(clamped / MAX_ALLOCATION_PERCENT_STEP) * MAX_ALLOCATION_PERCENT_STEP; + const nextBps = Math.round(snapped * 100); + + setSmartMaxAllocationBps((prev) => ({ + ...prev, + [uniqueKey]: nextBps, + })); + + setSmartMaxAllocationInputValues((prev) => ({ + ...prev, + [uniqueKey]: formatMaxAllocationInput(snapped), + })); + }, []); + + const handleMaxAllocationInputChange = useCallback((uniqueKey: string, value: string) => { + setSmartMaxAllocationInputValues((prev) => ({ + ...prev, + [uniqueKey]: value, + })); + }, []); + + const handleMaxAllocationInputBlur = useCallback( + (uniqueKey: string) => { + const fallbackValue = (smartMaxAllocationBps[uniqueKey] ?? 10_000) / 100; + const rawInput = smartMaxAllocationInputValues[uniqueKey] ?? formatMaxAllocationInput(fallbackValue); + const parsed = parseMaxAllocationInput(rawInput); + + if (parsed === null) { + setSmartMaxAllocationInputValues((prev) => ({ + ...prev, + [uniqueKey]: formatMaxAllocationInput(fallbackValue), + })); + return; + } + + const bounded = Math.max(MAX_ALLOCATION_PERCENT_MIN, Math.min(MAX_ALLOCATION_PERCENT_MAX, parsed)); + const normalized = Math.round(bounded / MAX_ALLOCATION_PERCENT_STEP) * MAX_ALLOCATION_PERCENT_STEP; + updateMaxAllocation(uniqueKey, normalized); + }, + [smartMaxAllocationBps, smartMaxAllocationInputValues, updateMaxAllocation], + ); + + const handleAddSmartMarkets = useCallback(() => { + openModal('rebalanceMarketSelection', { + vaultAsset: groupedPosition.loanAssetAddress as `0x${string}`, + chainId: groupedPosition.chainId as SupportedNetworks, + multiSelect: true, + onSelect: (selectedMarkets) => { + setSmartSelectedMarketKeys((prev) => { + const next = new Set(prev); + for (const market of selectedMarkets) { + next.add(market.uniqueKey); + } + return next; + }); + closeModal('rebalanceMarketSelection'); + }, + }); + }, [closeModal, groupedPosition.chainId, groupedPosition.loanAssetAddress, openModal]); + const getPendingDelta = useCallback( (marketUniqueKey: string): bigint => { return rebalanceActions.reduce((acc: bigint, action: RebalanceAction) => { @@ -64,29 +498,26 @@ export function RebalanceModal({ groupedPosition, isOpen, onOpenChange, refetch, if (!selectedFromMarketUniqueKey) missingFields.push('"From Market"'); if (!selectedToMarketUniqueKey) missingFields.push('"To Market"'); if (!amount) missingFields.push('"Amount"'); - - const errorMessage = `Missing fields: ${missingFields.join(', ')}`; - - toast.error('Missing fields', errorMessage); + toast.error('Missing fields', `Missing fields: ${missingFields.join(', ')}`); return false; } + const scaledAmount = parseUnits(amount, groupedPosition.loanAssetDecimals); - if (scaledAmount <= 0) { + if (scaledAmount <= 0n) { toast.error('Invalid amount', 'Amount must be greater than zero'); return false; } + return true; - }, [selectedFromMarketUniqueKey, selectedToMarketUniqueKey, amount, groupedPosition.loanAssetDecimals, toast]); + }, [amount, groupedPosition.loanAssetDecimals, selectedFromMarketUniqueKey, selectedToMarketUniqueKey, toast]); const getMarkets = useCallback(() => { - const fromMarket = eligibleMarkets.find((m) => m.uniqueKey === selectedFromMarketUniqueKey); - - const toMarket = eligibleMarkets.find((m) => m.uniqueKey === selectedToMarketUniqueKey); + const fromMarket = eligibleMarkets.find((market) => market.uniqueKey === selectedFromMarketUniqueKey); + const toMarket = eligibleMarkets.find((market) => market.uniqueKey === selectedToMarketUniqueKey); if (!fromMarket || !toMarket) { - const errorMessage = `Invalid ${fromMarket ? '' : '"From" Market'}${toMarket ? '' : '"To" Market'}`; - - toast.error('Invalid market selection', errorMessage); + const missing = `${fromMarket ? '' : '"From"'}${toMarket ? '' : `${fromMarket ? '' : ' and '}"To"`}`; + toast.error('Invalid market selection', `Invalid ${missing} market`); return null; } @@ -94,8 +525,8 @@ export function RebalanceModal({ groupedPosition, isOpen, onOpenChange, refetch, }, [eligibleMarkets, selectedFromMarketUniqueKey, selectedToMarketUniqueKey, toast]); const checkBalance = useCallback(() => { - const oldBalance = groupedPosition.markets.find((m) => m.market.uniqueKey === selectedFromMarketUniqueKey)?.state.supplyAssets; - + const oldBalance = groupedPosition.markets.find((position) => position.market.uniqueKey === selectedFromMarketUniqueKey)?.state + .supplyAssets; const pendingDelta = getPendingDelta(selectedFromMarketUniqueKey); const pendingBalance = BigInt(oldBalance ?? 0) + pendingDelta; @@ -105,7 +536,7 @@ export function RebalanceModal({ groupedPosition, isOpen, onOpenChange, refetch, return false; } return true; - }, [selectedFromMarketUniqueKey, amount, groupedPosition.loanAssetDecimals, getPendingDelta, toast, groupedPosition.markets]); + }, [amount, getPendingDelta, groupedPosition.loanAssetDecimals, groupedPosition.markets, selectedFromMarketUniqueKey, toast]); const createAction = useCallback((fromMarket: Market, toMarket: Market, actionAmount: bigint, isMax: boolean): RebalanceAction => { return { @@ -138,79 +569,113 @@ export function RebalanceModal({ groupedPosition, isOpen, onOpenChange, refetch, const handleMaxSelect = useCallback( (marketUniqueKey: string, maxAmount: bigint) => { - const market = eligibleMarkets.find((m) => m.uniqueKey === marketUniqueKey); + const market = eligibleMarkets.find((target) => target.uniqueKey === marketUniqueKey); if (!market) return; - setSelectedFromMarketUniqueKey(marketUniqueKey); - // Convert the bigint amount to a string with the correct number of decimals - const formattedAmount = formatUnits(maxAmount, groupedPosition.loanAssetDecimals); - setAmount(formattedAmount); + setAmount(formatUnits(maxAmount, groupedPosition.loanAssetDecimals)); }, [eligibleMarkets, groupedPosition.loanAssetDecimals], ); - // triggered when "add action" button is clicked, finally added to cart const handleAddAction = useCallback(() => { if (!validateInputs()) return; const fromToMarkets = getMarkets(); - if (!fromToMarkets) { - return; - } + if (!fromToMarkets) return; - const { fromMarket, toMarket } = fromToMarkets; if (!checkBalance()) return; + const { fromMarket, toMarket } = fromToMarkets; const scaledAmount = parseUnits(amount, groupedPosition.loanAssetDecimals); - const selectedPosition = groupedPosition.markets.find((p) => p.market.uniqueKey === selectedFromMarketUniqueKey); - - // Get the pending delta for this market + const selectedPosition = groupedPosition.markets.find((position) => position.market.uniqueKey === selectedFromMarketUniqueKey); const pendingDelta = selectedPosition ? getPendingDelta(selectedPosition.market.uniqueKey) : 0n; - // Check if this is a max amount considering pending delta const isMaxAmount = selectedPosition !== undefined && BigInt(selectedPosition.state.supplyAssets) + pendingDelta === scaledAmount; - // Create the action using the helper function - const action = createAction(fromMarket, toMarket, scaledAmount, isMaxAmount); - addRebalanceAction(action); + addRebalanceAction(createAction(fromMarket, toMarket, scaledAmount, isMaxAmount)); resetSelections(); }, [ - validateInputs, - getMarkets, - checkBalance, + addRebalanceAction, amount, + checkBalance, + createAction, + getMarkets, + getPendingDelta, groupedPosition.loanAssetDecimals, - selectedFromMarketUniqueKey, groupedPosition.markets, - getPendingDelta, - createAction, - addRebalanceAction, resetSelections, + selectedFromMarketUniqueKey, + validateInputs, ]); - const handleExecuteRebalance = useCallback(() => { + const handleExecuteManualRebalance = useCallback(() => { void (async () => { - try { - const result = await executeRebalance(); - // Explicitly refetch AFTER successful execution - if (result === true) { - refetch(() => { - toast.info('Data refreshed', 'Position data updated after rebalance.'); - }); - } - } catch (error) { - console.error('Error during rebalance:', error); + const ok = await executeRebalance(); + if (ok) { + refetch(() => { + toast.info('Data refreshed', 'Position data updated after rebalance.'); + }); } })(); - }, [executeRebalance, toast, refetch]); + }, [executeRebalance, refetch, toast]); - const handleManualRefresh = () => { - refetch(() => { - toast.info('Data refreshed', 'Position data updated', { - icon: 🚀, - }); + const handleExecuteSmartRebalance = useCallback(() => { + void executeSmartRebalance(smartSummaryItems); + }, [executeSmartRebalance, smartSummaryItems]); + + const refreshActionLoading = isManualRefreshing || isRefetching; + + const handleManualRefresh = useCallback(() => { + if (refreshActionLoading) return; + + setIsRefreshSynced(false); + setIsManualRefreshing(true); + + void Promise.resolve( + refetch(() => { + if (syncIndicatorTimeoutRef.current) { + clearTimeout(syncIndicatorTimeoutRef.current); + } + + setIsRefreshSynced(true); + syncIndicatorTimeoutRef.current = setTimeout(() => { + setIsRefreshSynced(false); + }, 1500); + + toast.info('Synced', 'Position data updated', { + icon: , + }); + }), + ).finally(() => { + setIsManualRefreshing(false); }); - }; + }, [refetch, refreshActionLoading, toast]); + + const smartPreviewRows = useMemo( + () => [ + { + label: `Weighted ${rateLabel}`, + value: ( + <> + {formatPercent(smartCurrentWeightedRate * 100)} → {formatPercent(smartProjectedWeightedRate * 100)}{' '} + = 0 ? 'text-green-600' : 'text-red-500'}> + ({smartWeightedRateDiff >= 0 ? '+' : ''} + {formatPercent(smartWeightedRateDiff * 100)}) + + + ), + }, + { + label: 'Capital moved', + value: fmtAmount(smartTotalMoved), + }, + { + label: SMART_REBALANCE_FEE_LABEL, + value: fmtAmount(smartFeeAmount), + }, + ], + [fmtAmount, rateLabel, smartCurrentWeightedRate, smartFeeAmount, smartProjectedWeightedRate, smartTotalMoved, smartWeightedRateDiff], + ); return ( - Rebalance {groupedPosition.loanAsset ?? 'Unknown'} Position - {isRefetching && } + setMode(nextMode as RebalanceMode)} + className="text-2xl" + /> + {mode === 'smart' && ( + + + New + + )}
} - description={`Click on your existing position to rebalance ${ - groupedPosition.loanAssetSymbol ?? groupedPosition.loanAsset ?? 'this token' - } to a new market. You can batch actions.`} + description={ + mode === 'manual' + ? `Move ${groupedPosition.loanAssetSymbol} between markets with explicit actions.` + : `Auto-compute an optimal ${groupedPosition.loanAssetSymbol} allocation for better yield.` + } mainIcon={ onOpenChange(false)} auxiliaryAction={{ - icon: ( - - ), + icon: isRefreshSynced ? : , onClick: () => { - if (!isRefetching) { + if (!refreshActionLoading) { handleManualRefresh(); } }, ariaLabel: 'Refresh position data', }} /> + - BigInt(p.state.supplyShares) > 0) - .map((market) => ({ - ...market, - pendingDelta: getPendingDelta(market.market.uniqueKey), - }))} - selectedMarketUniqueKey={selectedFromMarketUniqueKey} - onSelectMarket={setSelectedFromMarketUniqueKey} - onSelectMax={handleMaxSelect} - /> - - - openModal('rebalanceMarketSelection', { - vaultAsset: groupedPosition.loanAssetAddress as `0x${string}`, - chainId: groupedPosition.chainId, - multiSelect: false, - onSelect: (_markets) => { - if (_markets.length > 0) { - setSelectedToMarketUniqueKey(_markets[0].uniqueKey); + {mode === 'manual' ? ( + <> +
+

From Markets

+ BigInt(position.state.supplyShares) > 0n) + .map((market) => ({ + ...market, + pendingDelta: getPendingDelta(market.market.uniqueKey), + }))} + selectedMarketUniqueKey={selectedFromMarketUniqueKey} + onSelectMarket={setSelectedFromMarketUniqueKey} + onSelectMax={handleMaxSelect} + /> +
+ +
+

Add Action

+ + openModal('rebalanceMarketSelection', { + vaultAsset: groupedPosition.loanAssetAddress as `0x${string}`, + chainId: groupedPosition.chainId as SupportedNetworks, + multiSelect: false, + onSelect: (selectedMarkets) => { + if (selectedMarkets.length > 0) { + setSelectedToMarketUniqueKey(selectedMarkets[0].uniqueKey); + } + closeModal('rebalanceMarketSelection'); + }, + }) } - closeModal('rebalanceMarketSelection'); - }, - }) - } - onClearToMarket={() => setSelectedToMarketUniqueKey('')} - /> - - + onClearToMarket={() => setSelectedToMarketUniqueKey('')} + /> +
+ + + + ) : ( + <> +
+

Market Allocation

+ {(isSmartCalculating || isSmartConstraintsPending) && ( +
+ + Updating +
+ )} +
+
+ + + + + + + + + + + + + {smartRows.map((row) => { + const committedMaxAllocationValue = (smartMaxAllocationBps[row.market.uniqueKey] ?? 10_000) / 100; + const maxAllocationValue = + smartMaxAllocationInputValues[row.market.uniqueKey] ?? formatMaxAllocationInput(committedMaxAllocationValue); + + return ( + + + + + + + + + ); + })} + + + + + +
MarketCurrent AllocationFinal AllocationDeltaPost {rateLabel}Max %
+ + +
+ + +
+
+
+ + +
+
+ 0n ? 'text-green-600' : row.amountDelta < 0n ? 'text-red-500' : 'text-secondary'} + > + {row.amountDelta > 0n ? '+' : ''} + {fmtAmount(row.amountDelta)} + + +
{formatRate(row.projectedApy, isAprDisplay)}
+
+
+ handleMaxAllocationInputChange(row.market.uniqueKey, event.target.value)} + onBlur={() => handleMaxAllocationInputBlur(row.market.uniqueKey)} + className="h-7 w-16 rounded-sm border border-border bg-background px-1.5 text-right text-xs" + /> + +
+
+ + + +
+
+ + {smartCalculationError &&
{smartCalculationError}
} + {!isSmartCalculating && constraintViolations.length > 0 && ( +
+ Some max-allocation limits could not be fully satisfied due to current market liquidity/capacity. +
+ )} + + {smartPlan && ( + + )} + + )}
+ - - Execute Rebalance - + + {mode === 'manual' ? ( + + Execute Rebalance + + ) : ( + + {isSmartWithdrawOnly ? 'Batch Withdraw' : 'Smart Rebalance'} + + )} ); diff --git a/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx b/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx index 2b27f415..7c1a0135 100644 --- a/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx +++ b/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx @@ -331,10 +331,6 @@ export function SuppliedMorphoBlueGroupedTable({ { - if (!isOwner) { - toast.error('No authorization', 'You can only rebalance your own positions'); - return; - } openModal('rebalance', { groupedPosition, refetch, diff --git a/src/features/positions/smart-rebalance/engine.ts b/src/features/positions/smart-rebalance/engine.ts new file mode 100644 index 00000000..27a3210f --- /dev/null +++ b/src/features/positions/smart-rebalance/engine.ts @@ -0,0 +1,358 @@ +import type { Market as BlueMarket } from '@morpho-org/blue-sdk'; +import type { SmartRebalanceConstraintMap, SmartRebalanceDelta, SmartRebalanceEngineInput, SmartRebalanceEngineOutput } from './types'; + +const DEFAULT_MAX_ROUNDS = 50; +const DEFAULT_FRACTIONS: [bigint, bigint][] = [ + [2n, 100n], + [5n, 100n], + [10n, 100n], + [15n, 100n], + [20n, 100n], + [30n, 100n], + [40n, 100n], + [50n, 100n], + [60n, 100n], + [70n, 100n], + [80n, 100n], + [90n, 100n], + [1n, 1n], +]; +const APY_SCALE = 1_000_000_000_000n; + +function utilizationOf(market: BlueMarket): number { + return Number(market.utilization) / 1e18; +} + +function clampBps(value: number | undefined): number | undefined { + if (value === undefined) return undefined; + if (!Number.isFinite(value)) return undefined; + if (value <= 0) return 0; + if (value >= 10_000) return 10_000; + return Math.floor(value); +} + +function sanitizeMaxRounds(rawMaxRounds: number | undefined): number { + if (rawMaxRounds === undefined || !Number.isFinite(rawMaxRounds)) { + return DEFAULT_MAX_ROUNDS; + } + + const rounded = Math.floor(rawMaxRounds); + if (rounded < 1) return 1; + if (rounded > DEFAULT_MAX_ROUNDS) return DEFAULT_MAX_ROUNDS; + return rounded; +} + +function sanitizeFractions(rawFractions: [bigint, bigint][] | undefined): [bigint, bigint][] { + if (!rawFractions || rawFractions.length === 0) { + return DEFAULT_FRACTIONS; + } + + const deduped: [bigint, bigint][] = []; + const seen = new Set(); + + for (const [num, den] of rawFractions) { + if (den <= 0n || num <= 0n || num > den) continue; + + const key = `${num}/${den}`; + if (seen.has(key)) continue; + + seen.add(key); + deduped.push([num, den]); + } + + return deduped.length > 0 ? deduped : DEFAULT_FRACTIONS; +} + +function toApyScaled(apy: number): bigint { + if (!Number.isFinite(apy)) return 0n; + return BigInt(Math.round(apy * Number(APY_SCALE))); +} + +function computeObjective(uniqueKeys: string[], allocations: Map, marketMap: Map): bigint { + let objective = 0n; + + for (const uniqueKey of uniqueKeys) { + const amount = allocations.get(uniqueKey) ?? 0n; + if (amount <= 0n) continue; + + const market = marketMap.get(uniqueKey); + if (!market) continue; + + objective += amount * toApyScaled(market.supplyApy); + } + + return objective; +} + +function objectiveToWeightedApy(objective: bigint, totalPool: bigint): number { + if (totalPool <= 0n) return 0; + const scaled = objective / totalPool; + return Number(scaled) / Number(APY_SCALE); +} + +function minBigInt(a: bigint, b: bigint): bigint { + return a < b ? a : b; +} + +function resolveMaxAllocation( + uniqueKey: string, + totalPool: bigint, + constraints: SmartRebalanceConstraintMap | undefined, +): bigint | undefined { + const raw = constraints?.[uniqueKey]?.maxAllocationBps; + const maxBps = clampBps(raw); + if (maxBps === undefined || maxBps >= 10_000) return undefined; + return (totalPool * BigInt(maxBps)) / 10_000n; +} + +/** + * Pure smart-rebalance optimizer. + * + * Input: + * - Fresh market snapshots (BlueMarket) + * - Current user allocation + withdrawable limits + * - Optional per-market max-allocation constraints + * + * Output: + * - Final target allocation and per-market deltas + * - Weighted APY before/after optimization + */ +export function optimizeSmartRebalance(input: SmartRebalanceEngineInput): SmartRebalanceEngineOutput | null { + const { entries, constraints } = input; + const maxRounds = sanitizeMaxRounds(input.maxRounds); + const fractions = sanitizeFractions(input.fractionRationals); + + if (entries.length === 0) return null; + + const totalPool = entries.reduce((sum, entry) => sum + entry.currentSupply, 0n); + if (totalPool <= 0n) return null; + + const uniqueKeys = entries.map((entry) => entry.uniqueKey); + const allocations = new Map(entries.map((entry) => [entry.uniqueKey, entry.currentSupply])); + const simMarketMap = new Map(entries.map((entry) => [entry.uniqueKey, entry.baselineMarket])); + + const maxWithdrawableMap = new Map(entries.map((entry) => [entry.uniqueKey, entry.maxWithdrawable])); + const maxAllocationMap = new Map( + entries.map((entry) => [entry.uniqueKey, resolveMaxAllocation(entry.uniqueKey, totalPool, constraints)]), + ); + + // First enforce hard allocation limits before objective-driven optimization. + // This guarantees constraints like 0% are respected whenever capacity/liquidity allows. + for (const srcEntry of entries) { + const srcKey = srcEntry.uniqueKey; + const srcCap = maxAllocationMap.get(srcKey); + + if (srcCap === undefined) continue; + + while (true) { + const srcAlloc = allocations.get(srcKey) ?? 0n; + if (srcAlloc <= srcCap) break; + + const alreadyWithdrawn = srcEntry.currentSupply - srcAlloc; + const remainingWithdrawable = (maxWithdrawableMap.get(srcKey) ?? 0n) - alreadyWithdrawn; + if (remainingWithdrawable <= 0n) break; + + const requiredOut = srcAlloc - srcCap; + const maxAmount = minBigInt(requiredOut, remainingWithdrawable); + if (maxAmount <= 0n) break; + + const srcSim = simMarketMap.get(srcKey); + if (!srcSim) break; + + let bestDstKey: string | null = null; + let bestAmount = 0n; + let bestObjective: bigint | null = null; + let bestSrcMarket: BlueMarket | null = null; + let bestDstMarket: BlueMarket | null = null; + + for (const dstEntry of entries) { + const dstKey = dstEntry.uniqueKey; + if (dstKey === srcKey) continue; + + const dstSim = simMarketMap.get(dstKey); + if (!dstSim) continue; + + const dstAlloc = allocations.get(dstKey) ?? 0n; + const dstCap = maxAllocationMap.get(dstKey); + const room = dstCap === undefined ? maxAmount : dstCap - dstAlloc; + if (room <= 0n) continue; + + const amount = minBigInt(maxAmount, room); + if (amount <= 0n) continue; + + let srcAfter: BlueMarket; + let dstAfter: BlueMarket; + + try { + srcAfter = srcSim.withdraw(amount, 0n).market; + dstAfter = dstSim.supply(amount, 0n).market; + } catch { + continue; + } + + simMarketMap.set(srcKey, srcAfter); + simMarketMap.set(dstKey, dstAfter); + allocations.set(srcKey, srcAlloc - amount); + allocations.set(dstKey, dstAlloc + amount); + + const objective = computeObjective(uniqueKeys, allocations, simMarketMap); + + simMarketMap.set(srcKey, srcSim); + simMarketMap.set(dstKey, dstSim); + allocations.set(srcKey, srcAlloc); + allocations.set(dstKey, dstAlloc); + + if (bestObjective === null || objective > bestObjective) { + bestObjective = objective; + bestDstKey = dstKey; + bestAmount = amount; + bestSrcMarket = srcAfter; + bestDstMarket = dstAfter; + } + } + + if (!bestDstKey || !bestSrcMarket || !bestDstMarket || bestAmount <= 0n) { + break; + } + + const dstAlloc = allocations.get(bestDstKey) ?? 0n; + allocations.set(srcKey, srcAlloc - bestAmount); + allocations.set(bestDstKey, dstAlloc + bestAmount); + simMarketMap.set(srcKey, bestSrcMarket); + simMarketMap.set(bestDstKey, bestDstMarket); + } + } + + for (let round = 0; round < maxRounds; round++) { + const currentObjective = computeObjective(uniqueKeys, allocations, simMarketMap); + + let bestSrcKey: string | null = null; + let bestDstKey: string | null = null; + let bestAmount = 0n; + let bestObjective = currentObjective; + let bestSrcMarket: BlueMarket | null = null; + let bestDstMarket: BlueMarket | null = null; + + for (const srcEntry of entries) { + const srcKey = srcEntry.uniqueKey; + const srcAlloc = allocations.get(srcKey) ?? 0n; + const alreadyWithdrawn = srcEntry.currentSupply - srcAlloc; + const remainingWithdrawable = (maxWithdrawableMap.get(srcKey) ?? 0n) - alreadyWithdrawn; + if (remainingWithdrawable <= 0n) continue; + + const maxMove = remainingWithdrawable < srcAlloc ? remainingWithdrawable : srcAlloc; + if (maxMove <= 0n) continue; + + const srcSim = simMarketMap.get(srcKey); + if (!srcSim) continue; + + for (const dstEntry of entries) { + const dstKey = dstEntry.uniqueKey; + if (dstKey === srcKey) continue; + + const dstSim = simMarketMap.get(dstKey); + if (!dstSim) continue; + + const dstAlloc = allocations.get(dstKey) ?? 0n; + const dstCap = maxAllocationMap.get(dstKey); + + if (dstCap !== undefined && dstAlloc >= dstCap) continue; + + for (const [num, den] of fractions) { + let amount = (maxMove * num) / den; + if (amount <= 0n) continue; + if (amount > maxMove) amount = maxMove; + + if (dstCap !== undefined) { + const room = dstCap - dstAlloc; + if (room <= 0n) continue; + if (amount > room) amount = room; + if (amount <= 0n) continue; + } + + let srcAfter: BlueMarket; + let dstAfter: BlueMarket; + + try { + srcAfter = srcSim.withdraw(amount, 0n).market; + dstAfter = dstSim.supply(amount, 0n).market; + } catch { + continue; + } + + simMarketMap.set(srcKey, srcAfter); + simMarketMap.set(dstKey, dstAfter); + allocations.set(srcKey, srcAlloc - amount); + allocations.set(dstKey, dstAlloc + amount); + + const objective = computeObjective(uniqueKeys, allocations, simMarketMap); + + simMarketMap.set(srcKey, srcSim); + simMarketMap.set(dstKey, dstSim); + allocations.set(srcKey, srcAlloc); + allocations.set(dstKey, dstAlloc); + + if (objective > bestObjective) { + bestObjective = objective; + bestSrcKey = srcKey; + bestDstKey = dstKey; + bestAmount = amount; + bestSrcMarket = srcAfter; + bestDstMarket = dstAfter; + } + } + } + } + + if (!bestSrcKey || !bestDstKey || !bestSrcMarket || !bestDstMarket || bestAmount <= 0n) { + break; + } + + simMarketMap.set(bestSrcKey, bestSrcMarket); + simMarketMap.set(bestDstKey, bestDstMarket); + allocations.set(bestSrcKey, (allocations.get(bestSrcKey) ?? 0n) - bestAmount); + allocations.set(bestDstKey, (allocations.get(bestDstKey) ?? 0n) + bestAmount); + } + + const deltas: SmartRebalanceDelta[] = entries.map((entry) => { + const currentAmount = entry.currentSupply; + const targetAmount = allocations.get(entry.uniqueKey) ?? 0n; + const projectedMarket = simMarketMap.get(entry.uniqueKey) ?? entry.baselineMarket; + + return { + market: entry.market, + currentAmount, + targetAmount, + delta: targetAmount - currentAmount, + currentApy: entry.baselineMarket.supplyApy, + projectedApy: projectedMarket.supplyApy, + currentUtilization: utilizationOf(entry.baselineMarket), + projectedUtilization: utilizationOf(projectedMarket), + collateralSymbol: entry.market.collateralAsset?.symbol ?? 'N/A', + }; + }); + + const currentObjective = computeObjective( + uniqueKeys, + new Map(entries.map((entry) => [entry.uniqueKey, entry.currentSupply])), + new Map(entries.map((entry) => [entry.uniqueKey, entry.baselineMarket])), + ); + const projectedObjective = computeObjective(uniqueKeys, allocations, simMarketMap); + + const totalMoved = deltas.reduce((sum, delta) => { + if (delta.delta < 0n) return sum + -delta.delta; + return sum; + }, 0n); + + return { + deltas: deltas.sort((a, b) => { + if (b.delta > a.delta) return 1; + if (b.delta < a.delta) return -1; + return 0; + }), + totalPool, + currentWeightedApy: objectiveToWeightedApy(currentObjective, totalPool), + projectedWeightedApy: objectiveToWeightedApy(projectedObjective, totalPool), + totalMoved, + }; +} diff --git a/src/features/positions/smart-rebalance/planner.ts b/src/features/positions/smart-rebalance/planner.ts new file mode 100644 index 00000000..94780bb8 --- /dev/null +++ b/src/features/positions/smart-rebalance/planner.ts @@ -0,0 +1,210 @@ +import { Market as BlueMarket, MarketParams as BlueMarketParams } from '@morpho-org/blue-sdk'; +import morphoABI from '@/abis/morpho'; +import { getMorphoAddress } from '@/utils/morpho'; +import { getClient } from '@/utils/rpc'; +import type { GroupedPosition, Market } from '@/utils/types'; +import type { SupportedNetworks } from '@/utils/networks'; +import { optimizeSmartRebalance } from './engine'; +import type { SmartRebalanceConstraintMap, SmartRebalanceEngineOutput } from './types'; + +const DUST_AMOUNT = 1000n; + +export type SmartRebalancePlan = SmartRebalanceEngineOutput & { + loanAssetSymbol: string; + loanAssetDecimals: number; +}; + +type BuildSmartRebalancePlanInput = { + groupedPosition: GroupedPosition; + chainId: SupportedNetworks; + candidateMarkets: Market[]; + includedMarketKeys: Set; + constraints?: SmartRebalanceConstraintMap; +}; + +const APY_SCALE = 1_000_000_000_000n; + +function toApyScaled(apy: number): bigint { + if (!Number.isFinite(apy)) return 0n; + return BigInt(Math.round(apy * Number(APY_SCALE))); +} + +function objectiveToWeightedApy(objective: bigint, totalPool: bigint): number { + if (totalPool <= 0n) return 0; + const scaled = objective / totalPool; + return Number(scaled) / Number(APY_SCALE); +} + +function normalizeMaxBps(raw: number | undefined): number | undefined { + if (raw === undefined) return undefined; + if (!Number.isFinite(raw)) return undefined; + if (raw <= 0) return 0; + if (raw >= 10_000) return 10_000; + return Math.floor(raw); +} + +/** + * Loads fresh on-chain market state and builds a smart-rebalance plan. + * + * Responsibilities: + * - Resolve candidate markets + * - Fetch live `market()` snapshots from Morpho + * - Build engine input entries (current supply + withdraw limits) + * - Run pure optimizer + */ +export async function calculateSmartRebalancePlan({ + groupedPosition, + chainId, + candidateMarkets, + includedMarketKeys, + constraints, +}: BuildSmartRebalancePlanInput): Promise { + if (includedMarketKeys.size === 0) return null; + + const selectedMarkets = candidateMarkets.filter((market) => includedMarketKeys.has(market.uniqueKey)); + if (selectedMarkets.length === 0) return null; + + const client = getClient(chainId); + const morphoAddress = getMorphoAddress(chainId); + + const multicallResults = await client.multicall({ + contracts: selectedMarkets.map((market) => ({ + address: morphoAddress as `0x${string}`, + abi: morphoABI, + functionName: 'market' as const, + args: [market.uniqueKey as `0x${string}`], + })), + allowFailure: true, + }); + + const userSupplyByMarket = new Map( + groupedPosition.markets.map((position) => [position.market.uniqueKey, BigInt(position.state.supplyAssets)]), + ); + + const entries = selectedMarkets.flatMap((market, index) => { + const result = multicallResults[index]; + if (result.status !== 'success' || !result.result) { + return []; + } + + const [totalSupplyAssets, totalSupplyShares, totalBorrowAssets, totalBorrowShares, lastUpdate, fee] = result.result; + + if ( + !market.loanAsset?.address || + !market.collateralAsset?.address || + !market.oracleAddress || + !market.irmAddress || + market.lltv === undefined + ) { + return []; + } + + const params = new BlueMarketParams({ + loanToken: market.loanAsset.address as `0x${string}`, + collateralToken: market.collateralAsset.address as `0x${string}`, + oracle: market.oracleAddress as `0x${string}`, + irm: market.irmAddress as `0x${string}`, + lltv: BigInt(market.lltv), + }); + + const baselineMarket = new BlueMarket({ + params, + totalSupplyAssets, + totalBorrowAssets, + totalSupplyShares, + totalBorrowShares, + lastUpdate, + fee, + rateAtTarget: BigInt(market.state.rateAtTarget), + }); + + const currentSupply = userSupplyByMarket.get(market.uniqueKey) ?? 0n; + const normalizedMaxBps = normalizeMaxBps(constraints?.[market.uniqueKey]?.maxAllocationBps); + const allowFullWithdraw = normalizedMaxBps === 0; + const safeSupply = currentSupply > DUST_AMOUNT ? currentSupply - DUST_AMOUNT : 0n; + const maxFromUser = allowFullWithdraw ? currentSupply : safeSupply; + const maxWithdrawable = maxFromUser < baselineMarket.liquidity ? maxFromUser : baselineMarket.liquidity; + + return [ + { + uniqueKey: market.uniqueKey, + market, + baselineMarket, + currentSupply, + maxWithdrawable, + }, + ]; + }); + + if (entries.length === 0) return null; + + const suppliedEntries = entries.filter((entry) => entry.currentSupply > 0n); + const totalPool = entries.reduce((sum, entry) => sum + entry.currentSupply, 0n); + const withdrawAllRequested = + suppliedEntries.length > 0 && + suppliedEntries.every((entry) => { + const normalized = normalizeMaxBps(constraints?.[entry.uniqueKey]?.maxAllocationBps); + return normalized === 0; + }); + + const hasDestinationCapacity = entries.some((entry) => { + const normalized = normalizeMaxBps(constraints?.[entry.uniqueKey]?.maxAllocationBps); + if (normalized === 0) return false; + if (normalized === undefined || normalized >= 10_000) return true; + + const maxAllowed = (totalPool * BigInt(normalized)) / 10_000n; + return entry.currentSupply < maxAllowed; + }); + + if (withdrawAllRequested && !hasDestinationCapacity) { + const currentObjective = entries.reduce((sum, entry) => sum + entry.currentSupply * toApyScaled(entry.baselineMarket.supplyApy), 0n); + + const deltas = entries.map((entry) => { + const withdrawAmount = entry.maxWithdrawable < entry.currentSupply ? entry.maxWithdrawable : entry.currentSupply; + const targetAmount = entry.currentSupply - withdrawAmount; + const projectedMarket = withdrawAmount > 0n ? entry.baselineMarket.withdraw(withdrawAmount, 0n).market : entry.baselineMarket; + + return { + market: entry.market, + currentAmount: entry.currentSupply, + targetAmount, + delta: targetAmount - entry.currentSupply, + currentApy: entry.baselineMarket.supplyApy, + projectedApy: projectedMarket.supplyApy, + currentUtilization: Number(entry.baselineMarket.utilization) / 1e18, + projectedUtilization: Number(projectedMarket.utilization) / 1e18, + collateralSymbol: entry.market.collateralAsset?.symbol ?? 'N/A', + }; + }); + + const projectedObjective = deltas.reduce((sum, delta) => sum + delta.targetAmount * toApyScaled(delta.projectedApy), 0n); + const totalMoved = deltas.reduce((sum, delta) => (delta.delta < 0n ? sum + -delta.delta : sum), 0n); + + return { + deltas: deltas.sort((a, b) => { + if (b.delta > a.delta) return 1; + if (b.delta < a.delta) return -1; + return 0; + }), + totalPool, + currentWeightedApy: objectiveToWeightedApy(currentObjective, totalPool), + projectedWeightedApy: objectiveToWeightedApy(projectedObjective, totalPool), + totalMoved, + loanAssetSymbol: groupedPosition.loanAssetSymbol, + loanAssetDecimals: groupedPosition.loanAssetDecimals, + }; + } + + const optimized = optimizeSmartRebalance({ + entries, + constraints, + }); + + if (!optimized) return null; + + return { + ...optimized, + loanAssetSymbol: groupedPosition.loanAssetSymbol, + loanAssetDecimals: groupedPosition.loanAssetDecimals, + }; +} diff --git a/src/features/positions/smart-rebalance/types.ts b/src/features/positions/smart-rebalance/types.ts new file mode 100644 index 00000000..4f66780a --- /dev/null +++ b/src/features/positions/smart-rebalance/types.ts @@ -0,0 +1,54 @@ +import type { Market as BlueMarket } from '@morpho-org/blue-sdk'; +import type { Market } from '@/utils/types'; + +export type SmartRebalanceEngineEntry = { + /** Stable market ID (Morpho uniqueKey) */ + uniqueKey: string; + /** Full market metadata used by tx builder and UI */ + market: Market; + /** Fresh on-chain market snapshot used for simulation */ + baselineMarket: BlueMarket; + /** User's current supplied amount in this market */ + currentSupply: bigint; + /** Max amount that can be withdrawn from this market */ + maxWithdrawable: bigint; +}; + +export type SmartRebalanceConstraintMap = Record< + string, + { + /** + * Optional max final allocation share in basis points. + * Example: 5000 = 50%. + */ + maxAllocationBps?: number; + } +>; + +export type SmartRebalanceEngineInput = { + entries: SmartRebalanceEngineEntry[]; + constraints?: SmartRebalanceConstraintMap; + maxRounds?: number; + /** Candidate transfer-size fractions as [num, den] pairs */ + fractionRationals?: [bigint, bigint][]; +}; + +export type SmartRebalanceDelta = { + market: Market; + currentAmount: bigint; + targetAmount: bigint; + delta: bigint; + currentApy: number; + projectedApy: number; + currentUtilization: number; + projectedUtilization: number; + collateralSymbol: string; +}; + +export type SmartRebalanceEngineOutput = { + deltas: SmartRebalanceDelta[]; + totalPool: bigint; + currentWeightedApy: number; + projectedWeightedApy: number; + totalMoved: bigint; +}; diff --git a/src/hooks/useRebalance.ts b/src/hooks/useRebalance.ts index 4df5eb96..95738099 100644 --- a/src/hooks/useRebalance.ts +++ b/src/hooks/useRebalance.ts @@ -1,81 +1,40 @@ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useMemo } from 'react'; import { type Address, encodeFunctionData, maxUint256 } from 'viem'; -import { useConnection } from 'wagmi'; import morphoBundlerAbi from '@/abis/bundlerV2'; -import { useTransactionWithToast } from '@/hooks/useTransactionWithToast'; -import { useTransactionTracking } from '@/hooks/useTransactionTracking'; -import { getBundlerV2, MONARCH_TX_IDENTIFIER } from '@/utils/morpho'; +import { GAS_COSTS } from '@/features/markets/components/constants'; +import { useStyledToast } from '@/hooks/useStyledToast'; import type { GroupedPosition, RebalanceAction } from '@/utils/types'; -import { GAS_COSTS, GAS_MULTIPLIER_NUMERATOR, GAS_MULTIPLIER_DENOMINATOR } from '@/features/markets/components/constants'; -import { useERC20Approval } from './useERC20Approval'; -import { useBundlerAuthorizationStep } from './useBundlerAuthorizationStep'; -import { usePermit2 } from './usePermit2'; -import { useAppSettings } from '@/stores/useAppSettings'; -import { useStyledToast } from './useStyledToast'; import { useUserMarketsCache } from '@/stores/useUserMarketsCache'; - -// Define more specific step types -export type RebalanceStepType = - | 'idle' - | 'approve_permit2' // For Permit2 flow: Step 1 - | 'authorize_bundler_sig' // For Permit2 flow: Step 2 (if needed) - | 'sign_permit' // For Permit2 flow: Step 3 - | 'authorize_bundler_tx' // For standard flow: Step 1 (if needed) - | 'approve_token' // For standard flow: Step 2 (if needed) - | 'execute'; // Common final step +import { useConnection } from 'wagmi'; +import { useRebalanceExecution, type RebalanceExecutionStepType } from './useRebalanceExecution'; export const useRebalance = (groupedPosition: GroupedPosition, onRebalance?: () => void) => { const [rebalanceActions, setRebalanceActions] = useState([]); - const [isProcessing, setIsProcessing] = useState(false); - const tracking = useTransactionTracking('rebalance'); - - const { address: account } = useConnection(); - const bundlerAddress = getBundlerV2(groupedPosition.chainId); const toast = useStyledToast(); - const { usePermit2: usePermit2Setting } = useAppSettings(); + const { address: account } = useConnection(); - const totalAmount = rebalanceActions.reduce((acc, action) => acc + BigInt(action.amount), BigInt(0)); + const totalAmount = useMemo(() => rebalanceActions.reduce((acc, action) => acc + BigInt(action.amount), 0n), [rebalanceActions]); - // Hook for Morpho bundler authorization (both sig and tx) - const { isBundlerAuthorized, isAuthorizingBundler, ensureBundlerAuthorization, refetchIsBundlerAuthorized } = useBundlerAuthorizationStep( - { - chainId: groupedPosition.chainId, - bundlerAddress: bundlerAddress as Address, - }, - ); + const { batchAddUserMarkets } = useUserMarketsCache(account); - // Hook for Permit2 handling - const { - authorizePermit2, - permit2Authorized, - signForBundlers, - isLoading: isLoadingPermit2, - } = usePermit2({ - user: account as Address, - spender: bundlerAddress, - token: groupedPosition.loanAssetAddress as Address, - refetchInterval: 10_000, - chainId: groupedPosition.chainId, - tokenSymbol: groupedPosition.loanAsset, - amount: totalAmount, - }); + const handleTxSuccess = useCallback(() => { + setRebalanceActions([]); + onRebalance?.(); + }, [onRebalance]); - // Hook for standard ERC20 approval - const { - isApproved: isTokenApproved, - approve: approveToken, - isApproving: isTokenApproving, - } = useERC20Approval({ - token: groupedPosition.loanAssetAddress as Address, - spender: bundlerAddress, - amount: totalAmount, - tokenSymbol: groupedPosition.loanAsset, + const execution = useRebalanceExecution({ chainId: groupedPosition.chainId, + loanAssetAddress: groupedPosition.loanAssetAddress as Address, + loanAssetSymbol: groupedPosition.loanAsset, + requiredAmount: totalAmount, + trackingType: 'rebalance', + toastId: 'rebalance', + pendingText: 'Rebalancing positions', + successText: 'Positions rebalanced successfully', + errorText: 'Failed to rebalance positions', + onSuccess: handleTxSuccess, }); - // Add newly used markets to the cache - const { batchAddUserMarkets } = useUserMarketsCache(account); - const addRebalanceAction = useCallback((action: RebalanceAction) => { setRebalanceActions((prev) => [...prev, action]); }, []); @@ -84,64 +43,9 @@ export const useRebalance = (groupedPosition: GroupedPosition, onRebalance?: () setRebalanceActions((prev) => prev.filter((_, i) => i !== index)); }, []); - // Transaction hook for the final multicall - const handleTransactionSuccess = useCallback(() => { - setRebalanceActions([]); - void refetchIsBundlerAuthorized(); - // Note: tracking.complete() is called directly after sendTransactionAsync - // to close the modal immediately, not here (which fires on tx confirmation) - if (onRebalance) { - onRebalance(); - } - }, [refetchIsBundlerAuthorized, onRebalance]); - - const { sendTransactionAsync, isConfirming: isExecuting } = useTransactionWithToast({ - toastId: 'rebalance', - pendingText: 'Rebalancing positions', - successText: 'Positions rebalanced successfully', - errorText: 'Failed to rebalance positions', - chainId: groupedPosition.chainId, - onSuccess: handleTransactionSuccess, - }); - - // Helper to generate steps based on flow type - const getStepsForFlow = useCallback( - (isPermit2: boolean) => { - if (isPermit2) { - return [ - { - id: 'approve_permit2', - title: 'Authorize Permit2', - description: "This one-time approval makes sure you don't need to send approval tx again in the future.", - }, - { - id: 'authorize_bundler_sig', - title: 'Authorize Morpho Bundler', - description: 'Sign a message to authorize the Morpho bundler for your position.', - }, - { id: 'sign_permit', title: 'Sign Token Permit', description: 'Sign a Permit2 signature to authorize the token transfer' }, - { id: 'execute', title: 'Confirm Rebalance', description: 'Confirm transaction in wallet to complete the rebalance' }, - ]; - } - return [ - { - id: 'authorize_bundler_tx', - title: 'Authorize Morpho Bundler (Transaction)', - description: 'Submit a transaction to authorize the Morpho bundler for your position.', - }, - { - id: 'approve_token', - title: `Approve ${groupedPosition.loanAsset}`, - description: `Approve ${groupedPosition.loanAsset} for spending`, - }, - { id: 'execute', title: 'Confirm Rebalance', description: 'Confirm transaction in wallet to complete the rebalance' }, - ]; - }, - [groupedPosition.loanAsset], - ); - - // Helper function to generate common withdraw/supply tx data const generateRebalanceTxData = useCallback(() => { + if (!account) return null; + const withdrawTxs: `0x${string}`[] = []; const supplyTxs: `0x${string}`[] = []; const allMarketKeys: string[] = []; @@ -164,21 +68,19 @@ export const useRebalance = (groupedPosition: GroupedPosition, onRebalance?: () } for (const actions of Object.values(groupedWithdraws)) { - const batchAmount = actions.reduce((sum, action) => sum + BigInt(action.amount), BigInt(0)); + const batchAmount = actions.reduce((sum, action) => sum + BigInt(action.amount), 0n); const isWithdrawMax = actions.some((action) => action.isMax); const shares = isWithdrawMax ? groupedPosition.markets.find((m) => m.market.uniqueKey === actions[0].fromMarket.uniqueKey)?.state.supplyShares : undefined; if (isWithdrawMax && shares === undefined) { - throw new Error(`No shares found for max withdraw from market ${actions[0].fromMarket.uniqueKey}`); + return null; } const market = actions[0].fromMarket; - - // Add checks for required market properties if (!market.loanToken || !market.collateralToken || !market.oracle || !market.irm || market.lltv === undefined) { - throw new Error(`Market data incomplete for withdraw from ${market.uniqueKey}`); + return null; } const withdrawTx = encodeFunctionData({ @@ -186,28 +88,27 @@ export const useRebalance = (groupedPosition: GroupedPosition, onRebalance?: () functionName: 'morphoWithdraw', args: [ { - loanToken: market.loanToken! as Address, - collateralToken: market.collateralToken! as Address, + loanToken: market.loanToken as Address, + collateralToken: market.collateralToken as Address, oracle: market.oracle as Address, irm: market.irm as Address, lltv: BigInt(market.lltv), }, - isWithdrawMax ? BigInt(0) : batchAmount, // assets - isWithdrawMax ? BigInt(shares as string) : BigInt(0), // shares - isWithdrawMax ? batchAmount : maxUint256, // maxAssetsToWithdraw or minSharesToWithdraw depending on other inputs - account!, // receiver (assets sent here) + isWithdrawMax ? 0n : batchAmount, + isWithdrawMax ? BigInt(shares as string) : 0n, + isWithdrawMax ? batchAmount : maxUint256, + account, ], }); withdrawTxs.push(withdrawTx); } for (const actions of Object.values(groupedSupplies)) { - const batchedAmount = actions.reduce((sum, action) => sum + BigInt(action.amount), BigInt(0)); + const batchedAmount = actions.reduce((sum, action) => sum + BigInt(action.amount), 0n); const market = actions[0].toMarket; - // Add checks for required market properties if (!market.loanToken || !market.collateralToken || !market.oracle || !market.irm || market.lltv === undefined) { - throw new Error(`Market data incomplete for supply to ${market.uniqueKey}`); + return null; } const supplyTx = encodeFunctionData({ @@ -215,210 +116,83 @@ export const useRebalance = (groupedPosition: GroupedPosition, onRebalance?: () functionName: 'morphoSupply', args: [ { - loanToken: market.loanToken! as Address, - collateralToken: market.collateralToken! as Address, + loanToken: market.loanToken as Address, + collateralToken: market.collateralToken as Address, oracle: market.oracle as Address, irm: market.irm as Address, lltv: BigInt(market.lltv), }, - batchedAmount, // assets - BigInt(0), // shares (must be 0 if assets > 0) - BigInt(1), // minShares (slippage control - accept at least 1 share) - account!, // onBehalf (supply deposited for this account) - '0x', // callback data + batchedAmount, + 0n, + 1n, + account, + '0x', ], }); supplyTxs.push(supplyTx); } return { withdrawTxs, supplyTxs, allMarketKeys }; - }, [rebalanceActions, groupedPosition.markets, account]); + }, [account, groupedPosition.markets, rebalanceActions]); const executeRebalance = useCallback(async () => { - if (!account || rebalanceActions.length === 0) { + if (rebalanceActions.length === 0) { toast.info('No actions', 'Please add rebalance actions first.'); - return; + return false; + } + + const txData = generateRebalanceTxData(); + if (!txData) { + toast.error('Unable to execute', 'Missing wallet or market data for rebalance.'); + return false; } - setIsProcessing(true); - const transactions: `0x${string}`[] = []; - const initialStep = usePermit2Setting ? 'approve_permit2' : 'authorize_bundler_tx'; - tracking.start( - getStepsForFlow(usePermit2Setting), - { + const { withdrawTxs, supplyTxs, allMarketKeys } = txData; + + let gasEstimate = GAS_COSTS.BUNDLER_REBALANCE; + if (supplyTxs.length > 1) { + gasEstimate += GAS_COSTS.SINGLE_SUPPLY * BigInt(supplyTxs.length - 1); + } + if (withdrawTxs.length > 1) { + gasEstimate += GAS_COSTS.SINGLE_WITHDRAW * BigInt(withdrawTxs.length - 1); + } + + return execution.executeBundle({ + metadata: { title: 'Rebalance', description: `Rebalancing ${groupedPosition.loanAsset} positions`, tokenSymbol: groupedPosition.loanAsset, }, - initialStep, - ); - - try { - const { withdrawTxs, supplyTxs, allMarketKeys } = generateRebalanceTxData(); - - let multicallGas: bigint | undefined = undefined; - - if (usePermit2Setting) { - // --- Permit2 Flow --- - tracking.update('approve_permit2'); - if (!permit2Authorized) { - await authorizePermit2(); // Authorize Permit2 contract - await new Promise((resolve) => setTimeout(resolve, 800)); // UI delay - } - - tracking.update('authorize_bundler_sig'); - const { authorizationTxData } = await ensureBundlerAuthorization({ mode: 'signature' }); // Get signature for Bundler auth if needed - if (authorizationTxData) { - transactions.push(authorizationTxData); - await new Promise((resolve) => setTimeout(resolve, 800)); // UI delay - } - - tracking.update('sign_permit'); - const { sigs, permitSingle } = await signForBundlers(); // Sign for Permit2 token transfer - const permitTx = encodeFunctionData({ - abi: morphoBundlerAbi, - functionName: 'approve2', - args: [permitSingle, sigs, false], - }); - const transferFromTx = encodeFunctionData({ - abi: morphoBundlerAbi, - functionName: 'transferFrom2', - args: [groupedPosition.loanAssetAddress as Address, totalAmount], - }); - - transactions.push(permitTx); - transactions.push(...withdrawTxs); // Withdraw first - transactions.push(transferFromTx); // Then transfer assets via Permit2 - transactions.push(...supplyTxs); // Then supply - } else { - // --- Standard ERC20 Flow --- - tracking.update('authorize_bundler_tx'); - const { authorized } = await ensureBundlerAuthorization({ mode: 'transaction' }); // Authorize Bundler via TX if needed - if (!authorized) { - throw new Error('Failed to authorize Bundler via transaction.'); // Stop if auth tx fails/is rejected - } - // Wait for tx confirmation implicitly handled by useTransactionWithToast within authorizeWithTransaction - - tracking.update('approve_token'); - if (!isTokenApproved) { - await approveToken(); // Approve ERC20 token - await new Promise((resolve) => setTimeout(resolve, 1000)); // UI delay - } - - const erc20TransferTx = encodeFunctionData({ - abi: morphoBundlerAbi, - functionName: 'erc20TransferFrom', - args: [groupedPosition.loanAssetAddress as Address, totalAmount], - }); - - transactions.push(...withdrawTxs); // Withdraw first - transactions.push(erc20TransferTx); // Then transfer assets via standard ERC20 - transactions.push(...supplyTxs); // Then supply - - // estimate gas for multicall - multicallGas = GAS_COSTS.BUNDLER_REBALANCE; - - if (supplyTxs.length > 1) { - multicallGas += GAS_COSTS.SINGLE_SUPPLY * BigInt(supplyTxs.length - 1); - } - - if (withdrawTxs.length > 1) { - multicallGas += GAS_COSTS.SINGLE_WITHDRAW * BigInt(withdrawTxs.length - 1); - } - - console.log('multicallGas', multicallGas.toString()); - } - - // Step Final: Execute multicall - tracking.update('execute'); - const multicallTx = (encodeFunctionData({ - abi: morphoBundlerAbi, - functionName: 'multicall', - args: [transactions], - }) + MONARCH_TX_IDENTIFIER) as `0x${string}`; - - await sendTransactionAsync({ - account, - to: bundlerAddress, - data: multicallTx, - chainId: groupedPosition.chainId, - gas: multicallGas ? (multicallGas * GAS_MULTIPLIER_NUMERATOR) / GAS_MULTIPLIER_DENOMINATOR : undefined, - }); - - batchAddUserMarkets( - allMarketKeys.map((key) => ({ - marketUniqueKey: key, - chainId: groupedPosition.chainId, - })), - ); - - // Complete tracking immediately after transaction is sent - // (handleTransactionSuccess will be called later when tx confirms for cleanup) - tracking.complete(); - - return true; - } catch (error) { - console.error('Error during rebalance executeRebalance:', error); - tracking.fail(); - // Log specific details if available, especially for standard flow issues - if (!usePermit2Setting) { - console.error('Error occurred during standard ERC20 rebalance flow.'); - } - if (error instanceof Error) { - console.error('Error message:', error.message); - // Attempt to log simulation failure details if present (common pattern) - if (error.message.toLowerCase().includes('simulation failed') || error.message.toLowerCase().includes('gas estimation failed')) { - console.error('Potential transaction simulation/estimation failure details:', error); - } - } - - // Specific errors should be handled within the sub-functions (auth, approve, sign) with toasts - if (error instanceof Error && !error.message.toLowerCase().includes('rejected')) { - toast.error('Rebalance Failed', 'An unexpected error occurred during rebalance.'); - } - // Don't re-throw generic errors if specific ones were already handled - } finally { - setIsProcessing(false); - } + withdrawTxs, + supplyTxs, + gasEstimate, + onSubmitted: () => { + batchAddUserMarkets( + allMarketKeys.map((marketUniqueKey) => ({ + marketUniqueKey, + chainId: groupedPosition.chainId, + })), + ); + }, + }); }, [ - account, - rebalanceActions, - usePermit2Setting, - permit2Authorized, - authorizePermit2, - ensureBundlerAuthorization, - signForBundlers, - isTokenApproved, - approveToken, + batchAddUserMarkets, + execution, generateRebalanceTxData, - sendTransactionAsync, - bundlerAddress, groupedPosition.chainId, - groupedPosition.loanAssetAddress, groupedPosition.loanAsset, - totalAmount, - batchAddUserMarkets, + rebalanceActions.length, toast, - tracking, - getStepsForFlow, ]); - // Determine overall loading state - const isLoading = isProcessing || isLoadingPermit2 || isTokenApproving || isAuthorizingBundler || isExecuting; - return { rebalanceActions, addRebalanceAction, removeRebalanceAction, executeRebalance, - isProcessing: isLoading, // Use combined loading state - // Transaction tracking - transaction: tracking.transaction, - dismiss: tracking.dismiss, - currentStep: tracking.currentStep as RebalanceStepType | null, - // Expose relevant states for UI feedback - isBundlerAuthorized, - permit2Authorized, // Relevant only if usePermit2Setting is true - isTokenApproved, // Relevant only if usePermit2Setting is false + isProcessing: execution.isProcessing, + transaction: execution.transaction, + dismiss: execution.dismiss, + currentStep: execution.currentStep as RebalanceExecutionStepType | null, }; }; diff --git a/src/hooks/useRebalanceExecution.ts b/src/hooks/useRebalanceExecution.ts new file mode 100644 index 00000000..cf0ab3b7 --- /dev/null +++ b/src/hooks/useRebalanceExecution.ts @@ -0,0 +1,439 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { type Address, encodeFunctionData, zeroAddress } from 'viem'; +import { useConnection } from 'wagmi'; +import morphoBundlerAbi from '@/abis/bundlerV2'; +import { GAS_MULTIPLIER_DENOMINATOR, GAS_MULTIPLIER_NUMERATOR } from '@/features/markets/components/constants'; +import { useAppSettings } from '@/stores/useAppSettings'; +import type { TransactionSummaryItem } from '@/stores/useTransactionProcessStore'; +import { getBundlerV2, MONARCH_TX_IDENTIFIER } from '@/utils/morpho'; +import type { SupportedNetworks } from '@/utils/networks'; +import { useBundlerAuthorizationStep } from './useBundlerAuthorizationStep'; +import { useERC20Approval } from './useERC20Approval'; +import { usePermit2 } from './usePermit2'; +import { useStyledToast } from './useStyledToast'; +import { useTransactionTracking } from './useTransactionTracking'; +import { useTransactionWithToast } from './useTransactionWithToast'; + +export type RebalanceExecutionStepType = + | 'idle' + | 'approve_permit2' + | 'authorize_bundler_sig' + | 'sign_permit' + | 'authorize_bundler_tx' + | 'approve_token' + | 'execute'; + +type ExecuteRebalanceBundleInput = { + metadata: { + title: string; + description: string; + tokenSymbol: string; + summaryItems?: TransactionSummaryItem[]; + }; + withdrawTxs: `0x${string}`[]; + supplyTxs: `0x${string}`[]; + trailingTxs?: `0x${string}`[]; + gasEstimate?: bigint; + transferAmount?: bigint; + requiresAssetTransfer?: boolean; + onSubmitted?: () => void; +}; + +type UseRebalanceExecutionParams = { + chainId: number; + loanAssetAddress: Address; + loanAssetSymbol: string; + requiredAmount: bigint; + trackingType: string; + toastId: string; + pendingText: string; + successText: string; + errorText: string; + onSuccess?: () => void; +}; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +function isUserRejected(error: unknown): boolean { + if (!error || typeof error !== 'object') return false; + const err = error as Record; + if (err.code === 4001 || err.code === 'ACTION_REJECTED') return true; + + const message = typeof err.message === 'string' ? err.message : ''; + if (/user rejected|user denied|request has been rejected/i.test(message)) return true; + + const nested = (err.data as Record | undefined)?.originalError as Record | undefined; + if (nested?.code === 4001 || nested?.code === 'ACTION_REJECTED') return true; + + const cause = err.cause as Record | undefined; + if (cause?.code === 4001 || cause?.code === 'ACTION_REJECTED') return true; + + return false; +} + +const getStepsForFlow = (isPermit2: boolean, loanAssetSymbol: string, requiresAssetTransfer: boolean) => { + if (isPermit2 && requiresAssetTransfer) { + return [ + { id: 'approve_permit2', title: 'Authorize Permit2', description: 'One-time approval for future transactions.' }, + { id: 'authorize_bundler_sig', title: 'Authorize Morpho Bundler', description: 'Sign a message to authorize the Morpho bundler.' }, + { id: 'sign_permit', title: 'Sign Token Permit', description: 'Sign a Permit2 signature to authorize token transfer.' }, + { id: 'execute', title: 'Confirm Rebalance', description: 'Confirm transaction in wallet to execute rebalance.' }, + ]; + } + + if (!isPermit2 && requiresAssetTransfer) { + return [ + { + id: 'authorize_bundler_tx', + title: 'Authorize Morpho Bundler (Transaction)', + description: 'Submit a transaction to authorize the Morpho bundler.', + }, + { id: 'approve_token', title: `Approve ${loanAssetSymbol}`, description: `Approve ${loanAssetSymbol} for spending.` }, + { id: 'execute', title: 'Confirm Rebalance', description: 'Confirm transaction in wallet to execute rebalance.' }, + ]; + } + + return isPermit2 + ? [ + { + id: 'authorize_bundler_sig', + title: 'Authorize Morpho Bundler', + description: 'Sign a message to authorize the Morpho bundler.', + }, + { id: 'execute', title: 'Confirm Rebalance', description: 'Confirm transaction in wallet to execute rebalance.' }, + ] + : [ + { + id: 'authorize_bundler_tx', + title: 'Authorize Morpho Bundler (Transaction)', + description: 'Submit a transaction to authorize the Morpho bundler.', + }, + { id: 'execute', title: 'Confirm Rebalance', description: 'Confirm transaction in wallet to execute rebalance.' }, + ]; +}; + +export function useRebalanceExecution({ + chainId, + loanAssetAddress, + loanAssetSymbol, + requiredAmount, + trackingType, + toastId, + pendingText, + successText, + errorText, + onSuccess, +}: UseRebalanceExecutionParams) { + const [isProcessing, setIsProcessing] = useState(false); + + const tracking = useTransactionTracking(trackingType); + const toast = useStyledToast(); + const { usePermit2: usePermit2Setting } = useAppSettings(); + const { address: account } = useConnection(); + + const bundlerAddress = getBundlerV2(chainId as SupportedNetworks); + const hasBundler = bundlerAddress !== zeroAddress; + + const { isBundlerAuthorized, isAuthorizingBundler, ensureBundlerAuthorization, refetchIsBundlerAuthorized } = useBundlerAuthorizationStep( + { + chainId, + bundlerAddress: bundlerAddress as Address, + }, + ); + + const { + authorizePermit2, + permit2Authorized, + signForBundlers, + isLoading: isLoadingPermit2, + } = usePermit2({ + user: account, + spender: bundlerAddress, + token: loanAssetAddress, + refetchInterval: 10_000, + chainId, + tokenSymbol: loanAssetSymbol, + amount: requiredAmount, + }); + + const permit2AuthorizedRef = useRef(permit2Authorized); + const isLoadingPermit2Ref = useRef(isLoadingPermit2); + + useEffect(() => { + permit2AuthorizedRef.current = permit2Authorized; + }, [permit2Authorized]); + + useEffect(() => { + isLoadingPermit2Ref.current = isLoadingPermit2; + }, [isLoadingPermit2]); + + const { + isApproved: isTokenApproved, + approve: approveToken, + isApproving: isTokenApproving, + } = useERC20Approval({ + token: loanAssetAddress, + spender: bundlerAddress, + amount: requiredAmount, + tokenSymbol: loanAssetSymbol, + chainId, + }); + + const handleTxConfirmed = useCallback(() => { + void refetchIsBundlerAuthorized(); + onSuccess?.(); + }, [refetchIsBundlerAuthorized, onSuccess]); + + const { sendTransactionAsync, isConfirming: isExecuting } = useTransactionWithToast({ + toastId, + pendingText, + successText, + errorText, + chainId, + onSuccess: handleTxConfirmed, + }); + + const waitForPermit2State = useCallback(async () => { + const start = Date.now(); + const timeoutMs = 15_000; + + while (isLoadingPermit2Ref.current) { + if (Date.now() - start >= timeoutMs) { + throw new Error('Permit2 allowance check timed out.'); + } + await sleep(100); + } + }, []); + + const executeBundle = useCallback( + async ({ + metadata, + withdrawTxs, + supplyTxs, + trailingTxs, + gasEstimate, + transferAmount, + requiresAssetTransfer, + onSubmitted, + }: ExecuteRebalanceBundleInput): Promise => { + const amount = transferAmount ?? requiredAmount; + const shouldTransfer = requiresAssetTransfer ?? true; + const hasExecutableTxs = withdrawTxs.length > 0 || supplyTxs.length > 0 || (trailingTxs?.length ?? 0) > 0; + + if (!account) { + return false; + } + + if (!hasExecutableTxs) { + toast.info('Nothing to rebalance', 'No moves to execute.'); + return false; + } + + if (shouldTransfer && amount <= 0n) { + toast.info('Nothing to rebalance', 'No moves to execute.'); + return false; + } + + if (!hasBundler) { + toast.error('Unsupported chain', 'Rebalance is not available on this chain.'); + return false; + } + + setIsProcessing(true); + + try { + await waitForPermit2State(); + + const initialStep: RebalanceExecutionStepType = usePermit2Setting + ? shouldTransfer + ? permit2AuthorizedRef.current + ? isBundlerAuthorized + ? 'sign_permit' + : 'authorize_bundler_sig' + : 'approve_permit2' + : isBundlerAuthorized + ? 'execute' + : 'authorize_bundler_sig' + : shouldTransfer + ? isBundlerAuthorized + ? isTokenApproved + ? 'execute' + : 'approve_token' + : 'authorize_bundler_tx' + : isBundlerAuthorized + ? 'execute' + : 'authorize_bundler_tx'; + + const flowSteps = getStepsForFlow(usePermit2Setting, loanAssetSymbol, shouldTransfer); + + tracking.start( + flowSteps, + { + title: metadata.title, + description: metadata.description, + tokenSymbol: metadata.tokenSymbol, + summaryItems: metadata.summaryItems, + }, + initialStep, + ); + + const stepOrder = new Map(flowSteps.map((step, index) => [step.id, index])); + let runtimeStep: RebalanceExecutionStepType = initialStep; + const updateStepIfAdvancing = (nextStep: RebalanceExecutionStepType) => { + const currentIndex = stepOrder.get(runtimeStep); + const nextIndex = stepOrder.get(nextStep); + if (nextIndex === undefined) return; + if (currentIndex !== undefined && nextIndex <= currentIndex) return; + + tracking.update(nextStep); + runtimeStep = nextStep; + }; + + const transactions: `0x${string}`[] = []; + + if (usePermit2Setting) { + if (shouldTransfer) { + updateStepIfAdvancing('approve_permit2'); + if (!permit2AuthorizedRef.current) { + await authorizePermit2(); + await sleep(800); + } + + updateStepIfAdvancing('authorize_bundler_sig'); + const { authorizationTxData } = await ensureBundlerAuthorization({ mode: 'signature' }); + if (authorizationTxData) { + transactions.push(authorizationTxData); + await sleep(800); + } + + updateStepIfAdvancing('sign_permit'); + const { sigs, permitSingle } = await signForBundlers(amount); + + const permitTx = encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'approve2', + args: [permitSingle, sigs, false], + }); + + const transferFromTx = encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'transferFrom2', + args: [loanAssetAddress, amount], + }); + + transactions.push(permitTx); + transactions.push(...withdrawTxs); + transactions.push(transferFromTx); + transactions.push(...supplyTxs); + } else { + updateStepIfAdvancing('authorize_bundler_sig'); + const { authorizationTxData } = await ensureBundlerAuthorization({ mode: 'signature' }); + if (authorizationTxData) { + transactions.push(authorizationTxData); + await sleep(800); + } + + transactions.push(...withdrawTxs); + transactions.push(...supplyTxs); + } + } else { + updateStepIfAdvancing('authorize_bundler_tx'); + const { authorized } = await ensureBundlerAuthorization({ mode: 'transaction' }); + if (!authorized) { + throw new Error('Failed to authorize Bundler via transaction.'); + } + + if (shouldTransfer) { + updateStepIfAdvancing('approve_token'); + if (!isTokenApproved) { + await approveToken(); + await sleep(1000); + } + + const transferFromTx = encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'erc20TransferFrom', + args: [loanAssetAddress, amount], + }); + + transactions.push(...withdrawTxs); + transactions.push(transferFromTx); + transactions.push(...supplyTxs); + } else { + transactions.push(...withdrawTxs); + transactions.push(...supplyTxs); + } + } + + if (trailingTxs?.length) { + transactions.push(...trailingTxs); + } + + updateStepIfAdvancing('execute'); + + const multicallTx = (encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'multicall', + args: [transactions], + }) + MONARCH_TX_IDENTIFIER) as `0x${string}`; + + await sendTransactionAsync({ + account, + to: bundlerAddress, + data: multicallTx, + chainId, + gas: gasEstimate ? (gasEstimate * GAS_MULTIPLIER_NUMERATOR) / GAS_MULTIPLIER_DENOMINATOR : undefined, + }); + + onSubmitted?.(); + tracking.complete(); + return true; + } catch (error) { + tracking.fail(); + + if (isUserRejected(error)) { + toast.error('Transaction Rejected', 'User rejected transaction.'); + } else { + toast.error('Rebalance Failed', 'An unexpected error occurred during rebalance.'); + } + + return false; + } finally { + setIsProcessing(false); + } + }, + [ + account, + approveToken, + authorizePermit2, + bundlerAddress, + chainId, + ensureBundlerAuthorization, + hasBundler, + isBundlerAuthorized, + isTokenApproved, + loanAssetAddress, + loanAssetSymbol, + requiredAmount, + sendTransactionAsync, + signForBundlers, + toast, + tracking, + usePermit2Setting, + waitForPermit2State, + ], + ); + + const isLoading = useMemo( + () => isProcessing || isLoadingPermit2 || isTokenApproving || isAuthorizingBundler || isExecuting, + [isProcessing, isLoadingPermit2, isTokenApproving, isAuthorizingBundler, isExecuting], + ); + + return { + executeBundle, + isProcessing: isLoading, + transaction: tracking.transaction, + dismiss: tracking.dismiss, + currentStep: tracking.currentStep as RebalanceExecutionStepType | null, + }; +} diff --git a/src/hooks/useSmartRebalance.ts b/src/hooks/useSmartRebalance.ts new file mode 100644 index 00000000..ce8e1c60 --- /dev/null +++ b/src/hooks/useSmartRebalance.ts @@ -0,0 +1,312 @@ +import { useCallback, useMemo } from 'react'; +import { type Address, encodeFunctionData, formatUnits, maxUint256, parseUnits } from 'viem'; +import morphoBundlerAbi from '@/abis/bundlerV2'; +import { getTokenPriceKey } from '@/data-sources/morpho-api/prices'; +import { GAS_COSTS } from '@/features/markets/components/constants'; +import { SMART_REBALANCE_FEE_RECIPIENT } from '@/config/smart-rebalance'; +import { useTokenPrices } from '@/hooks/useTokenPrices'; +import type { GroupedPosition } from '@/utils/types'; +import type { TransactionSummaryItem } from '@/stores/useTransactionProcessStore'; +import type { SmartRebalancePlan } from '@/features/positions/smart-rebalance/planner'; +import { useUserMarketsCache } from '@/stores/useUserMarketsCache'; +import { useRebalanceExecution, type RebalanceExecutionStepType } from './useRebalanceExecution'; +import { useConnection } from 'wagmi'; + +const SMART_REBALANCE_FEE_BPS = 4n; // measured in tenths of a BPS (0.4 bps = 0.004%). +const FEE_BPS_DENOMINATOR = 100_000n; +const SMART_REBALANCE_MAX_FEE_USD = 4; +const SMART_REBALANCE_SHARE_WITHDRAW_DUST_BUFFER = 1000n; + +type SmartRebalanceFeeBreakdown = { + totalFee: bigint; + feeByMarket: Map; +}; + +function computeBaseFeeForDelta(delta: bigint): bigint { + if (delta <= 0n) return 0n; + return (delta * SMART_REBALANCE_FEE_BPS) / FEE_BPS_DENOMINATOR; +} + +function deriveLoanAssetPriceUsdFromPlan(plan: SmartRebalancePlan, loanAssetDecimals: number): number | null { + for (const delta of plan.deltas) { + const market = delta.market; + if (!market.hasUSDPrice) continue; + + const totalSupplyAssets = BigInt(market.state.supplyAssets); + const totalSupplyAssetsUsd = market.state.supplyAssetsUsd; + if (totalSupplyAssets <= 0n || !Number.isFinite(totalSupplyAssetsUsd) || totalSupplyAssetsUsd <= 0) continue; + + const totalSupplyToken = Number(formatUnits(totalSupplyAssets, loanAssetDecimals)); + if (!Number.isFinite(totalSupplyToken) || totalSupplyToken <= 0) continue; + + const priceUsd = totalSupplyAssetsUsd / totalSupplyToken; + if (Number.isFinite(priceUsd) && priceUsd > 0) return priceUsd; + } + + return null; +} + +function computeFeeCapInLoanAssetUnits(loanAssetPriceUsd: number, loanAssetDecimals: number): bigint { + if (!Number.isFinite(loanAssetPriceUsd) || loanAssetPriceUsd <= 0) return 0n; + + const cappedAmountInLoanAsset = SMART_REBALANCE_MAX_FEE_USD / loanAssetPriceUsd; + if (!Number.isFinite(cappedAmountInLoanAsset) || cappedAmountInLoanAsset <= 0) return 0n; + + const precision = Math.min(loanAssetDecimals, 18); + const cappedAmountString = cappedAmountInLoanAsset.toFixed(precision); + return parseUnits(cappedAmountString, loanAssetDecimals); +} + +function computeFeeBreakdown( + plan: SmartRebalancePlan | null, + loanAssetDecimals: number, + pricedLoanAssetUsd: number | null, +): SmartRebalanceFeeBreakdown { + if (!plan) { + return { totalFee: 0n, feeByMarket: new Map() }; + } + + const feeByMarket = new Map(); + const baseFees = plan.deltas + .filter((delta) => delta.delta > 0n) + .map((delta) => ({ + uniqueKey: delta.market.uniqueKey, + fee: computeBaseFeeForDelta(delta.delta), + })) + .filter((entry) => entry.fee > 0n); + + const uncappedTotal = baseFees.reduce((sum, entry) => sum + entry.fee, 0n); + if (uncappedTotal === 0n) { + return { totalFee: 0n, feeByMarket }; + } + + const fallbackPriceUsd = deriveLoanAssetPriceUsdFromPlan(plan, loanAssetDecimals); + const effectiveLoanAssetPriceUsd = pricedLoanAssetUsd ?? fallbackPriceUsd; + const feeCap = + effectiveLoanAssetPriceUsd !== null ? computeFeeCapInLoanAssetUnits(effectiveLoanAssetPriceUsd, loanAssetDecimals) : uncappedTotal; + + let remainingFee = feeCap < uncappedTotal ? feeCap : uncappedTotal; + for (const entry of baseFees) { + if (remainingFee <= 0n) { + feeByMarket.set(entry.uniqueKey, 0n); + continue; + } + + const allocatedFee = entry.fee < remainingFee ? entry.fee : remainingFee; + feeByMarket.set(entry.uniqueKey, allocatedFee); + remainingFee -= allocatedFee; + } + + return { + totalFee: feeCap < uncappedTotal ? feeCap : uncappedTotal, + feeByMarket, + }; +} + +export const useSmartRebalance = (groupedPosition: GroupedPosition, plan: SmartRebalancePlan | null, onSuccess?: () => void) => { + const { address: account } = useConnection(); + const { batchAddUserMarkets } = useUserMarketsCache(account); + const { prices: tokenPrices } = useTokenPrices([ + { + address: groupedPosition.loanAssetAddress, + chainId: groupedPosition.chainId, + }, + ]); + + const totalMoved = useMemo(() => { + if (!plan) return 0n; + return plan.totalMoved; + }, [plan]); + + const pricedLoanAssetUsd = useMemo( + () => tokenPrices.get(getTokenPriceKey(groupedPosition.loanAssetAddress, groupedPosition.chainId)) ?? null, + [groupedPosition.chainId, groupedPosition.loanAssetAddress, tokenPrices], + ); + + const feeBreakdown = useMemo( + () => computeFeeBreakdown(plan, groupedPosition.loanAssetDecimals, pricedLoanAssetUsd), + [groupedPosition.loanAssetDecimals, plan, pricedLoanAssetUsd], + ); + + const feeAmount = useMemo(() => { + return feeBreakdown.totalFee; + }, [feeBreakdown.totalFee]); + + const execution = useRebalanceExecution({ + chainId: groupedPosition.chainId, + loanAssetAddress: groupedPosition.loanAssetAddress as Address, + loanAssetSymbol: groupedPosition.loanAsset, + requiredAmount: totalMoved, + trackingType: 'smart-rebalance', + toastId: 'smart-rebalance', + pendingText: 'Smart rebalancing positions', + successText: 'Smart rebalance completed successfully', + errorText: 'Failed to smart rebalance positions', + onSuccess, + }); + + const generateSmartRebalanceTxData = useCallback(() => { + if (!plan || !account) { + throw new Error('Missing smart-rebalance plan'); + } + + const withdrawTxs: `0x${string}`[] = []; + const supplyTxs: `0x${string}`[] = []; + const touchedMarketKeys = new Set(); + + for (const delta of plan.deltas) { + if (delta.delta >= 0n) continue; + + const withdrawAmount = -delta.delta; + const market = delta.market; + touchedMarketKeys.add(market.uniqueKey); + const supplyShares = BigInt( + groupedPosition.markets.find((position) => position.market.uniqueKey === market.uniqueKey)?.state.supplyShares ?? '0', + ); + const shouldWithdrawByShares = supplyShares > 0n && delta.targetAmount <= SMART_REBALANCE_SHARE_WITHDRAW_DUST_BUFFER; + + if ( + !market.loanAsset?.address || + !market.collateralAsset?.address || + !market.oracleAddress || + !market.irmAddress || + market.lltv === undefined + ) { + throw new Error(`Market data incomplete for withdraw from ${market.uniqueKey}`); + } + + const marketParams = { + loanToken: market.loanAsset.address as Address, + collateralToken: market.collateralAsset.address as Address, + oracle: market.oracleAddress as Address, + irm: market.irmAddress as Address, + lltv: BigInt(market.lltv), + }; + + withdrawTxs.push( + encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'morphoWithdraw', + args: [ + marketParams, + shouldWithdrawByShares ? 0n : withdrawAmount, + shouldWithdrawByShares ? supplyShares : 0n, + shouldWithdrawByShares ? withdrawAmount : maxUint256, + account, + ], + }), + ); + } + + for (const delta of plan.deltas) { + if (delta.delta <= 0n) continue; + + const market = delta.market; + touchedMarketKeys.add(market.uniqueKey); + + if ( + !market.loanAsset?.address || + !market.collateralAsset?.address || + !market.oracleAddress || + !market.irmAddress || + market.lltv === undefined + ) { + throw new Error(`Market data incomplete for supply to ${market.uniqueKey}`); + } + + const marketParams = { + loanToken: market.loanAsset.address as Address, + collateralToken: market.collateralAsset.address as Address, + oracle: market.oracleAddress as Address, + irm: market.irmAddress as Address, + lltv: BigInt(market.lltv), + }; + + const reducedAmount = delta.delta - (feeBreakdown.feeByMarket.get(market.uniqueKey) ?? 0n); + if (reducedAmount <= 0n) continue; + + supplyTxs.push( + encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'morphoSupply', + args: [marketParams, reducedAmount, 0n, 1n, account, '0x'], + }), + ); + } + + return { withdrawTxs, supplyTxs, allMarketKeys: [...touchedMarketKeys] }; + }, [account, feeBreakdown.feeByMarket, groupedPosition.markets, plan]); + + const executeSmartRebalance = useCallback( + async (summaryItems?: TransactionSummaryItem[]) => { + if (!plan || !account || totalMoved === 0n) { + return false; + } + + const { withdrawTxs, supplyTxs, allMarketKeys } = generateSmartRebalanceTxData(); + const isWithdrawOnly = supplyTxs.length === 0; + + let gasEstimate = GAS_COSTS.BUNDLER_REBALANCE; + if (supplyTxs.length > 1) { + gasEstimate += GAS_COSTS.SINGLE_SUPPLY * BigInt(supplyTxs.length - 1); + } + if (withdrawTxs.length > 1) { + gasEstimate += GAS_COSTS.SINGLE_WITHDRAW * BigInt(withdrawTxs.length - 1); + } + + const trailingTxs = isWithdrawOnly + ? [] + : [ + encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'erc20Transfer', + args: [groupedPosition.loanAssetAddress as Address, SMART_REBALANCE_FEE_RECIPIENT, maxUint256], + }), + ]; + + return execution.executeBundle({ + metadata: { + title: 'Smart Rebalance', + description: `Smart rebalancing ${groupedPosition.loanAsset} positions`, + tokenSymbol: groupedPosition.loanAsset, + summaryItems, + }, + withdrawTxs, + supplyTxs, + trailingTxs, + gasEstimate, + transferAmount: isWithdrawOnly ? 0n : totalMoved, + requiresAssetTransfer: !isWithdrawOnly, + onSubmitted: () => { + batchAddUserMarkets( + allMarketKeys.map((marketUniqueKey) => ({ + marketUniqueKey, + chainId: groupedPosition.chainId, + })), + ); + }, + }); + }, + [ + account, + batchAddUserMarkets, + execution, + generateSmartRebalanceTxData, + groupedPosition.chainId, + groupedPosition.loanAsset, + groupedPosition.loanAssetAddress, + plan, + totalMoved, + ], + ); + + return { + executeSmartRebalance, + isProcessing: execution.isProcessing, + totalMoved, + feeAmount, + transaction: execution.transaction, + dismiss: execution.dismiss, + currentStep: execution.currentStep as RebalanceExecutionStepType | null, + }; +}; diff --git a/src/stores/useTransactionProcessStore.ts b/src/stores/useTransactionProcessStore.ts index 82bc0658..d73995d6 100644 --- a/src/stores/useTransactionProcessStore.ts +++ b/src/stores/useTransactionProcessStore.ts @@ -6,6 +6,16 @@ export type TransactionStep = { description: string; }; +export type TransactionSummaryItem = { + id: string; + label: string; + value: string; + /** Secondary value shown smaller, e.g. the diff like "(+0.07%)" */ + detail?: string; + /** Color the detail text */ + detailColor?: 'positive' | 'negative'; +}; + export type TransactionMetadata = { title: string; description?: string; @@ -13,6 +23,8 @@ export type TransactionMetadata = { amount?: bigint; marketId?: string; vaultName?: string; + /** Key-value summary shown in the process modal above the step list */ + summaryItems?: TransactionSummaryItem[]; }; export type ActiveTransaction = {