From 98597e846b996cd86a6f3f100d3d7d353887daa0 Mon Sep 17 00:00:00 2001 From: secretmemelocker Date: Sat, 28 Feb 2026 20:26:42 -0600 Subject: [PATCH 01/10] Initial rebalancing button --- .../components/position-actions-dropdown.tsx | 13 +- .../supplied-morpho-blue-grouped-table.tsx | 14 + src/utils/smart-rebalance.ts | 239 ++++++++++++++++++ 3 files changed, 264 insertions(+), 2 deletions(-) create mode 100644 src/utils/smart-rebalance.ts diff --git a/src/features/positions/components/position-actions-dropdown.tsx b/src/features/positions/components/position-actions-dropdown.tsx index 59df8c73..0478ce03 100644 --- a/src/features/positions/components/position-actions-dropdown.tsx +++ b/src/features/positions/components/position-actions-dropdown.tsx @@ -1,7 +1,7 @@ 'use client'; import type React from 'react'; -import { TbArrowsRightLeft } from 'react-icons/tb'; +import { TbArrowsRightLeft, TbTargetArrow } from 'react-icons/tb'; import { IoEllipsisVertical } from 'react-icons/io5'; import { Button } from '@/components/ui/button'; import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu'; @@ -9,9 +9,10 @@ import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuIte type PositionActionsDropdownProps = { isOwner: boolean; onRebalanceClick: () => void; + onSmartRebalanceClick: () => void; }; -export function PositionActionsDropdown({ isOwner, onRebalanceClick }: PositionActionsDropdownProps) { +export function PositionActionsDropdown({ isOwner, onRebalanceClick, onSmartRebalanceClick }: PositionActionsDropdownProps) { const handleClick = (e: React.MouseEvent) => { e.stopPropagation(); }; @@ -47,6 +48,14 @@ export function PositionActionsDropdown({ isOwner, onRebalanceClick }: PositionA > Rebalance + } + disabled={!isOwner} + className={isOwner ? '' : 'opacity-50 cursor-not-allowed'} + > + 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..cd161bb0 100644 --- a/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx +++ b/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx @@ -31,6 +31,7 @@ import { getGroupedEarnings, groupPositionsByLoanAsset, processCollaterals } fro import { convertApyToApr } from '@/utils/rateMath'; import { useTokenPrices } from '@/hooks/useTokenPrices'; import { getTokenPriceKey } from '@/data-sources/morpho-api/prices'; +import { calculateSmartRebalance, logSmartRebalanceResults } from '@/utils/smart-rebalance'; import { PositionActionsDropdown } from './position-actions-dropdown'; import { SuppliedMarketsDetail } from './supplied-markets-detail'; import { CollateralIconsDisplay } from './collateral-icons-display'; @@ -341,6 +342,19 @@ export function SuppliedMorphoBlueGroupedTable({ isRefetching, }); }} + onSmartRebalanceClick={() => { + if (!isOwner) { + toast.error('No authorization', 'You can only rebalance your own positions'); + return; + } + const result = calculateSmartRebalance(groupedPosition); + if (!result) { + toast.info('Nothing to rebalance', 'No rebalanceable positions found'); + return; + } + logSmartRebalanceResults(result); + toast.info('Smart Rebalance calculated', 'Check browser console for details'); + }} /> , +): SmartRebalanceResult | null { + const decimals = groupedPosition.loanAssetDecimals; + + // 1. Filter markets: keep positions with supply > 0, remove excluded + const eligiblePositions = groupedPosition.markets.filter((pos) => { + if (BigInt(pos.state.supplyAssets) <= 0n) return false; + if (excludedMarketIds?.has(pos.market.uniqueKey)) return false; + return true; + }); + + if (eligiblePositions.length === 0) return null; + + // 2. Determine locked amounts and withdrawable portions + const positionData = eligiblePositions.map((pos) => { + const userSupply = BigInt(pos.state.supplyAssets); + const marketLiquidity = BigInt(pos.market.state.liquidityAssets); + const locked = userSupply > marketLiquidity ? userSupply - marketLiquidity : 0n; + const withdrawable = userSupply - locked; + return { position: pos, userSupply, locked, withdrawable }; + }); + + const totalAssets = positionData.reduce((sum, d) => sum + d.userSupply, 0n); + const totalRebalanceable = positionData.reduce((sum, d) => sum + d.withdrawable, 0n); + + if (totalRebalanceable === 0n) return null; + + // 3. Clone markets and simulate withdrawing our capital to get baseline state + const clonedMarkets = new Map(); + for (const { position, withdrawable } of positionData) { + const clone = deepCloneMarket(position.market); + if (withdrawable > 0n) { + const preview = previewMarketState(clone, -withdrawable); + if (preview) { + applyPreviewToMarket(clone, preview); + } + } + clonedMarkets.set(clone.uniqueKey, clone); + } + + // 4. Greedy allocation loop + // Start with locked amounts pre-seeded + const allocations = new Map(); + for (const { position, locked } of positionData) { + allocations.set(position.market.uniqueKey, locked); + } + + const chunk = totalRebalanceable / BigInt(ALLOCATION_ROUNDS); + if (chunk === 0n) return null; + + for (let round = 0; round < ALLOCATION_ROUNDS; round++) { + const isLastRound = round === ALLOCATION_ROUNDS - 1; + // On last round, allocate remaining to avoid rounding dust + const allocated = chunk * BigInt(round); + const roundAmount = isLastRound ? totalRebalanceable - allocated : chunk; + + let bestMarketKey: string | null = null; + let bestApy = -Infinity; + + for (const [key, clone] of clonedMarkets) { + const preview = previewMarketState(clone, roundAmount); + if (!preview) continue; + if (preview.supplyApy > bestApy) { + bestApy = preview.supplyApy; + bestMarketKey = key; + } + } + + if (!bestMarketKey) break; + + // Allocate to best market + allocations.set(bestMarketKey, (allocations.get(bestMarketKey) ?? 0n) + roundAmount); + + // Update the cloned market state + const clone = clonedMarkets.get(bestMarketKey)!; + const preview = previewMarketState(clone, roundAmount); + if (preview) { + applyPreviewToMarket(clone, preview); + } + } + + // 5. Build deltas + const deltas: MarketDelta[] = positionData.map(({ position, userSupply, locked }) => { + const targetAmount = allocations.get(position.market.uniqueKey) ?? 0n; + const delta = targetAmount - userSupply; + + // Get projected APY by simulating the target supply on a fresh clone + const freshClone = deepCloneMarket(position.market); + // Withdraw our current supply first + const withdrawPreview = previewMarketState(freshClone, -userSupply); + let projectedApy = position.market.state.supplyApy; + if (withdrawPreview && targetAmount > 0n) { + applyPreviewToMarket(freshClone, withdrawPreview); + const supplyPreview = previewMarketState(freshClone, targetAmount); + if (supplyPreview) { + projectedApy = supplyPreview.supplyApy; + } + } + + return { + market: position.market, + currentAmount: userSupply, + targetAmount, + delta, + lockedAmount: locked, + currentApy: position.market.state.supplyApy, + projectedApy, + collateralSymbol: position.market.collateralAsset?.symbol ?? 'N/A', + }; + }); + + // Calculate weighted APYs + const currentWeightedApy = + totalAssets > 0n + ? deltas.reduce((sum, d) => sum + Number(d.currentAmount) * d.currentApy, 0) / Number(totalAssets) + : 0; + + const projectedWeightedApy = + totalAssets > 0n + ? deltas.reduce((sum, d) => sum + Number(d.targetAmount) * d.projectedApy, 0) / Number(totalAssets) + : 0; + + return { + deltas: deltas.sort((a, b) => Number(b.delta - a.delta)), + totalRebalanceable, + totalAssets, + currentWeightedApy, + projectedWeightedApy, + loanAssetSymbol: groupedPosition.loanAssetSymbol, + loanAssetDecimals: decimals, + }; +} + +export function logSmartRebalanceResults(result: SmartRebalanceResult): void { + const { deltas, totalAssets, totalRebalanceable, currentWeightedApy, projectedWeightedApy, loanAssetSymbol, loanAssetDecimals } = result; + + const fmtAmount = (val: bigint) => formatReadable(formatBalance(val, loanAssetDecimals)); + const fmtApy = (val: number) => `${(val * 100).toFixed(2)}%`; + + console.log('\n=== Smart Rebalance Results ==='); + console.log(`Asset: ${loanAssetSymbol}`); + console.log(`Total Assets: ${fmtAmount(totalAssets)} ${loanAssetSymbol}`); + console.log(`Rebalanceable: ${fmtAmount(totalRebalanceable)} ${loanAssetSymbol}`); + console.log(''); + + console.table( + deltas.map((d) => ({ + Collateral: d.collateralSymbol, + 'Current': `${fmtAmount(d.currentAmount)} ${loanAssetSymbol}`, + 'Target': `${fmtAmount(d.targetAmount)} ${loanAssetSymbol}`, + 'Delta': `${Number(d.delta) >= 0 ? '+' : ''}${fmtAmount(d.delta)} ${loanAssetSymbol}`, + 'Locked': d.lockedAmount > 0n ? `${fmtAmount(d.lockedAmount)} ${loanAssetSymbol}` : '-', + 'APY Now': fmtApy(d.currentApy), + 'APY Projected': fmtApy(d.projectedApy), + 'Market ID': `${d.market.uniqueKey.slice(0, 10)}...`, + })), + ); + + console.log(''); + console.log(`Weighted APY: ${fmtApy(currentWeightedApy)} → ${fmtApy(projectedWeightedApy)}`); + const apyDiff = projectedWeightedApy - currentWeightedApy; + console.log(`APY Change: ${apyDiff >= 0 ? '+' : ''}${(apyDiff * 100).toFixed(4)}%`); + console.log('================================\n'); +} From fe9f68ac58f8d130f1c9428ca33e11f6484de287 Mon Sep 17 00:00:00 2001 From: secretmemelocker Date: Sat, 28 Feb 2026 23:45:28 -0600 Subject: [PATCH 02/10] Make rebalance work --- .../supplied-morpho-blue-grouped-table.tsx | 20 +- src/utils/smart-rebalance.ts | 409 +++++++++++------- 2 files changed, 267 insertions(+), 162 deletions(-) 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 cd161bb0..46a6205b 100644 --- a/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx +++ b/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx @@ -26,7 +26,7 @@ import { useRateLabel } from '@/hooks/useRateLabel'; import { useStyledToast } from '@/hooks/useStyledToast'; import type { EarningsPeriod } from '@/hooks/useUserPositionsSummaryData'; import { formatReadable, formatBalance } from '@/utils/balance'; -import { getNetworkImg } from '@/utils/networks'; +import { getNetworkImg, SupportedNetworks } from '@/utils/networks'; import { getGroupedEarnings, groupPositionsByLoanAsset, processCollaterals } from '@/utils/positions'; import { convertApyToApr } from '@/utils/rateMath'; import { useTokenPrices } from '@/hooks/useTokenPrices'; @@ -347,13 +347,17 @@ export function SuppliedMorphoBlueGroupedTable({ toast.error('No authorization', 'You can only rebalance your own positions'); return; } - const result = calculateSmartRebalance(groupedPosition); - if (!result) { - toast.info('Nothing to rebalance', 'No rebalanceable positions found'); - return; - } - logSmartRebalanceResults(result); - toast.info('Smart Rebalance calculated', 'Check browser console for details'); + void calculateSmartRebalance(groupedPosition, groupedPosition.chainId as SupportedNetworks).then((result) => { + if (!result) { + toast.info('Nothing to rebalance', 'No rebalanceable positions found'); + return; + } + logSmartRebalanceResults(result); + toast.info('Smart Rebalance calculated', 'Check browser console for details'); + }).catch((err) => { + console.error('[smart-rebalance] Error:', err); + toast.error('Smart Rebalance failed', 'Check browser console for details'); + }); }} /> , markets: BlueMarket[]): number { + let weightedSum = 0; + let totalWeight = 0; + for (let i = 0; i < entries.length; i++) { + const amount = Number(allocations.get(entries[i].uniqueKey) ?? 0n); + weightedSum += amount * markets[i].supplyApy; + totalWeight += amount; + } + return totalWeight > 0 ? weightedSum / totalWeight : 0; +} + +// --- Main --- + +export async function calculateSmartRebalance( groupedPosition: GroupedPosition, + chainId: SupportedNetworks, excludedMarketIds?: Set, -): SmartRebalanceResult | null { - const decimals = groupedPosition.loanAssetDecimals; - - // 1. Filter markets: keep positions with supply > 0, remove excluded - const eligiblePositions = groupedPosition.markets.filter((pos) => { +): Promise { + // 1. Filter to positions with supply, excluding any blacklisted markets + const positions = groupedPosition.markets.filter((pos) => { if (BigInt(pos.state.supplyAssets) <= 0n) return false; if (excludedMarketIds?.has(pos.market.uniqueKey)) return false; return true; }); - if (eligiblePositions.length === 0) return null; + if (positions.length === 0) return null; - // 2. Determine locked amounts and withdrawable portions - const positionData = eligiblePositions.map((pos) => { - const userSupply = BigInt(pos.state.supplyAssets); - const marketLiquidity = BigInt(pos.market.state.liquidityAssets); - const locked = userSupply > marketLiquidity ? userSupply - marketLiquidity : 0n; - const withdrawable = userSupply - locked; - return { position: pos, userSupply, locked, withdrawable }; + // 2. Fetch fresh on-chain market state via multicall + const client = getClient(chainId); + const morphoAddress = getMorphoAddress(chainId); + + const results = await client.multicall({ + contracts: positions.map((pos) => ({ + address: morphoAddress as `0x${string}`, + abi: morphoABI, + functionName: 'market' as const, + args: [pos.market.uniqueKey as `0x${string}`], + })), + allowFailure: true, }); - const totalAssets = positionData.reduce((sum, d) => sum + d.userSupply, 0n); - const totalRebalanceable = positionData.reduce((sum, d) => sum + d.withdrawable, 0n); + // 3. Build MarketEntry objects from live on-chain data + const entries: MarketEntry[] = []; - if (totalRebalanceable === 0n) return null; + for (let i = 0; i < positions.length; i++) { + const pos = positions[i]; + const result = results[i]; - // 3. Clone markets and simulate withdrawing our capital to get baseline state - const clonedMarkets = new Map(); - for (const { position, withdrawable } of positionData) { - const clone = deepCloneMarket(position.market); - if (withdrawable > 0n) { - const preview = previewMarketState(clone, -withdrawable); - if (preview) { - applyPreviewToMarket(clone, preview); - } + if (result.status !== 'success' || !result.result) { + console.warn(`${LOG_TAG} Failed to fetch on-chain state for ${pos.market.uniqueKey}, skipping`); + continue; } - clonedMarkets.set(clone.uniqueKey, clone); + + const data = result.result as readonly bigint[]; + const [totalSupplyAssets, totalSupplyShares, totalBorrowAssets, totalBorrowShares, lastUpdate, fee] = data; + + const params = new BlueMarketParams({ + loanToken: pos.market.loanAsset.address as `0x${string}`, + collateralToken: pos.market.collateralAsset.address as `0x${string}`, + oracle: pos.market.oracleAddress as `0x${string}`, + irm: pos.market.irmAddress as `0x${string}`, + lltv: BigInt(pos.market.lltv), + }); + + const baselineMarket = new BlueMarket({ + params, + totalSupplyAssets, + totalBorrowAssets, + totalSupplyShares, + totalBorrowShares, + lastUpdate, + fee, + rateAtTarget: BigInt(pos.market.state.rateAtTarget), + }); + + const userSupply = BigInt(pos.state.supplyAssets); + const liquidity = baselineMarket.liquidity; + const maxWithdrawable = userSupply < liquidity ? userSupply : liquidity; + + entries.push({ + uniqueKey: pos.market.uniqueKey, + originalMarket: pos.market, + collateralSymbol: pos.market.collateralAsset?.symbol ?? 'N/A', + baselineMarket, + currentSupply: userSupply, + maxWithdrawable, + }); } - // 4. Greedy allocation loop - // Start with locked amounts pre-seeded + if (entries.length === 0) return null; + + // 4. Compute total moveable capital + let totalMoveable = 0n; + for (const e of entries) totalMoveable += e.maxWithdrawable; + if (totalMoveable === 0n) return null; + + // 5. Determine chunk size: 1% of portfolio, capped at $10 worth + const dollarCap = 10n * 10n ** BigInt(groupedPosition.loanAssetDecimals); + const onePercent = totalMoveable / 100n; + const chunk = onePercent < dollarCap ? onePercent : dollarCap; + if (chunk === 0n) return null; + + // 6. Initialize working state + // - `allocations` tracks the target amount per market (starts as current) + // - `simMarkets` tracks the simulated BlueMarket state reflecting moves const allocations = new Map(); - for (const { position, locked } of positionData) { - allocations.set(position.market.uniqueKey, locked); + const simMarkets: BlueMarket[] = []; + + for (const entry of entries) { + allocations.set(entry.uniqueKey, entry.currentSupply); + simMarkets.push(entry.baselineMarket); } - const chunk = totalRebalanceable / BigInt(ALLOCATION_ROUNDS); - if (chunk === 0n) return null; + // Log initial state + console.log(`${LOG_TAG} chunk=${chunk}, totalMoveable=${totalMoveable}, maxRounds=${MAX_ROUNDS}, markets=${entries.length}`); + for (let i = 0; i < entries.length; i++) { + const e = entries[i]; + console.log(` ${e.collateralSymbol}: supply=${e.currentSupply}, withdrawable=${e.maxWithdrawable}, apy=${(simMarkets[i].supplyApy * 100).toFixed(4)}%`); + } - for (let round = 0; round < ALLOCATION_ROUNDS; round++) { - const isLastRound = round === ALLOCATION_ROUNDS - 1; - // On last round, allocate remaining to avoid rounding dust - const allocated = chunk * BigInt(round); - const roundAmount = isLastRound ? totalRebalanceable - allocated : chunk; - - let bestMarketKey: string | null = null; - let bestApy = -Infinity; - - for (const [key, clone] of clonedMarkets) { - const preview = previewMarketState(clone, roundAmount); - if (!preview) continue; - if (preview.supplyApy > bestApy) { - bestApy = preview.supplyApy; - bestMarketKey = key; + // 7. Greedy hill-climb: try every (src→dst) move of `chunk`, keep the best one per round + for (let round = 0; round < MAX_ROUNDS; round++) { + const currentApy = weightedApy(entries, allocations, simMarkets); + + let bestSrc = -1; + let bestDst = -1; + let bestApy = currentApy; + let bestSrcMarket: BlueMarket | null = null; + let bestDstMarket: BlueMarket | null = null; + + for (let src = 0; src < entries.length; src++) { + const srcKey = entries[src].uniqueKey; + const srcAlloc = allocations.get(srcKey)!; + + // Can't withdraw more than allocated + if (srcAlloc < chunk) continue; + + // Can't withdraw more than what's actually withdrawable from the original position + const alreadyWithdrawn = entries[src].currentSupply - srcAlloc; + if (alreadyWithdrawn + chunk > entries[src].maxWithdrawable) continue; + + // Simulate withdrawal from source + const srcAfter = simMarkets[src].withdraw(chunk, 0n).market; + + for (let dst = 0; dst < entries.length; dst++) { + if (dst === src) continue; + + // Simulate supply to destination + const dstAfter = simMarkets[dst].supply(chunk, 0n).market; + + // Temporarily apply to compute weighted APY + const prevSrcMarket = simMarkets[src]; + const prevDstMarket = simMarkets[dst]; + simMarkets[src] = srcAfter; + simMarkets[dst] = dstAfter; + + const prevSrcAlloc = allocations.get(srcKey)!; + const dstKey = entries[dst].uniqueKey; + const prevDstAlloc = allocations.get(dstKey)!; + allocations.set(srcKey, prevSrcAlloc - chunk); + allocations.set(dstKey, prevDstAlloc + chunk); + + const candidateApy = weightedApy(entries, allocations, simMarkets); + + // Revert + simMarkets[src] = prevSrcMarket; + simMarkets[dst] = prevDstMarket; + allocations.set(srcKey, prevSrcAlloc); + allocations.set(dstKey, prevDstAlloc); + + if (candidateApy > bestApy) { + bestApy = candidateApy; + bestSrc = src; + bestDst = dst; + bestSrcMarket = srcAfter; + bestDstMarket = dstAfter; + } } } - if (!bestMarketKey) break; + // No move improves weighted APY — we're done + if (bestSrc === -1 || !bestSrcMarket || !bestDstMarket) { + console.log(`${LOG_TAG} round ${round}: converged. weighted APY=${(currentApy * 100).toFixed(6)}%`); + break; + } + + // Apply the best move + const srcKey = entries[bestSrc].uniqueKey; + const dstKey = entries[bestDst].uniqueKey; - // Allocate to best market - allocations.set(bestMarketKey, (allocations.get(bestMarketKey) ?? 0n) + roundAmount); + simMarkets[bestSrc] = bestSrcMarket; + simMarkets[bestDst] = bestDstMarket; + allocations.set(srcKey, allocations.get(srcKey)! - chunk); + allocations.set(dstKey, allocations.get(dstKey)! + chunk); - // Update the cloned market state - const clone = clonedMarkets.get(bestMarketKey)!; - const preview = previewMarketState(clone, roundAmount); - if (preview) { - applyPreviewToMarket(clone, preview); + if (round < 5 || round % 500 === 0) { + console.log( + `${LOG_TAG} round ${round}: ${entries[bestSrc].collateralSymbol}→${entries[bestDst].collateralSymbol} ` + + `| weighted APY: ${(currentApy * 100).toFixed(6)}%→${(bestApy * 100).toFixed(6)}%`, + ); } } - // 5. Build deltas - const deltas: MarketDelta[] = positionData.map(({ position, userSupply, locked }) => { - const targetAmount = allocations.get(position.market.uniqueKey) ?? 0n; - const delta = targetAmount - userSupply; - - // Get projected APY by simulating the target supply on a fresh clone - const freshClone = deepCloneMarket(position.market); - // Withdraw our current supply first - const withdrawPreview = previewMarketState(freshClone, -userSupply); - let projectedApy = position.market.state.supplyApy; - if (withdrawPreview && targetAmount > 0n) { - applyPreviewToMarket(freshClone, withdrawPreview); - const supplyPreview = previewMarketState(freshClone, targetAmount); - if (supplyPreview) { - projectedApy = supplyPreview.supplyApy; - } - } + // 8. Build result deltas + const deltas: MarketDelta[] = entries.map((entry, i) => { + const current = entry.currentSupply; + const target = allocations.get(entry.uniqueKey)!; return { - market: position.market, - currentAmount: userSupply, - targetAmount, - delta, - lockedAmount: locked, - currentApy: position.market.state.supplyApy, - projectedApy, - collateralSymbol: position.market.collateralAsset?.symbol ?? 'N/A', + market: entry.originalMarket, + currentAmount: current, + targetAmount: target, + delta: target - current, + currentApy: entry.baselineMarket.supplyApy, + projectedApy: simMarkets[i].supplyApy, + currentUtilization: utilizationOf(entry.baselineMarket), + projectedUtilization: utilizationOf(simMarkets[i]), + collateralSymbol: entry.collateralSymbol, }; }); - // Calculate weighted APYs + const totalPool = deltas.reduce((sum, d) => sum + d.currentAmount, 0n); + const currentWeightedApy = - totalAssets > 0n - ? deltas.reduce((sum, d) => sum + Number(d.currentAmount) * d.currentApy, 0) / Number(totalAssets) + totalPool > 0n + ? deltas.reduce((sum, d) => sum + Number(d.currentAmount) * d.currentApy, 0) / Number(totalPool) : 0; const projectedWeightedApy = - totalAssets > 0n - ? deltas.reduce((sum, d) => sum + Number(d.targetAmount) * d.projectedApy, 0) / Number(totalAssets) + totalPool > 0n + ? deltas.reduce((sum, d) => sum + Number(d.targetAmount) * d.projectedApy, 0) / Number(totalPool) : 0; return { deltas: deltas.sort((a, b) => Number(b.delta - a.delta)), - totalRebalanceable, - totalAssets, + totalPool, currentWeightedApy, projectedWeightedApy, loanAssetSymbol: groupedPosition.loanAssetSymbol, - loanAssetDecimals: decimals, + loanAssetDecimals: groupedPosition.loanAssetDecimals, }; } +// --- Logging --- + export function logSmartRebalanceResults(result: SmartRebalanceResult): void { - const { deltas, totalAssets, totalRebalanceable, currentWeightedApy, projectedWeightedApy, loanAssetSymbol, loanAssetDecimals } = result; + const { deltas, totalPool, currentWeightedApy, projectedWeightedApy, loanAssetSymbol, loanAssetDecimals } = result; - const fmtAmount = (val: bigint) => formatReadable(formatBalance(val, loanAssetDecimals)); - const fmtApy = (val: number) => `${(val * 100).toFixed(2)}%`; + const fmt = (val: bigint) => formatReadable(formatBalance(val, loanAssetDecimals)); + const fmtApr = (apy: number) => `${(apyToApr(apy) * 100).toFixed(2)}%`; + const fmtUtil = (u: number) => `${(u * 100).toFixed(1)}%`; - console.log('\n=== Smart Rebalance Results ==='); - console.log(`Asset: ${loanAssetSymbol}`); - console.log(`Total Assets: ${fmtAmount(totalAssets)} ${loanAssetSymbol}`); - console.log(`Rebalanceable: ${fmtAmount(totalRebalanceable)} ${loanAssetSymbol}`); + console.log('\n=== Smart Rebalance Results (fresh on-chain data) ==='); + console.log(`Asset: ${loanAssetSymbol} | Total: ${fmt(totalPool)} ${loanAssetSymbol}`); console.log(''); console.table( deltas.map((d) => ({ Collateral: d.collateralSymbol, - 'Current': `${fmtAmount(d.currentAmount)} ${loanAssetSymbol}`, - 'Target': `${fmtAmount(d.targetAmount)} ${loanAssetSymbol}`, - 'Delta': `${Number(d.delta) >= 0 ? '+' : ''}${fmtAmount(d.delta)} ${loanAssetSymbol}`, - 'Locked': d.lockedAmount > 0n ? `${fmtAmount(d.lockedAmount)} ${loanAssetSymbol}` : '-', - 'APY Now': fmtApy(d.currentApy), - 'APY Projected': fmtApy(d.projectedApy), + Current: `${fmt(d.currentAmount)} ${loanAssetSymbol}`, + Target: `${fmt(d.targetAmount)} ${loanAssetSymbol}`, + Delta: `${Number(d.delta) >= 0 ? '+' : ''}${fmt(d.delta)} ${loanAssetSymbol}`, + 'Util Now': fmtUtil(d.currentUtilization), + 'Util After': fmtUtil(d.projectedUtilization), + 'APR Now': fmtApr(d.currentApy), + 'APR After': fmtApr(d.projectedApy), 'Market ID': `${d.market.uniqueKey.slice(0, 10)}...`, })), ); console.log(''); - console.log(`Weighted APY: ${fmtApy(currentWeightedApy)} → ${fmtApy(projectedWeightedApy)}`); - const apyDiff = projectedWeightedApy - currentWeightedApy; - console.log(`APY Change: ${apyDiff >= 0 ? '+' : ''}${(apyDiff * 100).toFixed(4)}%`); + const currentApr = apyToApr(currentWeightedApy); + const projectedApr = apyToApr(projectedWeightedApy); + const aprDiff = projectedApr - currentApr; + console.log(`Weighted APR: ${fmtApr(currentWeightedApy)} → ${fmtApr(projectedWeightedApy)} (${aprDiff >= 0 ? '+' : ''}${(aprDiff * 100).toFixed(4)}%)`); console.log('================================\n'); } From 4b74fed389e74eb8fed6825ee7c7dc697fec941d Mon Sep 17 00:00:00 2001 From: secretmemelocker Date: Sun, 1 Mar 2026 08:51:32 -0600 Subject: [PATCH 03/10] Add smart rebalance button --- src/components/common/ProcessModal.tsx | 33 +- .../components/position-actions-dropdown.tsx | 32 +- .../rebalance/smart-rebalance-modal.tsx | 314 +++++++++++++++ .../supplied-morpho-blue-grouped-table.tsx | 29 +- src/hooks/useSmartRebalance.ts | 368 ++++++++++++++++++ src/modals/registry.tsx | 5 + src/stores/useModalStore.ts | 6 + src/stores/useTransactionProcessStore.ts | 11 + src/utils/smart-rebalance.ts | 146 ++++--- 9 files changed, 857 insertions(+), 87 deletions(-) create mode 100644 src/features/positions/components/rebalance/smart-rebalance-modal.tsx create mode 100644 src/hooks/useSmartRebalance.ts diff --git a/src/components/common/ProcessModal.tsx b/src/components/common/ProcessModal.tsx index 694aac1a..6e871ea3 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,34 @@ 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 +107,9 @@ export function ProcessModal({ transaction, onDismiss, title, description, child + {transaction.metadata.summaryItems && transaction.metadata.summaryItems.length > 0 && ( + + )} {children} void; onSmartRebalanceClick: () => void; + onSmartRebalanceConfigClick: () => void; }; -export function PositionActionsDropdown({ isOwner, onRebalanceClick, onSmartRebalanceClick }: PositionActionsDropdownProps) { +export function PositionActionsDropdown({ onRebalanceClick, onSmartRebalanceClick, onSmartRebalanceConfigClick }: PositionActionsDropdownProps) { const handleClick = (e: React.MouseEvent) => { e.stopPropagation(); }; const handleKeyDown = (e: React.KeyboardEvent) => { - // Stop propagation on keyboard events too e.stopPropagation(); }; @@ -43,19 +44,24 @@ export function PositionActionsDropdown({ isOwner, onRebalanceClick, onSmartReba } - disabled={!isOwner} - className={isOwner ? '' : 'opacity-50 cursor-not-allowed'} > Rebalance - } - disabled={!isOwner} - className={isOwner ? '' : 'opacity-50 cursor-not-allowed'} - > - Smart Rebalance - +
+ } + className="flex-1 rounded-r-none" + > + Smart Rebalance + + + + +
diff --git a/src/features/positions/components/rebalance/smart-rebalance-modal.tsx b/src/features/positions/components/rebalance/smart-rebalance-modal.tsx new file mode 100644 index 00000000..e6a73fef --- /dev/null +++ b/src/features/positions/components/rebalance/smart-rebalance-modal.tsx @@ -0,0 +1,314 @@ +import { useState, useCallback, useEffect, useRef, useMemo } from 'react'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Modal, ModalHeader, ModalBody, ModalFooter } from '@/components/common/Modal'; +import { Spinner } from '@/components/ui/spinner'; +import { TokenIcon } from '@/components/shared/token-icon'; +import { ExecuteTransactionButton } from '@/components/ui/ExecuteTransactionButton'; +import { useSmartRebalance } from '@/hooks/useSmartRebalance'; +import { formatBalance, formatReadable } from '@/utils/balance'; +import { calculateSmartRebalance } from '@/utils/smart-rebalance'; +import type { SmartRebalanceResult } from '@/utils/smart-rebalance'; +import type { TransactionSummaryItem } from '@/stores/useTransactionProcessStore'; +import type { GroupedPosition } from '@/utils/types'; +import type { SupportedNetworks } from '@/utils/networks'; + +type SmartRebalanceModalProps = { + groupedPosition: GroupedPosition; + chainId: SupportedNetworks; + isOpen: boolean; + onOpenChange: (open: boolean) => void; + quickMode?: boolean; +}; + +function apyToApr(apy: number): number { + if (apy <= 0) return 0; + return Math.log(1 + apy); +} + +function fmtApr(apy: number): string { + return `${(apyToApr(apy) * 100).toFixed(2)}%`; +} + +export function SmartRebalanceModal({ groupedPosition, chainId, isOpen, onOpenChange, quickMode }: SmartRebalanceModalProps) { + // Markets with supply > 0 + const marketsWithSupply = useMemo( + () => + groupedPosition.markets + .filter((pos) => BigInt(pos.state.supplyAssets) > 0n) + .sort( + (a, b) => + Number(formatBalance(b.state.supplyAssets, b.market.loanAsset.decimals)) - + Number(formatBalance(a.state.supplyAssets, a.market.loanAsset.decimals)), + ), + [groupedPosition.markets], + ); + + const [excludedIds, setExcludedIds] = useState>(new Set()); + const [result, setResult] = useState(null); + const [isCalculating, setIsCalculating] = useState(false); + + const { executeSmartRebalance, isProcessing, totalMoved, feeAmount } = useSmartRebalance(groupedPosition, result); + + const fmt = useCallback( + (val: bigint) => formatReadable(formatBalance(val, groupedPosition.loanAssetDecimals)), + [groupedPosition.loanAssetDecimals], + ); + + // Stable key that changes only when excluded set actually changes + const excludedKey = useMemo(() => [...excludedIds].sort().join(','), [excludedIds]); + + // Auto-calculate on open and when excludedIds change + const calcIdRef = useRef(0); + + useEffect(() => { + if (!isOpen) return; + + if (excludedIds.size >= marketsWithSupply.length) { + setResult(null); + return; + } + + const id = ++calcIdRef.current; + setIsCalculating(true); + + void calculateSmartRebalance(groupedPosition, chainId, excludedIds.size > 0 ? excludedIds : undefined) + .then((res) => { + if (id !== calcIdRef.current) return; + setResult(res); + }) + .catch((err) => { + if (id !== calcIdRef.current) return; + console.error('[smart-rebalance] Error:', err); + setResult(null); + }) + .finally(() => { + if (id !== calcIdRef.current) return; + setIsCalculating(false); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOpen, excludedKey, chainId]); + + const toggleMarket = useCallback((uniqueKey: string) => { + setExcludedIds((prev) => { + const next = new Set(prev); + if (next.has(uniqueKey)) { + next.delete(uniqueKey); + } else { + next.add(uniqueKey); + } + return next; + }); + }, []); + + const allExcluded = excludedIds.size >= marketsWithSupply.length; + + const handleClose = useCallback(() => { + onOpenChange(false); + setTimeout(() => { + setExcludedIds(new Set()); + setResult(null); + }, 200); + }, [onOpenChange]); + + const currentApr = result ? apyToApr(result.currentWeightedApy) : 0; + const projectedApr = result ? apyToApr(result.projectedWeightedApy) : 0; + const aprDiff = projectedApr - currentApr; + + const buildSummaryItems = useCallback((): TransactionSummaryItem[] => { + if (!result) return []; + const items: TransactionSummaryItem[] = [ + { + label: 'Weighted APR', + value: `${fmtApr(result.currentWeightedApy)} → ${fmtApr(result.projectedWeightedApy)}`, + detail: `(${aprDiff >= 0 ? '+' : ''}${(aprDiff * 100).toFixed(4)}%)`, + detailColor: aprDiff >= 0 ? 'positive' : 'negative', + }, + ]; + if (totalMoved > 0n) { + items.push({ + label: 'Capital moved', + value: `${fmt(totalMoved)} ${result.loanAssetSymbol}`, + }); + items.push({ + label: 'Fee (0.01%)', + value: `${fmt(feeAmount)} ${result.loanAssetSymbol}`, + }); + } + return items; + }, [result, aprDiff, totalMoved, feeAmount, fmt]); + + // Quick mode: auto-execute as soon as calculation completes + const quickFiredRef = useRef(false); + useEffect(() => { + if (!quickMode || !isOpen || isCalculating || !result || totalMoved === 0n) return; + if (quickFiredRef.current) return; + quickFiredRef.current = true; + void executeSmartRebalance(buildSummaryItems()).then((ok) => { + if (ok) handleClose(); + }); + }, [quickMode, isOpen, isCalculating, result, totalMoved, executeSmartRebalance, handleClose, buildSummaryItems]); + + // Reset quick-fired flag when modal closes + useEffect(() => { + if (!isOpen) quickFiredRef.current = false; + }, [isOpen]); + + return ( + { + if (!open) handleClose(); + }} + isDismissable={!isProcessing} + flexibleWidth + > + + Smart Rebalance {groupedPosition.loanAsset ?? 'Unknown'} + + } + description="Optimizes allocation across markets for maximum yield" + mainIcon={ + + } + onClose={!isProcessing ? handleClose : undefined} + /> + + + {/* Market selection table (hidden in quick mode) */} + {!quickMode && ( +
+ + + + + + + + + + + {marketsWithSupply.map((pos) => { + const isIncluded = !excludedIds.has(pos.market.uniqueKey); + const supplyAmount = BigInt(pos.state.supplyAssets); + const apy = pos.market.state?.supplyApy ? Number(pos.market.state.supplyApy) : 0; + + return ( + toggleMarket(pos.market.uniqueKey)} + > + + + + + + ); + })} + +
IncludeCollateralSupplyAPR
+ toggleMarket(pos.market.uniqueKey)} + onClick={(e) => e.stopPropagation()} + /> + +
+ + {pos.market.collateralAsset.symbol} +
+
+ + {fmt(supplyAmount)} {groupedPosition.loanAssetSymbol} + + + {fmtApr(apy)} +
+
+ )} + + {/* Loading indicator */} + {isCalculating && ( +
+ +

Calculating optimal allocation...

+
+ )} + + {/* Summary */} + {!isCalculating && result && ( +
+
+
Weighted APR
+
+ {fmtApr(result.currentWeightedApy)} + + {fmtApr(result.projectedWeightedApy)} + = 0 ? 'text-green-600' : 'text-red-500'}> + ({aprDiff >= 0 ? '+' : ''} + {(aprDiff * 100).toFixed(4)}%) + +
+
+ {totalMoved > 0n && ( +
+ Capital moved + + {fmt(totalMoved)} {result.loanAssetSymbol} + +
+ )} + {totalMoved > 0n && ( +
+ Fee (0.01%) + + {fmt(feeAmount)} {result.loanAssetSymbol} + +
+ )} +
+ )} + + {/* No result after calculation */} + {!isCalculating && !result && !allExcluded && ( +
No rebalancing needed — allocations are already optimal.
+ )} +
+ + + + void executeSmartRebalance(buildSummaryItems()).then((ok) => { if (ok) handleClose(); })} + isLoading={isProcessing} + disabled={isCalculating || !result || totalMoved === 0n} + className="rounded-sm p-4 px-10 font-zen text-white duration-200 ease-in-out hover:scale-105" + > + Execute 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 46a6205b..3d505b32 100644 --- a/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx +++ b/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx @@ -31,7 +31,6 @@ import { getGroupedEarnings, groupPositionsByLoanAsset, processCollaterals } fro import { convertApyToApr } from '@/utils/rateMath'; import { useTokenPrices } from '@/hooks/useTokenPrices'; import { getTokenPriceKey } from '@/data-sources/morpho-api/prices'; -import { calculateSmartRebalance, logSmartRebalanceResults } from '@/utils/smart-rebalance'; import { PositionActionsDropdown } from './position-actions-dropdown'; import { SuppliedMarketsDetail } from './supplied-markets-detail'; import { CollateralIconsDisplay } from './collateral-icons-display'; @@ -332,10 +331,6 @@ export function SuppliedMorphoBlueGroupedTable({ { - if (!isOwner) { - toast.error('No authorization', 'You can only rebalance your own positions'); - return; - } openModal('rebalance', { groupedPosition, refetch, @@ -343,20 +338,16 @@ export function SuppliedMorphoBlueGroupedTable({ }); }} onSmartRebalanceClick={() => { - if (!isOwner) { - toast.error('No authorization', 'You can only rebalance your own positions'); - return; - } - void calculateSmartRebalance(groupedPosition, groupedPosition.chainId as SupportedNetworks).then((result) => { - if (!result) { - toast.info('Nothing to rebalance', 'No rebalanceable positions found'); - return; - } - logSmartRebalanceResults(result); - toast.info('Smart Rebalance calculated', 'Check browser console for details'); - }).catch((err) => { - console.error('[smart-rebalance] Error:', err); - toast.error('Smart Rebalance failed', 'Check browser console for details'); + openModal('smartRebalance', { + groupedPosition, + chainId: groupedPosition.chainId as SupportedNetworks, + quickMode: true, + }); + }} + onSmartRebalanceConfigClick={() => { + openModal('smartRebalance', { + groupedPosition, + chainId: groupedPosition.chainId as SupportedNetworks, }); }} /> diff --git a/src/hooks/useSmartRebalance.ts b/src/hooks/useSmartRebalance.ts new file mode 100644 index 00000000..5bdabf43 --- /dev/null +++ b/src/hooks/useSmartRebalance.ts @@ -0,0 +1,368 @@ +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 type { GroupedPosition } from '@/utils/types'; +import type { SmartRebalanceResult, MarketDelta } from '@/utils/smart-rebalance'; +import { GAS_COSTS, GAS_MULTIPLIER_NUMERATOR, GAS_MULTIPLIER_DENOMINATOR } from '@/features/markets/components/constants'; +import type { TransactionSummaryItem } from '@/stores/useTransactionProcessStore'; +import { useERC20Approval } from './useERC20Approval'; +import { useBundlerAuthorizationStep } from './useBundlerAuthorizationStep'; +import { usePermit2 } from './usePermit2'; +import { useAppSettings } from '@/stores/useAppSettings'; +import { useStyledToast } from './useStyledToast'; + +const SMART_REBALANCE_FEE_BPS = 10n; // measured in tenths of a BPS. +const FEE_BPS_DENOMINATOR = 100_000n; +const FEE_RECIPIENT = '0xdb24a3611e7dd442c0fa80b32325ce92655e4eaf' as Address; + +export type SmartRebalanceStepType = + | 'idle' + | 'approve_permit2' + | 'authorize_bundler_sig' + | 'sign_permit' + | 'authorize_bundler_tx' + | 'approve_token' + | 'execute'; + +export const useSmartRebalance = ( + groupedPosition: GroupedPosition, + result: SmartRebalanceResult | null, +) => { + const [isProcessing, setIsProcessing] = useState(false); + const tracking = useTransactionTracking('smart-rebalance'); + + const { address: account } = useConnection(); + const bundlerAddress = getBundlerV2(groupedPosition.chainId); + const toast = useStyledToast(); + const { usePermit2: usePermit2Setting } = useAppSettings(); + + // Compute totalMoved from negative deltas (withdrawals) + const totalMoved = useMemo(() => { + if (!result) return 0n; + return result.deltas.reduce((acc, d) => { + if (d.delta < 0n) return acc + (-d.delta); + return acc; + }, 0n); + }, [result]); + + // Fee amount + const feeAmount = useMemo(() => { + return (totalMoved * SMART_REBALANCE_FEE_BPS) / FEE_BPS_DENOMINATOR; + }, [totalMoved]); + + // Hook for Morpho bundler authorization + const { isBundlerAuthorized, isAuthorizingBundler, ensureBundlerAuthorization, refetchIsBundlerAuthorized } = + useBundlerAuthorizationStep({ + chainId: groupedPosition.chainId, + bundlerAddress: bundlerAddress as Address, + }); + + // 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: totalMoved, + }); + + // Hook for standard ERC20 approval + const { + isApproved: isTokenApproved, + approve: approveToken, + isApproving: isTokenApproving, + } = useERC20Approval({ + token: groupedPosition.loanAssetAddress as Address, + spender: bundlerAddress, + amount: totalMoved, + tokenSymbol: groupedPosition.loanAsset, + chainId: groupedPosition.chainId, + }); + + const handleTransactionSuccess = useCallback(() => { + void refetchIsBundlerAuthorized(); + }, [refetchIsBundlerAuthorized]); + + const { sendTransactionAsync, isConfirming: isExecuting } = useTransactionWithToast({ + toastId: 'smart-rebalance', + pendingText: 'Smart rebalancing positions', + successText: 'Smart rebalance completed successfully', + errorText: 'Failed to smart rebalance positions', + chainId: groupedPosition.chainId, + onSuccess: handleTransactionSuccess, + }); + + // Generate withdraw/supply tx data from SmartRebalanceResult deltas + const generateSmartRebalanceTxData = useCallback(() => { + if (!result || !account) throw new Error('Missing result or account'); + + const withdrawTxs: `0x${string}`[] = []; + const supplyTxs: `0x${string}`[] = []; + + for (const d of result.deltas) { + if (d.delta >= 0n) continue; // skip supplies and zero deltas + + const withdrawAmount = -d.delta; + const market = d.market; + + 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), + }; + + // Smart rebalance always leaves dust, so never do a full shares-based withdrawal + const withdrawTx = encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'morphoWithdraw', + args: [ + marketParams, + withdrawAmount, + 0n, + maxUint256, + account, + ], + }); + withdrawTxs.push(withdrawTx); + } + + for (const d of result.deltas) { + if (d.delta <= 0n) continue; // skip withdrawals and zero deltas + + const market = d.market; + + 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), + }; + + // Reduce supply amount by fee share: targetDelta - (targetDelta * 10 / 10_000) + const reducedAmount = d.delta - (d.delta * SMART_REBALANCE_FEE_BPS) / FEE_BPS_DENOMINATOR; + + const supplyTx = encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'morphoSupply', + args: [ + marketParams, + reducedAmount, + 0n, + 1n, // minShares + account, + '0x', + ], + }); + supplyTxs.push(supplyTx); + } + + return { withdrawTxs, supplyTxs }; + }, [result, account, groupedPosition.markets]); + + // Helper to generate steps based on flow type + const getStepsForFlow = useCallback( + (isPermit2: boolean) => { + if (isPermit2) { + 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 the token transfer.' }, + { id: 'execute', title: 'Confirm Smart Rebalance', description: 'Confirm transaction in wallet to complete the smart rebalance.' }, + ]; + } + return [ + { id: 'authorize_bundler_tx', title: 'Authorize Morpho Bundler (Transaction)', description: 'Submit a transaction to authorize the Morpho bundler.' }, + { id: 'approve_token', title: `Approve ${groupedPosition.loanAsset}`, description: `Approve ${groupedPosition.loanAsset} for spending.` }, + { id: 'execute', title: 'Confirm Smart Rebalance', description: 'Confirm transaction in wallet to complete the smart rebalance.' }, + ]; + }, + [groupedPosition.loanAsset], + ); + + const executeSmartRebalance = useCallback(async (summaryItems?: TransactionSummaryItem[]) => { + if (!account || !result || totalMoved === 0n) { + toast.info('Nothing to rebalance', 'No moves to execute.'); + return; + } + + setIsProcessing(true); + const transactions: `0x${string}`[] = []; + + const initialStep = usePermit2Setting ? 'approve_permit2' : 'authorize_bundler_tx'; + tracking.start( + getStepsForFlow(usePermit2Setting), + { + title: 'Smart Rebalance', + description: `Smart rebalancing ${groupedPosition.loanAsset} positions`, + tokenSymbol: groupedPosition.loanAsset, + summaryItems, + }, + initialStep, + ); + + try { + const { withdrawTxs, supplyTxs } = generateSmartRebalanceTxData(); + + // Build fee sweep tx: erc20Transfer(asset, feeRecipient, maxUint256) to sweep all remaining + const feeTransferTx = encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'erc20Transfer', + args: [groupedPosition.loanAssetAddress as Address, FEE_RECIPIENT, maxUint256], + }); + + let multicallGas: bigint | undefined = undefined; + + if (usePermit2Setting) { + // --- Permit2 Flow --- + tracking.update('approve_permit2'); + if (!permit2Authorized) { + await authorizePermit2(); + await new Promise((resolve) => setTimeout(resolve, 800)); + } + + tracking.update('authorize_bundler_sig'); + const { authorizationTxData } = await ensureBundlerAuthorization({ mode: 'signature' }); + if (authorizationTxData) { + transactions.push(authorizationTxData); + await new Promise((resolve) => setTimeout(resolve, 800)); + } + + tracking.update('sign_permit'); + const { sigs, permitSingle } = await signForBundlers(); + const permitTx = encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'approve2', + args: [permitSingle, sigs, false], + }); + const transferFromTx = encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'transferFrom2', + args: [groupedPosition.loanAssetAddress as Address, totalMoved], + }); + + // Bundle order: auth → withdraws → transferFrom → supplies → fee sweep + transactions.push(permitTx); + transactions.push(...withdrawTxs); + transactions.push(transferFromTx); + transactions.push(...supplyTxs); + transactions.push(feeTransferTx); + } else { + // --- Standard ERC20 Flow --- + tracking.update('authorize_bundler_tx'); + const { authorized } = await ensureBundlerAuthorization({ mode: 'transaction' }); + if (!authorized) { + throw new Error('Failed to authorize Bundler via transaction.'); + } + + tracking.update('approve_token'); + if (!isTokenApproved) { + await approveToken(); + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + + const erc20TransferFromTx = encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'erc20TransferFrom', + args: [groupedPosition.loanAssetAddress as Address, totalMoved], + }); + + // Bundle order: withdraws → transferFrom → supplies → fee sweep + transactions.push(...withdrawTxs); + transactions.push(erc20TransferFromTx); + transactions.push(...supplyTxs); + transactions.push(feeTransferTx); + + // Estimate gas + 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); + } + } + + // 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, + }); + + tracking.complete(); + return true; + } catch (error) { + console.error('Error during smart rebalance:', error); + tracking.fail(); + + if (error instanceof Error && !error.message.toLowerCase().includes('rejected')) { + toast.error('Smart Rebalance Failed', 'An unexpected error occurred during smart rebalance.'); + } + } finally { + setIsProcessing(false); + } + }, [ + account, + result, + totalMoved, + usePermit2Setting, + permit2Authorized, + authorizePermit2, + ensureBundlerAuthorization, + signForBundlers, + isTokenApproved, + approveToken, + generateSmartRebalanceTxData, + sendTransactionAsync, + bundlerAddress, + groupedPosition.chainId, + groupedPosition.loanAssetAddress, + groupedPosition.loanAsset, + toast, + tracking, + getStepsForFlow, + ]); + + const isLoading = isProcessing || isLoadingPermit2 || isTokenApproving || isAuthorizingBundler || isExecuting; + + return { + executeSmartRebalance, + isProcessing: isLoading, + totalMoved, + feeAmount, + transaction: tracking.transaction, + dismiss: tracking.dismiss, + currentStep: tracking.currentStep as SmartRebalanceStepType | null, + }; +}; diff --git a/src/modals/registry.tsx b/src/modals/registry.tsx index d55edd0c..521f55e2 100644 --- a/src/modals/registry.tsx +++ b/src/modals/registry.tsx @@ -29,6 +29,10 @@ const RebalanceMarketSelectionModal = lazy(() => import('@/features/markets/components/market-selection-modal').then((m) => ({ default: m.MarketSelectionModal })), ); +const SmartRebalanceModal = lazy(() => + import('@/features/positions/components/rebalance/smart-rebalance-modal').then((m) => ({ default: m.SmartRebalanceModal })), +); + // Settings & Configuration const MarketSettingsModal = lazy(() => import('@/features/markets/components/market-settings-modal')); @@ -55,6 +59,7 @@ export const MODAL_REGISTRY: { supply: SupplyModalV2, rebalance: RebalanceModal, rebalanceMarketSelection: RebalanceMarketSelectionModal, + smartRebalance: SmartRebalanceModal, marketSettings: MarketSettingsModal, monarchSettings: MonarchSettingsModal, vaultDeposit: VaultDepositModal, diff --git a/src/stores/useModalStore.ts b/src/stores/useModalStore.ts index 874801bb..4c68cf13 100644 --- a/src/stores/useModalStore.ts +++ b/src/stores/useModalStore.ts @@ -57,6 +57,12 @@ export type ModalProps = { onSelect: (markets: Market[]) => void; }; + smartRebalance: { + groupedPosition: GroupedPosition; + chainId: SupportedNetworks; + quickMode?: boolean; + }; + // Settings & Configuration marketSettings: { zIndex?: 'settings' | 'top'; // Override z-index when opened from nested modals diff --git a/src/stores/useTransactionProcessStore.ts b/src/stores/useTransactionProcessStore.ts index 82bc0658..28c63d77 100644 --- a/src/stores/useTransactionProcessStore.ts +++ b/src/stores/useTransactionProcessStore.ts @@ -6,6 +6,15 @@ export type TransactionStep = { description: string; }; +export type TransactionSummaryItem = { + 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 +22,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 = { diff --git a/src/utils/smart-rebalance.ts b/src/utils/smart-rebalance.ts index 65ab03ab..a4c9b0e0 100644 --- a/src/utils/smart-rebalance.ts +++ b/src/utils/smart-rebalance.ts @@ -8,8 +8,9 @@ import { formatBalance, formatReadable } from './balance'; // --- Config --- -const MAX_ROUNDS = 10_000; +const MAX_ROUNDS = 50; const LOG_TAG = '[smart-rebalance]'; +const DUST_AMOUNT = 1000n; // Leave dust in markets to not remove them from future rebalances // --- Types --- @@ -138,7 +139,9 @@ export async function calculateSmartRebalance( const userSupply = BigInt(pos.state.supplyAssets); const liquidity = baselineMarket.liquidity; - const maxWithdrawable = userSupply < liquidity ? userSupply : liquidity; + // Leave DUST_AMOUNT in each market so the position persists for future rebalances + const maxFromUser = userSupply > DUST_AMOUNT ? userSupply - DUST_AMOUNT : 0n; + const maxWithdrawable = maxFromUser < liquidity ? maxFromUser : liquidity; entries.push({ uniqueKey: pos.market.uniqueKey, @@ -157,13 +160,7 @@ export async function calculateSmartRebalance( for (const e of entries) totalMoveable += e.maxWithdrawable; if (totalMoveable === 0n) return null; - // 5. Determine chunk size: 1% of portfolio, capped at $10 worth - const dollarCap = 10n * 10n ** BigInt(groupedPosition.loanAssetDecimals); - const onePercent = totalMoveable / 100n; - const chunk = onePercent < dollarCap ? onePercent : dollarCap; - if (chunk === 0n) return null; - - // 6. Initialize working state + // 5. Initialize working state // - `allocations` tracks the target amount per market (starts as current) // - `simMarkets` tracks the simulated BlueMarket state reflecting moves const allocations = new Map(); @@ -175,18 +172,63 @@ export async function calculateSmartRebalance( } // Log initial state - console.log(`${LOG_TAG} chunk=${chunk}, totalMoveable=${totalMoveable}, maxRounds=${MAX_ROUNDS}, markets=${entries.length}`); + console.log(`${LOG_TAG} totalMoveable=${totalMoveable}, maxRounds=${MAX_ROUNDS}, markets=${entries.length}`); for (let i = 0; i < entries.length; i++) { const e = entries[i]; console.log(` ${e.collateralSymbol}: supply=${e.currentSupply}, withdrawable=${e.maxWithdrawable}, apy=${(simMarkets[i].supplyApy * 100).toFixed(4)}%`); } - // 7. Greedy hill-climb: try every (src→dst) move of `chunk`, keep the best one per round + // 6. Multi-scale optimizer: for each (src→dst) pair, evaluate multiple transfer + // sizes to capture non-linear APY spikes (e.g. utilization approaching 100%). + // The old chunk-by-chunk greedy approach missed large beneficial moves because + // each small step looked worse individually. + + /** + * Simulate moving `amount` from simMarkets[src] to simMarkets[dst] and return + * the resulting weighted APY without mutating state. + */ + function evaluateMove( + src: number, + dst: number, + amount: bigint, + ): { apy: number; srcMarket: BlueMarket; dstMarket: BlueMarket } { + // Simulate cumulative withdrawal/supply from current sim state + const srcAfter = simMarkets[src].withdraw(amount, 0n).market; + const dstAfter = simMarkets[dst].supply(amount, 0n).market; + + // Temporarily apply + const prevSrc = simMarkets[src]; + const prevDst = simMarkets[dst]; + simMarkets[src] = srcAfter; + simMarkets[dst] = dstAfter; + + const srcKey = entries[src].uniqueKey; + const dstKey = entries[dst].uniqueKey; + const prevSrcAlloc = allocations.get(srcKey)!; + const prevDstAlloc = allocations.get(dstKey)!; + allocations.set(srcKey, prevSrcAlloc - amount); + allocations.set(dstKey, prevDstAlloc + amount); + + const apy = weightedApy(entries, allocations, simMarkets); + + // Revert + simMarkets[src] = prevSrc; + simMarkets[dst] = prevDst; + allocations.set(srcKey, prevSrcAlloc); + allocations.set(dstKey, prevDstAlloc); + + return { apy, srcMarket: srcAfter, dstMarket: dstAfter }; + } + + // Transfer size fractions to evaluate + const FRACTIONS = [0.02, 0.05, 0.10, 0.15, 0.20, 0.30, 0.40, 0.50, 0.60, 0.70, 0.80, 0.90, 1.0]; + for (let round = 0; round < MAX_ROUNDS; round++) { const currentApy = weightedApy(entries, allocations, simMarkets); let bestSrc = -1; let bestDst = -1; + let bestAmount = 0n; let bestApy = currentApy; let bestSrcMarket: BlueMarket | null = null; let bestDstMarket: BlueMarket | null = null; @@ -195,53 +237,51 @@ export async function calculateSmartRebalance( const srcKey = entries[src].uniqueKey; const srcAlloc = allocations.get(srcKey)!; - // Can't withdraw more than allocated - if (srcAlloc < chunk) continue; - - // Can't withdraw more than what's actually withdrawable from the original position + // How much can we still withdraw from this source? const alreadyWithdrawn = entries[src].currentSupply - srcAlloc; - if (alreadyWithdrawn + chunk > entries[src].maxWithdrawable) continue; + const remainingWithdrawable = entries[src].maxWithdrawable - alreadyWithdrawn; + if (remainingWithdrawable <= 0n) continue; - // Simulate withdrawal from source - const srcAfter = simMarkets[src].withdraw(chunk, 0n).market; + // Also can't withdraw more than current allocation (minus dust) + const maxFromAlloc = srcAlloc > DUST_AMOUNT ? srcAlloc - DUST_AMOUNT : 0n; + const maxMove = remainingWithdrawable < maxFromAlloc ? remainingWithdrawable : maxFromAlloc; + if (maxMove <= 0n) continue; for (let dst = 0; dst < entries.length; dst++) { if (dst === src) continue; - // Simulate supply to destination - const dstAfter = simMarkets[dst].supply(chunk, 0n).market; - - // Temporarily apply to compute weighted APY - const prevSrcMarket = simMarkets[src]; - const prevDstMarket = simMarkets[dst]; - simMarkets[src] = srcAfter; - simMarkets[dst] = dstAfter; - - const prevSrcAlloc = allocations.get(srcKey)!; - const dstKey = entries[dst].uniqueKey; - const prevDstAlloc = allocations.get(dstKey)!; - allocations.set(srcKey, prevSrcAlloc - chunk); - allocations.set(dstKey, prevDstAlloc + chunk); - - const candidateApy = weightedApy(entries, allocations, simMarkets); - - // Revert - simMarkets[src] = prevSrcMarket; - simMarkets[dst] = prevDstMarket; - allocations.set(srcKey, prevSrcAlloc); - allocations.set(dstKey, prevDstAlloc); + // Evaluate multiple transfer sizes for this pair + for (const frac of FRACTIONS) { + let amount = BigInt(Math.floor(Number(maxMove) * frac)); + if (amount <= 0n) continue; + // Clamp to maxMove + if (amount > maxMove) amount = maxMove; + + const result = evaluateMove(src, dst, amount); + if (result.apy > bestApy) { + bestApy = result.apy; + bestSrc = src; + bestDst = dst; + bestAmount = amount; + bestSrcMarket = result.srcMarket; + bestDstMarket = result.dstMarket; + } + } - if (candidateApy > bestApy) { - bestApy = candidateApy; + // Always also try the exact max + const resultMax = evaluateMove(src, dst, maxMove); + if (resultMax.apy > bestApy) { + bestApy = resultMax.apy; bestSrc = src; bestDst = dst; - bestSrcMarket = srcAfter; - bestDstMarket = dstAfter; + bestAmount = maxMove; + bestSrcMarket = resultMax.srcMarket; + bestDstMarket = resultMax.dstMarket; } } } - // No move improves weighted APY — we're done + // No move improves weighted APY — converged if (bestSrc === -1 || !bestSrcMarket || !bestDstMarket) { console.log(`${LOG_TAG} round ${round}: converged. weighted APY=${(currentApy * 100).toFixed(6)}%`); break; @@ -253,15 +293,13 @@ export async function calculateSmartRebalance( simMarkets[bestSrc] = bestSrcMarket; simMarkets[bestDst] = bestDstMarket; - allocations.set(srcKey, allocations.get(srcKey)! - chunk); - allocations.set(dstKey, allocations.get(dstKey)! + chunk); - - if (round < 5 || round % 500 === 0) { - console.log( - `${LOG_TAG} round ${round}: ${entries[bestSrc].collateralSymbol}→${entries[bestDst].collateralSymbol} ` + - `| weighted APY: ${(currentApy * 100).toFixed(6)}%→${(bestApy * 100).toFixed(6)}%`, - ); - } + allocations.set(srcKey, allocations.get(srcKey)! - bestAmount); + allocations.set(dstKey, allocations.get(dstKey)! + bestAmount); + + console.log( + `${LOG_TAG} round ${round}: ${entries[bestSrc].collateralSymbol}→${entries[bestDst].collateralSymbol} ` + + `amount=${bestAmount} | weighted APY: ${(currentApy * 100).toFixed(6)}%→${(bestApy * 100).toFixed(6)}%`, + ); } // 8. Build result deltas From b07961069bd8b19679c74e2d8db90096c786a94c Mon Sep 17 00:00:00 2001 From: secretmemelocker Date: Sun, 1 Mar 2026 10:09:46 -0600 Subject: [PATCH 04/10] Code rabbit fixes by Claude --- .../components/position-actions-dropdown.tsx | 1 + .../rebalance/smart-rebalance-modal.tsx | 21 +-- .../supplied-morpho-blue-grouped-table.tsx | 4 +- src/hooks/useSmartRebalance.ts | 40 ++++- src/stores/useModalStore.ts | 1 - src/utils/smart-rebalance.ts | 166 +++++++++--------- 6 files changed, 128 insertions(+), 105 deletions(-) diff --git a/src/features/positions/components/position-actions-dropdown.tsx b/src/features/positions/components/position-actions-dropdown.tsx index 54ed8a7c..5e7b5d5f 100644 --- a/src/features/positions/components/position-actions-dropdown.tsx +++ b/src/features/positions/components/position-actions-dropdown.tsx @@ -58,6 +58,7 @@ export function PositionActionsDropdown({ onRebalanceClick, onSmartRebalanceClic diff --git a/src/features/positions/components/rebalance/smart-rebalance-modal.tsx b/src/features/positions/components/rebalance/smart-rebalance-modal.tsx index e6a73fef..53337cb6 100644 --- a/src/features/positions/components/rebalance/smart-rebalance-modal.tsx +++ b/src/features/positions/components/rebalance/smart-rebalance-modal.tsx @@ -11,26 +11,20 @@ import { calculateSmartRebalance } from '@/utils/smart-rebalance'; import type { SmartRebalanceResult } from '@/utils/smart-rebalance'; import type { TransactionSummaryItem } from '@/stores/useTransactionProcessStore'; import type { GroupedPosition } from '@/utils/types'; -import type { SupportedNetworks } from '@/utils/networks'; +import { convertApyToApr } from '@/utils/rateMath'; type SmartRebalanceModalProps = { groupedPosition: GroupedPosition; - chainId: SupportedNetworks; isOpen: boolean; onOpenChange: (open: boolean) => void; quickMode?: boolean; }; -function apyToApr(apy: number): number { - if (apy <= 0) return 0; - return Math.log(1 + apy); -} - function fmtApr(apy: number): string { - return `${(apyToApr(apy) * 100).toFixed(2)}%`; + return `${(convertApyToApr(apy) * 100).toFixed(2)}%`; } -export function SmartRebalanceModal({ groupedPosition, chainId, isOpen, onOpenChange, quickMode }: SmartRebalanceModalProps) { +export function SmartRebalanceModal({ groupedPosition, isOpen, onOpenChange, quickMode }: SmartRebalanceModalProps) { // Markets with supply > 0 const marketsWithSupply = useMemo( () => @@ -72,7 +66,7 @@ export function SmartRebalanceModal({ groupedPosition, chainId, isOpen, onOpenCh const id = ++calcIdRef.current; setIsCalculating(true); - void calculateSmartRebalance(groupedPosition, chainId, excludedIds.size > 0 ? excludedIds : undefined) + void calculateSmartRebalance(groupedPosition, groupedPosition.chainId, excludedIds.size > 0 ? excludedIds : undefined) .then((res) => { if (id !== calcIdRef.current) return; setResult(res); @@ -86,8 +80,7 @@ export function SmartRebalanceModal({ groupedPosition, chainId, isOpen, onOpenCh if (id !== calcIdRef.current) return; setIsCalculating(false); }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isOpen, excludedKey, chainId]); + }, [isOpen, excludedKey, groupedPosition]); const toggleMarket = useCallback((uniqueKey: string) => { setExcludedIds((prev) => { @@ -111,8 +104,8 @@ export function SmartRebalanceModal({ groupedPosition, chainId, isOpen, onOpenCh }, 200); }, [onOpenChange]); - const currentApr = result ? apyToApr(result.currentWeightedApy) : 0; - const projectedApr = result ? apyToApr(result.projectedWeightedApy) : 0; + const currentApr = result ? convertApyToApr(result.currentWeightedApy) : 0; + const projectedApr = result ? convertApyToApr(result.projectedWeightedApy) : 0; const aprDiff = projectedApr - currentApr; const buildSummaryItems = useCallback((): TransactionSummaryItem[] => { 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 3d505b32..5c6eae2c 100644 --- a/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx +++ b/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx @@ -26,7 +26,7 @@ import { useRateLabel } from '@/hooks/useRateLabel'; import { useStyledToast } from '@/hooks/useStyledToast'; import type { EarningsPeriod } from '@/hooks/useUserPositionsSummaryData'; import { formatReadable, formatBalance } from '@/utils/balance'; -import { getNetworkImg, SupportedNetworks } from '@/utils/networks'; +import { getNetworkImg } from '@/utils/networks'; import { getGroupedEarnings, groupPositionsByLoanAsset, processCollaterals } from '@/utils/positions'; import { convertApyToApr } from '@/utils/rateMath'; import { useTokenPrices } from '@/hooks/useTokenPrices'; @@ -340,14 +340,12 @@ export function SuppliedMorphoBlueGroupedTable({ onSmartRebalanceClick={() => { openModal('smartRebalance', { groupedPosition, - chainId: groupedPosition.chainId as SupportedNetworks, quickMode: true, }); }} onSmartRebalanceConfigClick={() => { openModal('smartRebalance', { groupedPosition, - chainId: groupedPosition.chainId as SupportedNetworks, }); }} /> diff --git a/src/hooks/useSmartRebalance.ts b/src/hooks/useSmartRebalance.ts index 5bdabf43..df1c3e93 100644 --- a/src/hooks/useSmartRebalance.ts +++ b/src/hooks/useSmartRebalance.ts @@ -1,5 +1,5 @@ import { useState, useCallback, useMemo } from 'react'; -import { type Address, encodeFunctionData, maxUint256 } from 'viem'; +import { type Address, encodeFunctionData, maxUint256, zeroAddress } from 'viem'; import { useConnection } from 'wagmi'; import morphoBundlerAbi from '@/abis/bundlerV2'; import { useTransactionWithToast } from '@/hooks/useTransactionWithToast'; @@ -37,6 +37,7 @@ export const useSmartRebalance = ( const { address: account } = useConnection(); const bundlerAddress = getBundlerV2(groupedPosition.chainId); + const hasBundler = bundlerAddress !== zeroAddress; const toast = useStyledToast(); const { usePermit2: usePermit2Setting } = useAppSettings(); @@ -68,7 +69,7 @@ export const useSmartRebalance = ( signForBundlers, isLoading: isLoadingPermit2, } = usePermit2({ - user: account as Address, + user: account, spender: bundlerAddress, token: groupedPosition.loanAssetAddress as Address, refetchInterval: 10_000, @@ -207,10 +208,21 @@ export const useSmartRebalance = ( return; } + if (!hasBundler) { + toast.error('Unsupported chain', 'Smart rebalance is not available on this chain.'); + return; + } + setIsProcessing(true); const transactions: `0x${string}`[] = []; - const initialStep = usePermit2Setting ? 'approve_permit2' : 'authorize_bundler_tx'; + const initialStep = usePermit2Setting + ? (permit2Authorized + ? (isBundlerAuthorized ? 'sign_permit' : 'authorize_bundler_sig') + : 'approve_permit2') + : (isBundlerAuthorized + ? (isTokenApproved ? 'execute' : 'approve_token') + : 'authorize_bundler_tx'); tracking.start( getStepsForFlow(usePermit2Setting), { @@ -322,11 +334,27 @@ export const useSmartRebalance = ( tracking.complete(); return true; - } catch (error) { + } catch (error: unknown) { console.error('Error during smart rebalance:', error); tracking.fail(); - if (error instanceof Error && !error.message.toLowerCase().includes('rejected')) { + const isUserRejection = (() => { + if (error && typeof error === 'object') { + const err = error as Record; + if (err.code === 4001 || err.code === 'ACTION_REJECTED') return true; + const msg = typeof err.message === 'string' ? err.message : ''; + if (/user rejected|user denied|request has been rejected/i.test(msg)) return true; + const nested = (err.data as Record)?.originalError as Record | undefined; + if (nested?.code === 4001) return true; + const cause = err.cause as Record | undefined; + if (cause?.code === 4001 || cause?.code === 'ACTION_REJECTED') return true; + } + return false; + })(); + + if (isUserRejection) { + toast.error('Transaction Rejected', 'User rejected transaction.'); + } else { toast.error('Smart Rebalance Failed', 'An unexpected error occurred during smart rebalance.'); } } finally { @@ -336,8 +364,10 @@ export const useSmartRebalance = ( account, result, totalMoved, + hasBundler, usePermit2Setting, permit2Authorized, + isBundlerAuthorized, authorizePermit2, ensureBundlerAuthorization, signForBundlers, diff --git a/src/stores/useModalStore.ts b/src/stores/useModalStore.ts index 4c68cf13..eb74bc18 100644 --- a/src/stores/useModalStore.ts +++ b/src/stores/useModalStore.ts @@ -59,7 +59,6 @@ export type ModalProps = { smartRebalance: { groupedPosition: GroupedPosition; - chainId: SupportedNetworks; quickMode?: boolean; }; diff --git a/src/utils/smart-rebalance.ts b/src/utils/smart-rebalance.ts index a4c9b0e0..5967629b 100644 --- a/src/utils/smart-rebalance.ts +++ b/src/utils/smart-rebalance.ts @@ -2,6 +2,7 @@ import { Market as BlueMarket, MarketParams as BlueMarketParams } from '@morpho- import morphoABI from '@/abis/morpho'; import { getMorphoAddress } from '@/utils/morpho'; import { getClient } from '@/utils/rpc'; +import { convertApyToApr } from '@/utils/rateMath'; import type { SupportedNetworks } from '@/utils/networks'; import type { GroupedPosition, Market } from './types'; import { formatBalance, formatReadable } from './balance'; @@ -11,6 +12,11 @@ import { formatBalance, formatReadable } from './balance'; const MAX_ROUNDS = 50; const LOG_TAG = '[smart-rebalance]'; const DUST_AMOUNT = 1000n; // Leave dust in markets to not remove them from future rebalances +const DEBUG = process.env.NODE_ENV === 'development'; + +function debugLog(...args: unknown[]) { + if (DEBUG) console.log(...args); +} // --- Types --- @@ -49,11 +55,6 @@ export type SmartRebalanceResult = { // --- Helpers --- -function apyToApr(apy: number): number { - if (apy <= 0) return 0; - return Math.log(1 + apy); -} - function utilizationOf(market: BlueMarket): number { return Number(market.utilization) / 1e18; } @@ -62,12 +63,15 @@ function utilizationOf(market: BlueMarket): number { * Compute weighted APY across all markets given allocations and simulated market states. * Uses raw bigint amounts as weights so we don't lose precision. */ -function weightedApy(entries: MarketEntry[], allocations: Map, markets: BlueMarket[]): number { +function weightedApy(entries: MarketEntry[], allocations: Map, marketMap: Map): number { let weightedSum = 0; let totalWeight = 0; - for (let i = 0; i < entries.length; i++) { - const amount = Number(allocations.get(entries[i].uniqueKey) ?? 0n); - weightedSum += amount * markets[i].supplyApy; + for (const entry of entries) { + const amount = Number(allocations.get(entry.uniqueKey) ?? 0n); + if (amount <= 0) continue; + const market = marketMap.get(entry.uniqueKey); + if (!market) continue; + weightedSum += amount * market.supplyApy; totalWeight += amount; } return totalWeight > 0 ? weightedSum / totalWeight : 0; @@ -162,20 +166,20 @@ export async function calculateSmartRebalance( // 5. Initialize working state // - `allocations` tracks the target amount per market (starts as current) - // - `simMarkets` tracks the simulated BlueMarket state reflecting moves + // - `simMarketMap` tracks the simulated BlueMarket state reflecting moves const allocations = new Map(); - const simMarkets: BlueMarket[] = []; + const simMarketMap = new Map(); for (const entry of entries) { allocations.set(entry.uniqueKey, entry.currentSupply); - simMarkets.push(entry.baselineMarket); + simMarketMap.set(entry.uniqueKey, entry.baselineMarket); } // Log initial state - console.log(`${LOG_TAG} totalMoveable=${totalMoveable}, maxRounds=${MAX_ROUNDS}, markets=${entries.length}`); - for (let i = 0; i < entries.length; i++) { - const e = entries[i]; - console.log(` ${e.collateralSymbol}: supply=${e.currentSupply}, withdrawable=${e.maxWithdrawable}, apy=${(simMarkets[i].supplyApy * 100).toFixed(4)}%`); + debugLog(`${LOG_TAG} totalMoveable=${totalMoveable}, maxRounds=${MAX_ROUNDS}, markets=${entries.length}`); + for (const e of entries) { + const sim = simMarketMap.get(e.uniqueKey); + debugLog(` ${e.collateralSymbol}: supply=${e.currentSupply}, withdrawable=${e.maxWithdrawable}, apy=${sim ? (sim.supplyApy * 100).toFixed(4) : '?'}%`); } // 6. Multi-scale optimizer: for each (src→dst) pair, evaluate multiple transfer @@ -184,7 +188,7 @@ export async function calculateSmartRebalance( // each small step looked worse individually. /** - * Simulate moving `amount` from simMarkets[src] to simMarkets[dst] and return + * Simulate moving `amount` from src to dst and return * the resulting weighted APY without mutating state. */ function evaluateMove( @@ -192,39 +196,44 @@ export async function calculateSmartRebalance( dst: number, amount: bigint, ): { apy: number; srcMarket: BlueMarket; dstMarket: BlueMarket } { + const srcKey = entries[src].uniqueKey; + const dstKey = entries[dst].uniqueKey; + const srcSim = simMarketMap.get(srcKey)!; + const dstSim = simMarketMap.get(dstKey)!; + // Simulate cumulative withdrawal/supply from current sim state - const srcAfter = simMarkets[src].withdraw(amount, 0n).market; - const dstAfter = simMarkets[dst].supply(amount, 0n).market; + const srcAfter = srcSim.withdraw(amount, 0n).market; + const dstAfter = dstSim.supply(amount, 0n).market; // Temporarily apply - const prevSrc = simMarkets[src]; - const prevDst = simMarkets[dst]; - simMarkets[src] = srcAfter; - simMarkets[dst] = dstAfter; + simMarketMap.set(srcKey, srcAfter); + simMarketMap.set(dstKey, dstAfter); - const srcKey = entries[src].uniqueKey; - const dstKey = entries[dst].uniqueKey; - const prevSrcAlloc = allocations.get(srcKey)!; - const prevDstAlloc = allocations.get(dstKey)!; + const prevSrcAlloc = allocations.get(srcKey) ?? 0n; + const prevDstAlloc = allocations.get(dstKey) ?? 0n; allocations.set(srcKey, prevSrcAlloc - amount); allocations.set(dstKey, prevDstAlloc + amount); - const apy = weightedApy(entries, allocations, simMarkets); + const apy = weightedApy(entries, allocations, simMarketMap); // Revert - simMarkets[src] = prevSrc; - simMarkets[dst] = prevDst; + simMarketMap.set(srcKey, srcSim); + simMarketMap.set(dstKey, dstSim); allocations.set(srcKey, prevSrcAlloc); allocations.set(dstKey, prevDstAlloc); return { apy, srcMarket: srcAfter, dstMarket: dstAfter }; } - // Transfer size fractions to evaluate - const FRACTIONS = [0.02, 0.05, 0.10, 0.15, 0.20, 0.30, 0.40, 0.50, 0.60, 0.70, 0.80, 0.90, 1.0]; + // Transfer size fractions as rational pairs [numerator, denominator] for BigInt precision + const FRACTION_RATIONALS: [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], + ]; for (let round = 0; round < MAX_ROUNDS; round++) { - const currentApy = weightedApy(entries, allocations, simMarkets); + const currentApy = weightedApy(entries, allocations, simMarketMap); let bestSrc = -1; let bestDst = -1; @@ -235,7 +244,7 @@ export async function calculateSmartRebalance( for (let src = 0; src < entries.length; src++) { const srcKey = entries[src].uniqueKey; - const srcAlloc = allocations.get(srcKey)!; + const srcAlloc = allocations.get(srcKey) ?? 0n; // How much can we still withdraw from this source? const alreadyWithdrawn = entries[src].currentSupply - srcAlloc; @@ -251,10 +260,9 @@ export async function calculateSmartRebalance( if (dst === src) continue; // Evaluate multiple transfer sizes for this pair - for (const frac of FRACTIONS) { - let amount = BigInt(Math.floor(Number(maxMove) * frac)); + for (const [num, den] of FRACTION_RATIONALS) { + let amount = (maxMove * num) / den; if (amount <= 0n) continue; - // Clamp to maxMove if (amount > maxMove) amount = maxMove; const result = evaluateMove(src, dst, amount); @@ -267,23 +275,12 @@ export async function calculateSmartRebalance( bestDstMarket = result.dstMarket; } } - - // Always also try the exact max - const resultMax = evaluateMove(src, dst, maxMove); - if (resultMax.apy > bestApy) { - bestApy = resultMax.apy; - bestSrc = src; - bestDst = dst; - bestAmount = maxMove; - bestSrcMarket = resultMax.srcMarket; - bestDstMarket = resultMax.dstMarket; - } } } // No move improves weighted APY — converged if (bestSrc === -1 || !bestSrcMarket || !bestDstMarket) { - console.log(`${LOG_TAG} round ${round}: converged. weighted APY=${(currentApy * 100).toFixed(6)}%`); + debugLog(`${LOG_TAG} round ${round}: converged. weighted APY=${(currentApy * 100).toFixed(6)}%`); break; } @@ -291,21 +288,22 @@ export async function calculateSmartRebalance( const srcKey = entries[bestSrc].uniqueKey; const dstKey = entries[bestDst].uniqueKey; - simMarkets[bestSrc] = bestSrcMarket; - simMarkets[bestDst] = bestDstMarket; - allocations.set(srcKey, allocations.get(srcKey)! - bestAmount); - allocations.set(dstKey, allocations.get(dstKey)! + bestAmount); + simMarketMap.set(srcKey, bestSrcMarket); + simMarketMap.set(dstKey, bestDstMarket); + allocations.set(srcKey, (allocations.get(srcKey) ?? 0n) - bestAmount); + allocations.set(dstKey, (allocations.get(dstKey) ?? 0n) + bestAmount); - console.log( + debugLog( `${LOG_TAG} round ${round}: ${entries[bestSrc].collateralSymbol}→${entries[bestDst].collateralSymbol} ` + `amount=${bestAmount} | weighted APY: ${(currentApy * 100).toFixed(6)}%→${(bestApy * 100).toFixed(6)}%`, ); } // 8. Build result deltas - const deltas: MarketDelta[] = entries.map((entry, i) => { + const deltas: MarketDelta[] = entries.map((entry) => { const current = entry.currentSupply; - const target = allocations.get(entry.uniqueKey)!; + const target = allocations.get(entry.uniqueKey) ?? 0n; + const simMarket = simMarketMap.get(entry.uniqueKey); return { market: entry.originalMarket, @@ -313,9 +311,9 @@ export async function calculateSmartRebalance( targetAmount: target, delta: target - current, currentApy: entry.baselineMarket.supplyApy, - projectedApy: simMarkets[i].supplyApy, + projectedApy: simMarket?.supplyApy ?? entry.baselineMarket.supplyApy, currentUtilization: utilizationOf(entry.baselineMarket), - projectedUtilization: utilizationOf(simMarkets[i]), + projectedUtilization: simMarket ? utilizationOf(simMarket) : utilizationOf(entry.baselineMarket), collateralSymbol: entry.collateralSymbol, }; }); @@ -333,7 +331,7 @@ export async function calculateSmartRebalance( : 0; return { - deltas: deltas.sort((a, b) => Number(b.delta - a.delta)), + deltas: deltas.sort((a, b) => (b.delta > a.delta ? 1 : b.delta < a.delta ? -1 : 0)), totalPool, currentWeightedApy, projectedWeightedApy, @@ -345,34 +343,38 @@ export async function calculateSmartRebalance( // --- Logging --- export function logSmartRebalanceResults(result: SmartRebalanceResult): void { + if (!DEBUG) return; + const { deltas, totalPool, currentWeightedApy, projectedWeightedApy, loanAssetSymbol, loanAssetDecimals } = result; const fmt = (val: bigint) => formatReadable(formatBalance(val, loanAssetDecimals)); - const fmtApr = (apy: number) => `${(apyToApr(apy) * 100).toFixed(2)}%`; + const fmtApr = (apy: number) => `${(convertApyToApr(apy) * 100).toFixed(2)}%`; const fmtUtil = (u: number) => `${(u * 100).toFixed(1)}%`; - console.log('\n=== Smart Rebalance Results (fresh on-chain data) ==='); - console.log(`Asset: ${loanAssetSymbol} | Total: ${fmt(totalPool)} ${loanAssetSymbol}`); - console.log(''); - - console.table( - deltas.map((d) => ({ - Collateral: d.collateralSymbol, - Current: `${fmt(d.currentAmount)} ${loanAssetSymbol}`, - Target: `${fmt(d.targetAmount)} ${loanAssetSymbol}`, - Delta: `${Number(d.delta) >= 0 ? '+' : ''}${fmt(d.delta)} ${loanAssetSymbol}`, - 'Util Now': fmtUtil(d.currentUtilization), - 'Util After': fmtUtil(d.projectedUtilization), - 'APR Now': fmtApr(d.currentApy), - 'APR After': fmtApr(d.projectedApy), - 'Market ID': `${d.market.uniqueKey.slice(0, 10)}...`, - })), - ); + debugLog('\n=== Smart Rebalance Results (fresh on-chain data) ==='); + debugLog(`Asset: ${loanAssetSymbol} | Total: ${fmt(totalPool)} ${loanAssetSymbol}`); + debugLog(''); + + if (DEBUG) { + console.table( + deltas.map((d) => ({ + Collateral: d.collateralSymbol, + Current: `${fmt(d.currentAmount)} ${loanAssetSymbol}`, + Target: `${fmt(d.targetAmount)} ${loanAssetSymbol}`, + Delta: `${Number(d.delta) >= 0 ? '+' : ''}${fmt(d.delta)} ${loanAssetSymbol}`, + 'Util Now': fmtUtil(d.currentUtilization), + 'Util After': fmtUtil(d.projectedUtilization), + 'APR Now': fmtApr(d.currentApy), + 'APR After': fmtApr(d.projectedApy), + 'Market ID': `${d.market.uniqueKey.slice(0, 10)}...`, + })), + ); + } - console.log(''); - const currentApr = apyToApr(currentWeightedApy); - const projectedApr = apyToApr(projectedWeightedApy); + debugLog(''); + const currentApr = convertApyToApr(currentWeightedApy); + const projectedApr = convertApyToApr(projectedWeightedApy); const aprDiff = projectedApr - currentApr; - console.log(`Weighted APR: ${fmtApr(currentWeightedApy)} → ${fmtApr(projectedWeightedApy)} (${aprDiff >= 0 ? '+' : ''}${(aprDiff * 100).toFixed(4)}%)`); - console.log('================================\n'); + debugLog(`Weighted APR: ${fmtApr(currentWeightedApy)} → ${fmtApr(projectedWeightedApy)} (${aprDiff >= 0 ? '+' : ''}${(aprDiff * 100).toFixed(4)}%)`); + debugLog('================================\n'); } From e82c053ed30981ec77ffac41d7a24bd7588c2c22 Mon Sep 17 00:00:00 2001 From: secretmemelocker Date: Sun, 1 Mar 2026 13:01:14 -0600 Subject: [PATCH 05/10] Proper permit 2 timing check and add back owner gate --- .../components/position-actions-dropdown.tsx | 10 +++++++--- src/hooks/useSmartRebalance.ts | 17 ++++++++++++++--- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/features/positions/components/position-actions-dropdown.tsx b/src/features/positions/components/position-actions-dropdown.tsx index 5e7b5d5f..40962d06 100644 --- a/src/features/positions/components/position-actions-dropdown.tsx +++ b/src/features/positions/components/position-actions-dropdown.tsx @@ -14,7 +14,7 @@ type PositionActionsDropdownProps = { onSmartRebalanceConfigClick: () => void; }; -export function PositionActionsDropdown({ onRebalanceClick, onSmartRebalanceClick, onSmartRebalanceConfigClick }: PositionActionsDropdownProps) { +export function PositionActionsDropdown({ isOwner, onRebalanceClick, onSmartRebalanceClick, onSmartRebalanceConfigClick }: PositionActionsDropdownProps) { const handleClick = (e: React.MouseEvent) => { e.stopPropagation(); }; @@ -44,6 +44,8 @@ export function PositionActionsDropdown({ onRebalanceClick, onSmartRebalanceClic } + disabled={!isOwner} + className={isOwner ? '' : 'cursor-not-allowed opacity-50'} > Rebalance @@ -51,13 +53,15 @@ export function PositionActionsDropdown({ onRebalanceClick, onSmartRebalanceClic } - className="flex-1 rounded-r-none" + className={`flex-1 rounded-r-none ${isOwner ? '' : 'cursor-not-allowed opacity-50'}`} + disabled={!isOwner} > Smart Rebalance diff --git a/src/hooks/useSmartRebalance.ts b/src/hooks/useSmartRebalance.ts index df1c3e93..b09dce56 100644 --- a/src/hooks/useSmartRebalance.ts +++ b/src/hooks/useSmartRebalance.ts @@ -1,4 +1,4 @@ -import { useState, useCallback, useMemo } from 'react'; +import { useState, useCallback, useMemo, useRef, useEffect } from 'react'; import { type Address, encodeFunctionData, maxUint256, zeroAddress } from 'viem'; import { useConnection } from 'wagmi'; import morphoBundlerAbi from '@/abis/bundlerV2'; @@ -78,6 +78,12 @@ export const useSmartRebalance = ( amount: totalMoved, }); + // Refs to access latest permit2 state inside async callbacks + const permit2AuthorizedRef = useRef(permit2Authorized); + const isLoadingPermit2Ref = useRef(isLoadingPermit2); + useEffect(() => { permit2AuthorizedRef.current = permit2Authorized; }, [permit2Authorized]); + useEffect(() => { isLoadingPermit2Ref.current = isLoadingPermit2; }, [isLoadingPermit2]); + // Hook for standard ERC20 approval const { isApproved: isTokenApproved, @@ -214,6 +220,12 @@ export const useSmartRebalance = ( } setIsProcessing(true); + + // Wait for permit2 allowance query to resolve before checking authorization + while (isLoadingPermit2Ref.current) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + const transactions: `0x${string}`[] = []; const initialStep = usePermit2Setting @@ -249,7 +261,7 @@ export const useSmartRebalance = ( if (usePermit2Setting) { // --- Permit2 Flow --- tracking.update('approve_permit2'); - if (!permit2Authorized) { + if (!permit2AuthorizedRef.current) { await authorizePermit2(); await new Promise((resolve) => setTimeout(resolve, 800)); } @@ -366,7 +378,6 @@ export const useSmartRebalance = ( totalMoved, hasBundler, usePermit2Setting, - permit2Authorized, isBundlerAuthorized, authorizePermit2, ensureBundlerAuthorization, From d1df7d474ff4b34c6fd1bff3a5f8abf7fb099ea5 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Mon, 2 Mar 2026 16:34:19 +0800 Subject: [PATCH 06/10] feat: apply optimizer rebalancing workflow --- AGENTS.md | 1 + src/components/common/ProcessModal.tsx | 11 +- src/config/smart-rebalance.ts | 17 + .../positions/components/allocation-cell.tsx | 34 +- .../components/position-actions-dropdown.tsx | 25 +- .../components/rebalance/rebalance-modal.tsx | 807 +++++++++++++++--- .../rebalance/smart-rebalance-modal.tsx | 307 ------- .../supplied-morpho-blue-grouped-table.tsx | 11 - .../positions/smart-rebalance/engine.ts | 326 +++++++ .../positions/smart-rebalance/planner.ts | 200 +++++ .../positions/smart-rebalance/types.ts | 54 ++ src/hooks/useRebalance.ts | 378 ++------ src/hooks/useRebalanceExecution.ts | 425 +++++++++ src/hooks/useSmartRebalance.ts | 455 +++------- src/modals/registry.tsx | 5 - src/stores/useModalStore.ts | 5 - src/utils/smart-rebalance.ts | 380 --------- 17 files changed, 1923 insertions(+), 1518 deletions(-) create mode 100644 src/config/smart-rebalance.ts delete mode 100644 src/features/positions/components/rebalance/smart-rebalance-modal.tsx create mode 100644 src/features/positions/smart-rebalance/engine.ts create mode 100644 src/features/positions/smart-rebalance/planner.ts create mode 100644 src/features/positions/smart-rebalance/types.ts create mode 100644 src/hooks/useRebalanceExecution.ts delete mode 100644 src/utils/smart-rebalance.ts diff --git a/AGENTS.md b/AGENTS.md index 2c8d5d59..05d7fec3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -150,6 +150,7 @@ 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. ### REQUIRED: Regression Rule Capture diff --git a/src/components/common/ProcessModal.tsx b/src/components/common/ProcessModal.tsx index 6e871ea3..ecbc6027 100644 --- a/src/components/common/ProcessModal.tsx +++ b/src/components/common/ProcessModal.tsx @@ -54,18 +54,17 @@ function SummaryBlock({ items }: { items: TransactionSummaryItem[] }) { return (
{items.map((item) => ( -
+
{item.label} {item.value} {item.detail && ( {item.detail} diff --git a/src/config/smart-rebalance.ts b/src/config/smart-rebalance.ts new file mode 100644 index 00000000..1490c53f --- /dev/null +++ b/src/config/smart-rebalance.ts @@ -0,0 +1,17 @@ +import { type Address, isAddress } from 'viem'; + +/** + * Frontend-configured fee recipient for Smart Rebalance. + * + * Set `NEXT_PUBLIC_SMART_REBALANCE_FEE_RECIPIENT` in your env to override. + * Fallback keeps current production behavior until explicitly changed. + */ +const DEFAULT_FEE_RECIPIENT = '0xdb24a3611e7dd442c0fa80b32325ce92655e4eaf'; + +const configuredRecipient = process.env.NEXT_PUBLIC_SMART_REBALANCE_FEE_RECIPIENT?.trim(); + +if (configuredRecipient && !isAddress(configuredRecipient)) { + throw new Error('NEXT_PUBLIC_SMART_REBALANCE_FEE_RECIPIENT must be a valid EVM address.'); +} + +export const SMART_REBALANCE_FEE_RECIPIENT = (configuredRecipient ?? DEFAULT_FEE_RECIPIENT) as Address; diff --git a/src/features/positions/components/allocation-cell.tsx b/src/features/positions/components/allocation-cell.tsx index 435ca0df..e13c6d5d 100644 --- a/src/features/positions/components/allocation-cell.tsx +++ b/src/features/positions/components/allocation-cell.tsx @@ -5,28 +5,32 @@ import { MONARCH_PRIMARY } from '@/constants/chartColors'; type AllocationCellProps = { amount: number; - symbol: string; + symbol?: string; percentage: number; + compact?: boolean; }; /** * Combined allocation display component showing percentage as a circular indicator * alongside the amount. Used in expanded position tables for consistent allocation display. */ -export function AllocationCell({ amount, symbol, percentage }: AllocationCellProps) { +export function AllocationCell({ amount, symbol, percentage, compact = false }: AllocationCellProps) { const isZero = amount === 0; const displayPercentage = Math.min(percentage, 100); // Cap at 100% for display // Calculate SVG circle properties for progress indicator - const radius = 8; + const radius = compact ? 6 : 8; + const iconSize = compact ? 16 : 20; + const strokeWidth = compact ? 2 : 3; const circumference = 2 * Math.PI * radius; const offset = circumference - (displayPercentage / 100) * circumference; return ( -
+
{/* 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; - onSmartRebalanceClick: () => void; - onSmartRebalanceConfigClick: () => void; }; -export function PositionActionsDropdown({ isOwner, onRebalanceClick, onSmartRebalanceClick, onSmartRebalanceConfigClick }: PositionActionsDropdownProps) { +export function PositionActionsDropdown({ isOwner, onRebalanceClick }: PositionActionsDropdownProps) { const handleClick = (e: React.MouseEvent) => { e.stopPropagation(); }; @@ -49,24 +46,6 @@ export function PositionActionsDropdown({ isOwner, onRebalanceClick, onSmartReba > Rebalance -
- } - className={`flex-1 rounded-r-none ${isOwner ? '' : 'cursor-not-allowed opacity-50'}`} - disabled={!isOwner} - > - Smart Rebalance - - - - -
diff --git a/src/features/positions/components/rebalance/rebalance-modal.tsx b/src/features/positions/components/rebalance/rebalance-modal.tsx index 7f5cec7c..552d3868 100644 --- a/src/features/positions/components/rebalance/rebalance-modal.tsx +++ b/src/features/positions/components/rebalance/rebalance-modal.tsx @@ -1,18 +1,33 @@ -import { useState, useMemo, useCallback } from 'react'; +import { useState, useMemo, useCallback, useEffect, useRef, type ReactNode } from 'react'; 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 +39,371 @@ 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; + +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); +} + +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 [debouncedSmartMaxAllocationBps, setDebouncedSmartMaxAllocationBps] = useState>({}); + const [smartPlan, setSmartPlan] = useState(null); + const [isSmartCalculating, setIsSmartCalculating] = useState(false); + const [smartCalculationError, setSmartCalculationError] = useState(null); + + const calcIdRef = useRef(0); + 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]); + + const defaultSmartMarketKeys = useMemo( + () => + groupedPosition.markets.filter((position) => BigInt(position.state.supplyAssets) > 0n).map((position) => position.market.uniqueKey), + [groupedPosition.markets], + ); + + useEffect(() => { + if (!isOpen) return; + setMode('smart'); + setSmartSelectedMarketKeys(new Set(defaultSmartMarketKeys)); + setSmartMaxAllocationBps({}); + setDebouncedSmartMaxAllocationBps({}); + setSmartPlan(null); + setSmartCalculationError(null); + }, [defaultSmartMarketKeys, isOpen]); + + 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[] = [ + { + 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({ + label: 'Capital moved', + value: fmtAmount(smartTotalMoved), + }); + items.push({ + label: 'Fee (0.01%)', + 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)); + + if (sourceEntries.length !== debouncedEntries.length) return true; + + for (let index = 0; index < sourceEntries.length; index++) { + if (sourceEntries[index][0] !== debouncedEntries[index][0] || sourceEntries[index][1] !== debouncedEntries[index][1]) { + return true; + } + } + + return false; + }, [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, + })); + return; + } + + setSmartSelectedMarketKeys((prev) => { + const next = new Set(prev); + next.delete(uniqueKey); + return next; + }); + + setSmartMaxAllocationBps((prev) => { + if (!(uniqueKey in prev)) return prev; + const { [uniqueKey]: _removed, ...rest } = prev; + return rest; + }); + }, + [currentSupplyByMarket], + ); + + const updateMaxAllocation = useCallback((uniqueKey: string, rawValue: string) => { + const numeric = Number(rawValue); + if (!Number.isFinite(numeric)) return; + + const clamped = Math.max(0, Math.min(100, numeric)); + const nextBps = Math.round(clamped * 100); + + setSmartMaxAllocationBps((prev) => ({ + ...prev, + [uniqueKey]: nextBps, + })); + }, []); + + 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 +425,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 +452,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 +463,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,71 +496,59 @@ 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 handleExecuteSmartRebalance = useCallback(() => { + void executeSmartRebalance(smartSummaryItems); + }, [executeSmartRebalance, smartSummaryItems]); const handleManualRefresh = () => { refetch(() => { @@ -212,23 +558,65 @@ export function RebalanceModal({ groupedPosition, isOpen, onOpenChange, refetch, }); }; + 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: 'Fee (0.01%)', + 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: , onClick: () => { if (!isRefetching) { handleManualRefresh(); @@ -254,54 +637,220 @@ export function RebalanceModal({ groupedPosition, isOpen, onOpenChange, refetch, 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 maxAllocationValue = (smartMaxAllocationBps[row.market.uniqueKey] ?? 10_000) / 100; + + 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)}
+
+
+ updateMaxAllocation(row.market.uniqueKey, event.target.value)} + 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 current market liquidity/capacity. +
+ )} + + {!isSmartCalculating && smartPlan && ( + + )} + + )}
+ - - Execute Rebalance - + + {mode === 'manual' ? ( + + Execute Rebalance + + ) : ( + + {isSmartWithdrawOnly ? 'Batch Withdraw' : 'Smart Rebalance'} + + )} ); diff --git a/src/features/positions/components/rebalance/smart-rebalance-modal.tsx b/src/features/positions/components/rebalance/smart-rebalance-modal.tsx deleted file mode 100644 index 53337cb6..00000000 --- a/src/features/positions/components/rebalance/smart-rebalance-modal.tsx +++ /dev/null @@ -1,307 +0,0 @@ -import { useState, useCallback, useEffect, useRef, useMemo } from 'react'; -import { Button } from '@/components/ui/button'; -import { Checkbox } from '@/components/ui/checkbox'; -import { Modal, ModalHeader, ModalBody, ModalFooter } from '@/components/common/Modal'; -import { Spinner } from '@/components/ui/spinner'; -import { TokenIcon } from '@/components/shared/token-icon'; -import { ExecuteTransactionButton } from '@/components/ui/ExecuteTransactionButton'; -import { useSmartRebalance } from '@/hooks/useSmartRebalance'; -import { formatBalance, formatReadable } from '@/utils/balance'; -import { calculateSmartRebalance } from '@/utils/smart-rebalance'; -import type { SmartRebalanceResult } from '@/utils/smart-rebalance'; -import type { TransactionSummaryItem } from '@/stores/useTransactionProcessStore'; -import type { GroupedPosition } from '@/utils/types'; -import { convertApyToApr } from '@/utils/rateMath'; - -type SmartRebalanceModalProps = { - groupedPosition: GroupedPosition; - isOpen: boolean; - onOpenChange: (open: boolean) => void; - quickMode?: boolean; -}; - -function fmtApr(apy: number): string { - return `${(convertApyToApr(apy) * 100).toFixed(2)}%`; -} - -export function SmartRebalanceModal({ groupedPosition, isOpen, onOpenChange, quickMode }: SmartRebalanceModalProps) { - // Markets with supply > 0 - const marketsWithSupply = useMemo( - () => - groupedPosition.markets - .filter((pos) => BigInt(pos.state.supplyAssets) > 0n) - .sort( - (a, b) => - Number(formatBalance(b.state.supplyAssets, b.market.loanAsset.decimals)) - - Number(formatBalance(a.state.supplyAssets, a.market.loanAsset.decimals)), - ), - [groupedPosition.markets], - ); - - const [excludedIds, setExcludedIds] = useState>(new Set()); - const [result, setResult] = useState(null); - const [isCalculating, setIsCalculating] = useState(false); - - const { executeSmartRebalance, isProcessing, totalMoved, feeAmount } = useSmartRebalance(groupedPosition, result); - - const fmt = useCallback( - (val: bigint) => formatReadable(formatBalance(val, groupedPosition.loanAssetDecimals)), - [groupedPosition.loanAssetDecimals], - ); - - // Stable key that changes only when excluded set actually changes - const excludedKey = useMemo(() => [...excludedIds].sort().join(','), [excludedIds]); - - // Auto-calculate on open and when excludedIds change - const calcIdRef = useRef(0); - - useEffect(() => { - if (!isOpen) return; - - if (excludedIds.size >= marketsWithSupply.length) { - setResult(null); - return; - } - - const id = ++calcIdRef.current; - setIsCalculating(true); - - void calculateSmartRebalance(groupedPosition, groupedPosition.chainId, excludedIds.size > 0 ? excludedIds : undefined) - .then((res) => { - if (id !== calcIdRef.current) return; - setResult(res); - }) - .catch((err) => { - if (id !== calcIdRef.current) return; - console.error('[smart-rebalance] Error:', err); - setResult(null); - }) - .finally(() => { - if (id !== calcIdRef.current) return; - setIsCalculating(false); - }); - }, [isOpen, excludedKey, groupedPosition]); - - const toggleMarket = useCallback((uniqueKey: string) => { - setExcludedIds((prev) => { - const next = new Set(prev); - if (next.has(uniqueKey)) { - next.delete(uniqueKey); - } else { - next.add(uniqueKey); - } - return next; - }); - }, []); - - const allExcluded = excludedIds.size >= marketsWithSupply.length; - - const handleClose = useCallback(() => { - onOpenChange(false); - setTimeout(() => { - setExcludedIds(new Set()); - setResult(null); - }, 200); - }, [onOpenChange]); - - const currentApr = result ? convertApyToApr(result.currentWeightedApy) : 0; - const projectedApr = result ? convertApyToApr(result.projectedWeightedApy) : 0; - const aprDiff = projectedApr - currentApr; - - const buildSummaryItems = useCallback((): TransactionSummaryItem[] => { - if (!result) return []; - const items: TransactionSummaryItem[] = [ - { - label: 'Weighted APR', - value: `${fmtApr(result.currentWeightedApy)} → ${fmtApr(result.projectedWeightedApy)}`, - detail: `(${aprDiff >= 0 ? '+' : ''}${(aprDiff * 100).toFixed(4)}%)`, - detailColor: aprDiff >= 0 ? 'positive' : 'negative', - }, - ]; - if (totalMoved > 0n) { - items.push({ - label: 'Capital moved', - value: `${fmt(totalMoved)} ${result.loanAssetSymbol}`, - }); - items.push({ - label: 'Fee (0.01%)', - value: `${fmt(feeAmount)} ${result.loanAssetSymbol}`, - }); - } - return items; - }, [result, aprDiff, totalMoved, feeAmount, fmt]); - - // Quick mode: auto-execute as soon as calculation completes - const quickFiredRef = useRef(false); - useEffect(() => { - if (!quickMode || !isOpen || isCalculating || !result || totalMoved === 0n) return; - if (quickFiredRef.current) return; - quickFiredRef.current = true; - void executeSmartRebalance(buildSummaryItems()).then((ok) => { - if (ok) handleClose(); - }); - }, [quickMode, isOpen, isCalculating, result, totalMoved, executeSmartRebalance, handleClose, buildSummaryItems]); - - // Reset quick-fired flag when modal closes - useEffect(() => { - if (!isOpen) quickFiredRef.current = false; - }, [isOpen]); - - return ( - { - if (!open) handleClose(); - }} - isDismissable={!isProcessing} - flexibleWidth - > - - Smart Rebalance {groupedPosition.loanAsset ?? 'Unknown'} -
- } - description="Optimizes allocation across markets for maximum yield" - mainIcon={ - - } - onClose={!isProcessing ? handleClose : undefined} - /> - - - {/* Market selection table (hidden in quick mode) */} - {!quickMode && ( -
- - - - - - - - - - - {marketsWithSupply.map((pos) => { - const isIncluded = !excludedIds.has(pos.market.uniqueKey); - const supplyAmount = BigInt(pos.state.supplyAssets); - const apy = pos.market.state?.supplyApy ? Number(pos.market.state.supplyApy) : 0; - - return ( - toggleMarket(pos.market.uniqueKey)} - > - - - - - - ); - })} - -
IncludeCollateralSupplyAPR
- toggleMarket(pos.market.uniqueKey)} - onClick={(e) => e.stopPropagation()} - /> - -
- - {pos.market.collateralAsset.symbol} -
-
- - {fmt(supplyAmount)} {groupedPosition.loanAssetSymbol} - - - {fmtApr(apy)} -
-
- )} - - {/* Loading indicator */} - {isCalculating && ( -
- -

Calculating optimal allocation...

-
- )} - - {/* Summary */} - {!isCalculating && result && ( -
-
-
Weighted APR
-
- {fmtApr(result.currentWeightedApy)} - - {fmtApr(result.projectedWeightedApy)} - = 0 ? 'text-green-600' : 'text-red-500'}> - ({aprDiff >= 0 ? '+' : ''} - {(aprDiff * 100).toFixed(4)}%) - -
-
- {totalMoved > 0n && ( -
- Capital moved - - {fmt(totalMoved)} {result.loanAssetSymbol} - -
- )} - {totalMoved > 0n && ( -
- Fee (0.01%) - - {fmt(feeAmount)} {result.loanAssetSymbol} - -
- )} -
- )} - - {/* No result after calculation */} - {!isCalculating && !result && !allExcluded && ( -
No rebalancing needed — allocations are already optimal.
- )} -
- - - - void executeSmartRebalance(buildSummaryItems()).then((ok) => { if (ok) handleClose(); })} - isLoading={isProcessing} - disabled={isCalculating || !result || totalMoved === 0n} - className="rounded-sm p-4 px-10 font-zen text-white duration-200 ease-in-out hover:scale-105" - > - Execute 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 5c6eae2c..7c1a0135 100644 --- a/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx +++ b/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx @@ -337,17 +337,6 @@ export function SuppliedMorphoBlueGroupedTable({ isRefetching, }); }} - onSmartRebalanceClick={() => { - openModal('smartRebalance', { - groupedPosition, - quickMode: true, - }); - }} - onSmartRebalanceConfigClick={() => { - openModal('smartRebalance', { - groupedPosition, - }); - }} /> = 10_000) return 10_000; + return Math.floor(value); +} + +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 = input.maxRounds ?? DEFAULT_MAX_ROUNDS; + const fractions = input.fractionRationals ?? DEFAULT_FRACTIONS; + + 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..1fbff744 --- /dev/null +++ b/src/features/positions/smart-rebalance/planner.ts @@ -0,0 +1,200 @@ +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 maxFromUser = allowFullWithdraw ? currentSupply : currentSupply > DUST_AMOUNT ? currentSupply - DUST_AMOUNT : 0n; + 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 withdrawAllRequested = + suppliedEntries.length > 0 && + suppliedEntries.every((entry) => { + const normalized = normalizeMaxBps(constraints?.[entry.uniqueKey]?.maxAllocationBps); + return normalized === 0; + }); + + if (withdrawAllRequested) { + const totalPool = entries.reduce((sum, entry) => sum + entry.currentSupply, 0n); + 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..29527bc7 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) throw new Error('Wallet not connected'); + const withdrawTxs: `0x${string}`[] = []; const supplyTxs: `0x${string}`[] = []; const allMarketKeys: string[] = []; @@ -164,7 +68,7 @@ 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 @@ -175,8 +79,6 @@ export const useRebalance = (groupedPosition: GroupedPosition, onRebalance?: () } 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}`); } @@ -186,26 +88,25 @@ 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}`); } @@ -215,210 +116,77 @@ 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; } - setIsProcessing(true); - const transactions: `0x${string}`[] = []; - const initialStep = usePermit2Setting ? 'approve_permit2' : 'authorize_bundler_tx'; - tracking.start( - getStepsForFlow(usePermit2Setting), - { + const { withdrawTxs, supplyTxs, allMarketKeys } = generateRebalanceTxData(); + + 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..655d9a48 --- /dev/null +++ b/src/hooks/useRebalanceExecution.ts @@ -0,0 +1,425 @@ +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'; + + tracking.start( + getStepsForFlow(usePermit2Setting, loanAssetSymbol, shouldTransfer), + { + title: metadata.title, + description: metadata.description, + tokenSymbol: metadata.tokenSymbol, + summaryItems: metadata.summaryItems, + }, + initialStep, + ); + + const transactions: `0x${string}`[] = []; + + if (usePermit2Setting) { + if (shouldTransfer) { + tracking.update('approve_permit2'); + if (!permit2AuthorizedRef.current) { + await authorizePermit2(); + await sleep(800); + } + + tracking.update('authorize_bundler_sig'); + const { authorizationTxData } = await ensureBundlerAuthorization({ mode: 'signature' }); + if (authorizationTxData) { + transactions.push(authorizationTxData); + await sleep(800); + } + + tracking.update('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 { + tracking.update('authorize_bundler_sig'); + const { authorizationTxData } = await ensureBundlerAuthorization({ mode: 'signature' }); + if (authorizationTxData) { + transactions.push(authorizationTxData); + await sleep(800); + } + + transactions.push(...withdrawTxs); + transactions.push(...supplyTxs); + } + } else { + tracking.update('authorize_bundler_tx'); + const { authorized } = await ensureBundlerAuthorization({ mode: 'transaction' }); + if (!authorized) { + throw new Error('Failed to authorize Bundler via transaction.'); + } + + if (shouldTransfer) { + tracking.update('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); + } + + 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, + 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 index b09dce56..1301b880 100644 --- a/src/hooks/useSmartRebalance.ts +++ b/src/hooks/useSmartRebalance.ts @@ -1,129 +1,70 @@ -import { useState, useCallback, useMemo, useRef, useEffect } from 'react'; -import { type Address, encodeFunctionData, maxUint256, zeroAddress } from 'viem'; -import { useConnection } from 'wagmi'; +import { useCallback, useMemo } from 'react'; +import { type Address, encodeFunctionData, maxUint256 } from 'viem'; 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 { SMART_REBALANCE_FEE_RECIPIENT } from '@/config/smart-rebalance'; import type { GroupedPosition } from '@/utils/types'; -import type { SmartRebalanceResult, MarketDelta } from '@/utils/smart-rebalance'; -import { GAS_COSTS, GAS_MULTIPLIER_NUMERATOR, GAS_MULTIPLIER_DENOMINATOR } from '@/features/markets/components/constants'; import type { TransactionSummaryItem } from '@/stores/useTransactionProcessStore'; -import { useERC20Approval } from './useERC20Approval'; -import { useBundlerAuthorizationStep } from './useBundlerAuthorizationStep'; -import { usePermit2 } from './usePermit2'; -import { useAppSettings } from '@/stores/useAppSettings'; -import { useStyledToast } from './useStyledToast'; +import type { SmartRebalancePlan } from '@/features/positions/smart-rebalance/planner'; +import { useRebalanceExecution, type RebalanceExecutionStepType } from './useRebalanceExecution'; +import { useConnection } from 'wagmi'; const SMART_REBALANCE_FEE_BPS = 10n; // measured in tenths of a BPS. const FEE_BPS_DENOMINATOR = 100_000n; -const FEE_RECIPIENT = '0xdb24a3611e7dd442c0fa80b32325ce92655e4eaf' as Address; - -export type SmartRebalanceStepType = - | 'idle' - | 'approve_permit2' - | 'authorize_bundler_sig' - | 'sign_permit' - | 'authorize_bundler_tx' - | 'approve_token' - | 'execute'; - -export const useSmartRebalance = ( - groupedPosition: GroupedPosition, - result: SmartRebalanceResult | null, -) => { - const [isProcessing, setIsProcessing] = useState(false); - const tracking = useTransactionTracking('smart-rebalance'); +export const useSmartRebalance = (groupedPosition: GroupedPosition, plan: SmartRebalancePlan | null, onSuccess?: () => void) => { const { address: account } = useConnection(); - const bundlerAddress = getBundlerV2(groupedPosition.chainId); - const hasBundler = bundlerAddress !== zeroAddress; - const toast = useStyledToast(); - const { usePermit2: usePermit2Setting } = useAppSettings(); - // Compute totalMoved from negative deltas (withdrawals) const totalMoved = useMemo(() => { - if (!result) return 0n; - return result.deltas.reduce((acc, d) => { - if (d.delta < 0n) return acc + (-d.delta); - return acc; - }, 0n); - }, [result]); + if (!plan) return 0n; + return plan.totalMoved; + }, [plan]); - // Fee amount - const feeAmount = useMemo(() => { - return (totalMoved * SMART_REBALANCE_FEE_BPS) / FEE_BPS_DENOMINATOR; - }, [totalMoved]); - - // Hook for Morpho bundler authorization - const { isBundlerAuthorized, isAuthorizingBundler, ensureBundlerAuthorization, refetchIsBundlerAuthorized } = - useBundlerAuthorizationStep({ - chainId: groupedPosition.chainId, - bundlerAddress: bundlerAddress as Address, - }); - - // Hook for Permit2 handling - const { - authorizePermit2, - permit2Authorized, - signForBundlers, - isLoading: isLoadingPermit2, - } = usePermit2({ - user: account, - spender: bundlerAddress, - token: groupedPosition.loanAssetAddress as Address, - refetchInterval: 10_000, - chainId: groupedPosition.chainId, - tokenSymbol: groupedPosition.loanAsset, - amount: totalMoved, - }); + const hasPositiveSupplyDeltas = useMemo(() => plan?.deltas.some((delta) => delta.delta > 0n) ?? false, [plan]); - // Refs to access latest permit2 state inside async callbacks - const permit2AuthorizedRef = useRef(permit2Authorized); - const isLoadingPermit2Ref = useRef(isLoadingPermit2); - useEffect(() => { permit2AuthorizedRef.current = permit2Authorized; }, [permit2Authorized]); - useEffect(() => { isLoadingPermit2Ref.current = isLoadingPermit2; }, [isLoadingPermit2]); + const feeAmount = useMemo( + () => (hasPositiveSupplyDeltas ? (totalMoved * SMART_REBALANCE_FEE_BPS) / FEE_BPS_DENOMINATOR : 0n), + [hasPositiveSupplyDeltas, totalMoved], + ); - // Hook for standard ERC20 approval - const { - isApproved: isTokenApproved, - approve: approveToken, - isApproving: isTokenApproving, - } = useERC20Approval({ - token: groupedPosition.loanAssetAddress as Address, - spender: bundlerAddress, - amount: totalMoved, - tokenSymbol: groupedPosition.loanAsset, + const execution = useRebalanceExecution({ chainId: groupedPosition.chainId, - }); - - const handleTransactionSuccess = useCallback(() => { - void refetchIsBundlerAuthorized(); - }, [refetchIsBundlerAuthorized]); - - const { sendTransactionAsync, isConfirming: isExecuting } = useTransactionWithToast({ + 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', - chainId: groupedPosition.chainId, - onSuccess: handleTransactionSuccess, + onSuccess, }); - // Generate withdraw/supply tx data from SmartRebalanceResult deltas const generateSmartRebalanceTxData = useCallback(() => { - if (!result || !account) throw new Error('Missing result or account'); + if (!plan || !account) { + throw new Error('Missing smart-rebalance plan'); + } const withdrawTxs: `0x${string}`[] = []; const supplyTxs: `0x${string}`[] = []; - for (const d of result.deltas) { - if (d.delta >= 0n) continue; // skip supplies and zero deltas - - const withdrawAmount = -d.delta; - const market = d.market; - - if (!market.loanAsset?.address || !market.collateralAsset?.address || !market.oracleAddress || !market.irmAddress || market.lltv === undefined) { + for (const delta of plan.deltas) { + if (delta.delta >= 0n) continue; + + const withdrawAmount = -delta.delta; + const market = delta.market; + const supplyShares = BigInt( + groupedPosition.markets.find((position) => position.market.uniqueKey === market.uniqueKey)?.state.supplyShares ?? '0', + ); + const isFullWithdraw = delta.targetAmount === 0n && supplyShares > 0n; + + 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}`); } @@ -135,27 +76,33 @@ export const useSmartRebalance = ( lltv: BigInt(market.lltv), }; - // Smart rebalance always leaves dust, so never do a full shares-based withdrawal - const withdrawTx = encodeFunctionData({ - abi: morphoBundlerAbi, - functionName: 'morphoWithdraw', - args: [ - marketParams, - withdrawAmount, - 0n, - maxUint256, - account, - ], - }); - withdrawTxs.push(withdrawTx); + withdrawTxs.push( + encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'morphoWithdraw', + args: [ + marketParams, + isFullWithdraw ? 0n : withdrawAmount, + isFullWithdraw ? supplyShares : 0n, + isFullWithdraw ? withdrawAmount : maxUint256, + account, + ], + }), + ); } - for (const d of result.deltas) { - if (d.delta <= 0n) continue; // skip withdrawals and zero deltas + for (const delta of plan.deltas) { + if (delta.delta <= 0n) continue; - const market = d.market; + const market = delta.market; - if (!market.loanAsset?.address || !market.collateralAsset?.address || !market.oracleAddress || !market.irmAddress || market.lltv === undefined) { + 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}`); } @@ -167,243 +114,73 @@ export const useSmartRebalance = ( lltv: BigInt(market.lltv), }; - // Reduce supply amount by fee share: targetDelta - (targetDelta * 10 / 10_000) - const reducedAmount = d.delta - (d.delta * SMART_REBALANCE_FEE_BPS) / FEE_BPS_DENOMINATOR; + const reducedAmount = delta.delta - (delta.delta * SMART_REBALANCE_FEE_BPS) / FEE_BPS_DENOMINATOR; + if (reducedAmount <= 0n) continue; - const supplyTx = encodeFunctionData({ - abi: morphoBundlerAbi, - functionName: 'morphoSupply', - args: [ - marketParams, - reducedAmount, - 0n, - 1n, // minShares - account, - '0x', - ], - }); - supplyTxs.push(supplyTx); + supplyTxs.push( + encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'morphoSupply', + args: [marketParams, reducedAmount, 0n, 1n, account, '0x'], + }), + ); } return { withdrawTxs, supplyTxs }; - }, [result, account, groupedPosition.markets]); + }, [account, groupedPosition.markets, plan]); - // Helper to generate steps based on flow type - const getStepsForFlow = useCallback( - (isPermit2: boolean) => { - if (isPermit2) { - 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 the token transfer.' }, - { id: 'execute', title: 'Confirm Smart Rebalance', description: 'Confirm transaction in wallet to complete the smart rebalance.' }, - ]; + const executeSmartRebalance = useCallback( + async (summaryItems?: TransactionSummaryItem[]) => { + if (!plan || !account || totalMoved === 0n) { + return false; } - return [ - { id: 'authorize_bundler_tx', title: 'Authorize Morpho Bundler (Transaction)', description: 'Submit a transaction to authorize the Morpho bundler.' }, - { id: 'approve_token', title: `Approve ${groupedPosition.loanAsset}`, description: `Approve ${groupedPosition.loanAsset} for spending.` }, - { id: 'execute', title: 'Confirm Smart Rebalance', description: 'Confirm transaction in wallet to complete the smart rebalance.' }, - ]; - }, - [groupedPosition.loanAsset], - ); - - const executeSmartRebalance = useCallback(async (summaryItems?: TransactionSummaryItem[]) => { - if (!account || !result || totalMoved === 0n) { - toast.info('Nothing to rebalance', 'No moves to execute.'); - return; - } - if (!hasBundler) { - toast.error('Unsupported chain', 'Smart rebalance is not available on this chain.'); - return; - } - - setIsProcessing(true); - - // Wait for permit2 allowance query to resolve before checking authorization - while (isLoadingPermit2Ref.current) { - await new Promise((resolve) => setTimeout(resolve, 100)); - } - - const transactions: `0x${string}`[] = []; - - const initialStep = usePermit2Setting - ? (permit2Authorized - ? (isBundlerAuthorized ? 'sign_permit' : 'authorize_bundler_sig') - : 'approve_permit2') - : (isBundlerAuthorized - ? (isTokenApproved ? 'execute' : 'approve_token') - : 'authorize_bundler_tx'); - tracking.start( - getStepsForFlow(usePermit2Setting), - { - title: 'Smart Rebalance', - description: `Smart rebalancing ${groupedPosition.loanAsset} positions`, - tokenSymbol: groupedPosition.loanAsset, - summaryItems, - }, - initialStep, - ); - - try { const { withdrawTxs, supplyTxs } = generateSmartRebalanceTxData(); + const isWithdrawOnly = supplyTxs.length === 0; - // Build fee sweep tx: erc20Transfer(asset, feeRecipient, maxUint256) to sweep all remaining - const feeTransferTx = encodeFunctionData({ - abi: morphoBundlerAbi, - functionName: 'erc20Transfer', - args: [groupedPosition.loanAssetAddress as Address, FEE_RECIPIENT, maxUint256], - }); - - let multicallGas: bigint | undefined = undefined; - - if (usePermit2Setting) { - // --- Permit2 Flow --- - tracking.update('approve_permit2'); - if (!permit2AuthorizedRef.current) { - await authorizePermit2(); - await new Promise((resolve) => setTimeout(resolve, 800)); - } - - tracking.update('authorize_bundler_sig'); - const { authorizationTxData } = await ensureBundlerAuthorization({ mode: 'signature' }); - if (authorizationTxData) { - transactions.push(authorizationTxData); - await new Promise((resolve) => setTimeout(resolve, 800)); - } - - tracking.update('sign_permit'); - const { sigs, permitSingle } = await signForBundlers(); - const permitTx = encodeFunctionData({ - abi: morphoBundlerAbi, - functionName: 'approve2', - args: [permitSingle, sigs, false], - }); - const transferFromTx = encodeFunctionData({ - abi: morphoBundlerAbi, - functionName: 'transferFrom2', - args: [groupedPosition.loanAssetAddress as Address, totalMoved], - }); - - // Bundle order: auth → withdraws → transferFrom → supplies → fee sweep - transactions.push(permitTx); - transactions.push(...withdrawTxs); - transactions.push(transferFromTx); - transactions.push(...supplyTxs); - transactions.push(feeTransferTx); - } else { - // --- Standard ERC20 Flow --- - tracking.update('authorize_bundler_tx'); - const { authorized } = await ensureBundlerAuthorization({ mode: 'transaction' }); - if (!authorized) { - throw new Error('Failed to authorize Bundler via transaction.'); - } - - tracking.update('approve_token'); - if (!isTokenApproved) { - await approveToken(); - await new Promise((resolve) => setTimeout(resolve, 1000)); - } - - const erc20TransferFromTx = encodeFunctionData({ - abi: morphoBundlerAbi, - functionName: 'erc20TransferFrom', - args: [groupedPosition.loanAssetAddress as Address, totalMoved], - }); - - // Bundle order: withdraws → transferFrom → supplies → fee sweep - transactions.push(...withdrawTxs); - transactions.push(erc20TransferFromTx); - transactions.push(...supplyTxs); - transactions.push(feeTransferTx); - - // Estimate gas - 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); - } + let gasEstimate = GAS_COSTS.BUNDLER_REBALANCE; + if (supplyTxs.length > 1) { + gasEstimate += GAS_COSTS.SINGLE_SUPPLY * BigInt(supplyTxs.length - 1); } - - // 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, - }); - - tracking.complete(); - return true; - } catch (error: unknown) { - console.error('Error during smart rebalance:', error); - tracking.fail(); - - const isUserRejection = (() => { - if (error && typeof error === 'object') { - const err = error as Record; - if (err.code === 4001 || err.code === 'ACTION_REJECTED') return true; - const msg = typeof err.message === 'string' ? err.message : ''; - if (/user rejected|user denied|request has been rejected/i.test(msg)) return true; - const nested = (err.data as Record)?.originalError as Record | undefined; - if (nested?.code === 4001) return true; - const cause = err.cause as Record | undefined; - if (cause?.code === 4001 || cause?.code === 'ACTION_REJECTED') return true; - } - return false; - })(); - - if (isUserRejection) { - toast.error('Transaction Rejected', 'User rejected transaction.'); - } else { - toast.error('Smart Rebalance Failed', 'An unexpected error occurred during smart rebalance.'); + if (withdrawTxs.length > 1) { + gasEstimate += GAS_COSTS.SINGLE_WITHDRAW * BigInt(withdrawTxs.length - 1); } - } finally { - setIsProcessing(false); - } - }, [ - account, - result, - totalMoved, - hasBundler, - usePermit2Setting, - isBundlerAuthorized, - authorizePermit2, - ensureBundlerAuthorization, - signForBundlers, - isTokenApproved, - approveToken, - generateSmartRebalanceTxData, - sendTransactionAsync, - bundlerAddress, - groupedPosition.chainId, - groupedPosition.loanAssetAddress, - groupedPosition.loanAsset, - toast, - tracking, - getStepsForFlow, - ]); - const isLoading = isProcessing || isLoadingPermit2 || isTokenApproving || isAuthorizingBundler || isExecuting; + 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, + }); + }, + [account, execution, generateSmartRebalanceTxData, groupedPosition.loanAsset, groupedPosition.loanAssetAddress, plan, totalMoved], + ); return { executeSmartRebalance, - isProcessing: isLoading, + isProcessing: execution.isProcessing, totalMoved, feeAmount, - transaction: tracking.transaction, - dismiss: tracking.dismiss, - currentStep: tracking.currentStep as SmartRebalanceStepType | null, + transaction: execution.transaction, + dismiss: execution.dismiss, + currentStep: execution.currentStep as RebalanceExecutionStepType | null, }; }; diff --git a/src/modals/registry.tsx b/src/modals/registry.tsx index 521f55e2..d55edd0c 100644 --- a/src/modals/registry.tsx +++ b/src/modals/registry.tsx @@ -29,10 +29,6 @@ const RebalanceMarketSelectionModal = lazy(() => import('@/features/markets/components/market-selection-modal').then((m) => ({ default: m.MarketSelectionModal })), ); -const SmartRebalanceModal = lazy(() => - import('@/features/positions/components/rebalance/smart-rebalance-modal').then((m) => ({ default: m.SmartRebalanceModal })), -); - // Settings & Configuration const MarketSettingsModal = lazy(() => import('@/features/markets/components/market-settings-modal')); @@ -59,7 +55,6 @@ export const MODAL_REGISTRY: { supply: SupplyModalV2, rebalance: RebalanceModal, rebalanceMarketSelection: RebalanceMarketSelectionModal, - smartRebalance: SmartRebalanceModal, marketSettings: MarketSettingsModal, monarchSettings: MonarchSettingsModal, vaultDeposit: VaultDepositModal, diff --git a/src/stores/useModalStore.ts b/src/stores/useModalStore.ts index eb74bc18..874801bb 100644 --- a/src/stores/useModalStore.ts +++ b/src/stores/useModalStore.ts @@ -57,11 +57,6 @@ export type ModalProps = { onSelect: (markets: Market[]) => void; }; - smartRebalance: { - groupedPosition: GroupedPosition; - quickMode?: boolean; - }; - // Settings & Configuration marketSettings: { zIndex?: 'settings' | 'top'; // Override z-index when opened from nested modals diff --git a/src/utils/smart-rebalance.ts b/src/utils/smart-rebalance.ts deleted file mode 100644 index 5967629b..00000000 --- a/src/utils/smart-rebalance.ts +++ /dev/null @@ -1,380 +0,0 @@ -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 { convertApyToApr } from '@/utils/rateMath'; -import type { SupportedNetworks } from '@/utils/networks'; -import type { GroupedPosition, Market } from './types'; -import { formatBalance, formatReadable } from './balance'; - -// --- Config --- - -const MAX_ROUNDS = 50; -const LOG_TAG = '[smart-rebalance]'; -const DUST_AMOUNT = 1000n; // Leave dust in markets to not remove them from future rebalances -const DEBUG = process.env.NODE_ENV === 'development'; - -function debugLog(...args: unknown[]) { - if (DEBUG) console.log(...args); -} - -// --- Types --- - -type MarketEntry = { - uniqueKey: string; - originalMarket: Market; - collateralSymbol: string; - /** Live on-chain BlueMarket snapshot (immutable baseline) */ - baselineMarket: BlueMarket; - /** Current user supply in this market (live, immutable) */ - currentSupply: bigint; - /** Max we can withdraw (min of user supply and market liquidity) */ - maxWithdrawable: bigint; -}; - -export type MarketDelta = { - market: Market; - currentAmount: bigint; - targetAmount: bigint; - delta: bigint; - currentApy: number; - projectedApy: number; - currentUtilization: number; - projectedUtilization: number; - collateralSymbol: string; -}; - -export type SmartRebalanceResult = { - deltas: MarketDelta[]; - totalPool: bigint; - currentWeightedApy: number; - projectedWeightedApy: number; - loanAssetSymbol: string; - loanAssetDecimals: number; -}; - -// --- Helpers --- - -function utilizationOf(market: BlueMarket): number { - return Number(market.utilization) / 1e18; -} - -/** - * Compute weighted APY across all markets given allocations and simulated market states. - * Uses raw bigint amounts as weights so we don't lose precision. - */ -function weightedApy(entries: MarketEntry[], allocations: Map, marketMap: Map): number { - let weightedSum = 0; - let totalWeight = 0; - for (const entry of entries) { - const amount = Number(allocations.get(entry.uniqueKey) ?? 0n); - if (amount <= 0) continue; - const market = marketMap.get(entry.uniqueKey); - if (!market) continue; - weightedSum += amount * market.supplyApy; - totalWeight += amount; - } - return totalWeight > 0 ? weightedSum / totalWeight : 0; -} - -// --- Main --- - -export async function calculateSmartRebalance( - groupedPosition: GroupedPosition, - chainId: SupportedNetworks, - excludedMarketIds?: Set, -): Promise { - // 1. Filter to positions with supply, excluding any blacklisted markets - const positions = groupedPosition.markets.filter((pos) => { - if (BigInt(pos.state.supplyAssets) <= 0n) return false; - if (excludedMarketIds?.has(pos.market.uniqueKey)) return false; - return true; - }); - - if (positions.length === 0) return null; - - // 2. Fetch fresh on-chain market state via multicall - const client = getClient(chainId); - const morphoAddress = getMorphoAddress(chainId); - - const results = await client.multicall({ - contracts: positions.map((pos) => ({ - address: morphoAddress as `0x${string}`, - abi: morphoABI, - functionName: 'market' as const, - args: [pos.market.uniqueKey as `0x${string}`], - })), - allowFailure: true, - }); - - // 3. Build MarketEntry objects from live on-chain data - const entries: MarketEntry[] = []; - - for (let i = 0; i < positions.length; i++) { - const pos = positions[i]; - const result = results[i]; - - if (result.status !== 'success' || !result.result) { - console.warn(`${LOG_TAG} Failed to fetch on-chain state for ${pos.market.uniqueKey}, skipping`); - continue; - } - - const data = result.result as readonly bigint[]; - const [totalSupplyAssets, totalSupplyShares, totalBorrowAssets, totalBorrowShares, lastUpdate, fee] = data; - - const params = new BlueMarketParams({ - loanToken: pos.market.loanAsset.address as `0x${string}`, - collateralToken: pos.market.collateralAsset.address as `0x${string}`, - oracle: pos.market.oracleAddress as `0x${string}`, - irm: pos.market.irmAddress as `0x${string}`, - lltv: BigInt(pos.market.lltv), - }); - - const baselineMarket = new BlueMarket({ - params, - totalSupplyAssets, - totalBorrowAssets, - totalSupplyShares, - totalBorrowShares, - lastUpdate, - fee, - rateAtTarget: BigInt(pos.market.state.rateAtTarget), - }); - - const userSupply = BigInt(pos.state.supplyAssets); - const liquidity = baselineMarket.liquidity; - // Leave DUST_AMOUNT in each market so the position persists for future rebalances - const maxFromUser = userSupply > DUST_AMOUNT ? userSupply - DUST_AMOUNT : 0n; - const maxWithdrawable = maxFromUser < liquidity ? maxFromUser : liquidity; - - entries.push({ - uniqueKey: pos.market.uniqueKey, - originalMarket: pos.market, - collateralSymbol: pos.market.collateralAsset?.symbol ?? 'N/A', - baselineMarket, - currentSupply: userSupply, - maxWithdrawable, - }); - } - - if (entries.length === 0) return null; - - // 4. Compute total moveable capital - let totalMoveable = 0n; - for (const e of entries) totalMoveable += e.maxWithdrawable; - if (totalMoveable === 0n) return null; - - // 5. Initialize working state - // - `allocations` tracks the target amount per market (starts as current) - // - `simMarketMap` tracks the simulated BlueMarket state reflecting moves - const allocations = new Map(); - const simMarketMap = new Map(); - - for (const entry of entries) { - allocations.set(entry.uniqueKey, entry.currentSupply); - simMarketMap.set(entry.uniqueKey, entry.baselineMarket); - } - - // Log initial state - debugLog(`${LOG_TAG} totalMoveable=${totalMoveable}, maxRounds=${MAX_ROUNDS}, markets=${entries.length}`); - for (const e of entries) { - const sim = simMarketMap.get(e.uniqueKey); - debugLog(` ${e.collateralSymbol}: supply=${e.currentSupply}, withdrawable=${e.maxWithdrawable}, apy=${sim ? (sim.supplyApy * 100).toFixed(4) : '?'}%`); - } - - // 6. Multi-scale optimizer: for each (src→dst) pair, evaluate multiple transfer - // sizes to capture non-linear APY spikes (e.g. utilization approaching 100%). - // The old chunk-by-chunk greedy approach missed large beneficial moves because - // each small step looked worse individually. - - /** - * Simulate moving `amount` from src to dst and return - * the resulting weighted APY without mutating state. - */ - function evaluateMove( - src: number, - dst: number, - amount: bigint, - ): { apy: number; srcMarket: BlueMarket; dstMarket: BlueMarket } { - const srcKey = entries[src].uniqueKey; - const dstKey = entries[dst].uniqueKey; - const srcSim = simMarketMap.get(srcKey)!; - const dstSim = simMarketMap.get(dstKey)!; - - // Simulate cumulative withdrawal/supply from current sim state - const srcAfter = srcSim.withdraw(amount, 0n).market; - const dstAfter = dstSim.supply(amount, 0n).market; - - // Temporarily apply - simMarketMap.set(srcKey, srcAfter); - simMarketMap.set(dstKey, dstAfter); - - const prevSrcAlloc = allocations.get(srcKey) ?? 0n; - const prevDstAlloc = allocations.get(dstKey) ?? 0n; - allocations.set(srcKey, prevSrcAlloc - amount); - allocations.set(dstKey, prevDstAlloc + amount); - - const apy = weightedApy(entries, allocations, simMarketMap); - - // Revert - simMarketMap.set(srcKey, srcSim); - simMarketMap.set(dstKey, dstSim); - allocations.set(srcKey, prevSrcAlloc); - allocations.set(dstKey, prevDstAlloc); - - return { apy, srcMarket: srcAfter, dstMarket: dstAfter }; - } - - // Transfer size fractions as rational pairs [numerator, denominator] for BigInt precision - const FRACTION_RATIONALS: [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], - ]; - - for (let round = 0; round < MAX_ROUNDS; round++) { - const currentApy = weightedApy(entries, allocations, simMarketMap); - - let bestSrc = -1; - let bestDst = -1; - let bestAmount = 0n; - let bestApy = currentApy; - let bestSrcMarket: BlueMarket | null = null; - let bestDstMarket: BlueMarket | null = null; - - for (let src = 0; src < entries.length; src++) { - const srcKey = entries[src].uniqueKey; - const srcAlloc = allocations.get(srcKey) ?? 0n; - - // How much can we still withdraw from this source? - const alreadyWithdrawn = entries[src].currentSupply - srcAlloc; - const remainingWithdrawable = entries[src].maxWithdrawable - alreadyWithdrawn; - if (remainingWithdrawable <= 0n) continue; - - // Also can't withdraw more than current allocation (minus dust) - const maxFromAlloc = srcAlloc > DUST_AMOUNT ? srcAlloc - DUST_AMOUNT : 0n; - const maxMove = remainingWithdrawable < maxFromAlloc ? remainingWithdrawable : maxFromAlloc; - if (maxMove <= 0n) continue; - - for (let dst = 0; dst < entries.length; dst++) { - if (dst === src) continue; - - // Evaluate multiple transfer sizes for this pair - for (const [num, den] of FRACTION_RATIONALS) { - let amount = (maxMove * num) / den; - if (amount <= 0n) continue; - if (amount > maxMove) amount = maxMove; - - const result = evaluateMove(src, dst, amount); - if (result.apy > bestApy) { - bestApy = result.apy; - bestSrc = src; - bestDst = dst; - bestAmount = amount; - bestSrcMarket = result.srcMarket; - bestDstMarket = result.dstMarket; - } - } - } - } - - // No move improves weighted APY — converged - if (bestSrc === -1 || !bestSrcMarket || !bestDstMarket) { - debugLog(`${LOG_TAG} round ${round}: converged. weighted APY=${(currentApy * 100).toFixed(6)}%`); - break; - } - - // Apply the best move - const srcKey = entries[bestSrc].uniqueKey; - const dstKey = entries[bestDst].uniqueKey; - - simMarketMap.set(srcKey, bestSrcMarket); - simMarketMap.set(dstKey, bestDstMarket); - allocations.set(srcKey, (allocations.get(srcKey) ?? 0n) - bestAmount); - allocations.set(dstKey, (allocations.get(dstKey) ?? 0n) + bestAmount); - - debugLog( - `${LOG_TAG} round ${round}: ${entries[bestSrc].collateralSymbol}→${entries[bestDst].collateralSymbol} ` + - `amount=${bestAmount} | weighted APY: ${(currentApy * 100).toFixed(6)}%→${(bestApy * 100).toFixed(6)}%`, - ); - } - - // 8. Build result deltas - const deltas: MarketDelta[] = entries.map((entry) => { - const current = entry.currentSupply; - const target = allocations.get(entry.uniqueKey) ?? 0n; - const simMarket = simMarketMap.get(entry.uniqueKey); - - return { - market: entry.originalMarket, - currentAmount: current, - targetAmount: target, - delta: target - current, - currentApy: entry.baselineMarket.supplyApy, - projectedApy: simMarket?.supplyApy ?? entry.baselineMarket.supplyApy, - currentUtilization: utilizationOf(entry.baselineMarket), - projectedUtilization: simMarket ? utilizationOf(simMarket) : utilizationOf(entry.baselineMarket), - collateralSymbol: entry.collateralSymbol, - }; - }); - - const totalPool = deltas.reduce((sum, d) => sum + d.currentAmount, 0n); - - const currentWeightedApy = - totalPool > 0n - ? deltas.reduce((sum, d) => sum + Number(d.currentAmount) * d.currentApy, 0) / Number(totalPool) - : 0; - - const projectedWeightedApy = - totalPool > 0n - ? deltas.reduce((sum, d) => sum + Number(d.targetAmount) * d.projectedApy, 0) / Number(totalPool) - : 0; - - return { - deltas: deltas.sort((a, b) => (b.delta > a.delta ? 1 : b.delta < a.delta ? -1 : 0)), - totalPool, - currentWeightedApy, - projectedWeightedApy, - loanAssetSymbol: groupedPosition.loanAssetSymbol, - loanAssetDecimals: groupedPosition.loanAssetDecimals, - }; -} - -// --- Logging --- - -export function logSmartRebalanceResults(result: SmartRebalanceResult): void { - if (!DEBUG) return; - - const { deltas, totalPool, currentWeightedApy, projectedWeightedApy, loanAssetSymbol, loanAssetDecimals } = result; - - const fmt = (val: bigint) => formatReadable(formatBalance(val, loanAssetDecimals)); - const fmtApr = (apy: number) => `${(convertApyToApr(apy) * 100).toFixed(2)}%`; - const fmtUtil = (u: number) => `${(u * 100).toFixed(1)}%`; - - debugLog('\n=== Smart Rebalance Results (fresh on-chain data) ==='); - debugLog(`Asset: ${loanAssetSymbol} | Total: ${fmt(totalPool)} ${loanAssetSymbol}`); - debugLog(''); - - if (DEBUG) { - console.table( - deltas.map((d) => ({ - Collateral: d.collateralSymbol, - Current: `${fmt(d.currentAmount)} ${loanAssetSymbol}`, - Target: `${fmt(d.targetAmount)} ${loanAssetSymbol}`, - Delta: `${Number(d.delta) >= 0 ? '+' : ''}${fmt(d.delta)} ${loanAssetSymbol}`, - 'Util Now': fmtUtil(d.currentUtilization), - 'Util After': fmtUtil(d.projectedUtilization), - 'APR Now': fmtApr(d.currentApy), - 'APR After': fmtApr(d.projectedApy), - 'Market ID': `${d.market.uniqueKey.slice(0, 10)}...`, - })), - ); - } - - debugLog(''); - const currentApr = convertApyToApr(currentWeightedApy); - const projectedApr = convertApyToApr(projectedWeightedApy); - const aprDiff = projectedApr - currentApr; - debugLog(`Weighted APR: ${fmtApr(currentWeightedApy)} → ${fmtApr(projectedWeightedApy)} (${aprDiff >= 0 ? '+' : ''}${(aprDiff * 100).toFixed(4)}%)`); - debugLog('================================\n'); -} From 8191ffb7e247b00bb17a4276fdcfffbcd96a1213 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Mon, 2 Mar 2026 16:34:32 +0800 Subject: [PATCH 07/10] chore: always show preview --- src/features/positions/components/rebalance/rebalance-modal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/positions/components/rebalance/rebalance-modal.tsx b/src/features/positions/components/rebalance/rebalance-modal.tsx index 552d3868..e0cc79f6 100644 --- a/src/features/positions/components/rebalance/rebalance-modal.tsx +++ b/src/features/positions/components/rebalance/rebalance-modal.tsx @@ -841,7 +841,7 @@ export function RebalanceModal({ groupedPosition, isOpen, onOpenChange, refetch,
)} - {!isSmartCalculating && smartPlan && ( + {smartPlan && ( Date: Mon, 2 Mar 2026 17:09:49 +0800 Subject: [PATCH 08/10] chore: reivew fixes --- AGENTS.md | 1 + src/components/common/ProcessModal.tsx | 2 +- src/config/smart-rebalance.ts | 5 +- .../components/rebalance/rebalance-modal.tsx | 178 ++++++++++++++---- .../positions/smart-rebalance/engine.ts | 36 +++- .../positions/smart-rebalance/planner.ts | 16 +- src/hooks/useRebalance.ts | 16 +- src/hooks/useRebalanceExecution.ts | 30 ++- src/hooks/useSmartRebalance.ts | 46 ++++- src/stores/useTransactionProcessStore.ts | 1 + 10 files changed, 259 insertions(+), 72 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 05d7fec3..bb2f063c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -151,6 +151,7 @@ When touching transaction and position flows, validation MUST include all releva 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. ### REQUIRED: Regression Rule Capture diff --git a/src/components/common/ProcessModal.tsx b/src/components/common/ProcessModal.tsx index ecbc6027..98675560 100644 --- a/src/components/common/ProcessModal.tsx +++ b/src/components/common/ProcessModal.tsx @@ -55,7 +55,7 @@ function SummaryBlock({ items }: { items: TransactionSummaryItem[] }) {
{items.map((item) => (
{item.label} diff --git a/src/config/smart-rebalance.ts b/src/config/smart-rebalance.ts index 1490c53f..3cd1f093 100644 --- a/src/config/smart-rebalance.ts +++ b/src/config/smart-rebalance.ts @@ -9,9 +9,10 @@ import { type Address, isAddress } from 'viem'; const DEFAULT_FEE_RECIPIENT = '0xdb24a3611e7dd442c0fa80b32325ce92655e4eaf'; const configuredRecipient = process.env.NEXT_PUBLIC_SMART_REBALANCE_FEE_RECIPIENT?.trim(); +const resolvedRecipient = configuredRecipient ?? DEFAULT_FEE_RECIPIENT; -if (configuredRecipient && !isAddress(configuredRecipient)) { +if (!isAddress(resolvedRecipient)) { throw new Error('NEXT_PUBLIC_SMART_REBALANCE_FEE_RECIPIENT must be a valid EVM address.'); } -export const SMART_REBALANCE_FEE_RECIPIENT = (configuredRecipient ?? DEFAULT_FEE_RECIPIENT) as Address; +export const SMART_REBALANCE_FEE_RECIPIENT: Address = resolvedRecipient; diff --git a/src/features/positions/components/rebalance/rebalance-modal.tsx b/src/features/positions/components/rebalance/rebalance-modal.tsx index e0cc79f6..2c29b63c 100644 --- a/src/features/positions/components/rebalance/rebalance-modal.tsx +++ b/src/features/positions/components/rebalance/rebalance-modal.tsx @@ -1,4 +1,5 @@ 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'; @@ -46,6 +47,9 @@ const modeOptions: { value: RebalanceMode; label: string }[] = [ { 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; function formatPercent(value: number, digits = 2): string { return `${value.toFixed(digits)}%`; @@ -58,6 +62,21 @@ function formatRate(apy: number, isAprDisplay: boolean): string { 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; @@ -92,12 +111,17 @@ export function RebalanceModal({ groupedPosition, isOpen, onOpenChange, refetch, 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(); @@ -139,21 +163,31 @@ export function RebalanceModal({ groupedPosition, isOpen, onOpenChange, refetch, const marketByKey = useMemo(() => new Map(eligibleMarkets.map((market) => [market.uniqueKey, market])), [eligibleMarkets]); - const defaultSmartMarketKeys = useMemo( - () => - groupedPosition.markets.filter((position) => BigInt(position.state.supplyAssets) > 0n).map((position) => position.market.uniqueKey), - [groupedPosition.markets], - ); - useEffect(() => { - if (!isOpen) return; + 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(defaultSmartMarketKeys)); + setSmartSelectedMarketKeys(new Set(nextDefaultSmartMarketKeys)); setSmartMaxAllocationBps({}); + setSmartMaxAllocationInputValues({}); setDebouncedSmartMaxAllocationBps({}); setSmartPlan(null); setSmartCalculationError(null); - }, [defaultSmartMarketKeys, isOpen]); + }, [groupedPosition.markets, isOpen]); + + useEffect(() => { + return () => { + if (syncIndicatorTimeoutRef.current) { + clearTimeout(syncIndicatorTimeoutRef.current); + } + }; + }, []); useEffect(() => { if (!isOpen || mode !== 'smart') return; @@ -231,6 +265,7 @@ export function RebalanceModal({ groupedPosition, isOpen, onOpenChange, refetch, const items: TransactionSummaryItem[] = [ { + id: 'weighted-rate', label: `Weighted ${rateLabel}`, value: `${formatPercent(smartCurrentWeightedRate * 100)} → ${formatPercent(smartProjectedWeightedRate * 100)}`, detail: `(${smartWeightedRateDiff >= 0 ? '+' : ''}${formatPercent(smartWeightedRateDiff * 100)})`, @@ -240,10 +275,12 @@ export function RebalanceModal({ groupedPosition, isOpen, onOpenChange, refetch, if (smartTotalMoved > 0n) { items.push({ + id: 'capital-moved', label: 'Capital moved', value: fmtAmount(smartTotalMoved), }); items.push({ + id: 'fee', label: 'Fee (0.01%)', value: fmtAmount(smartFeeAmount), }); @@ -333,15 +370,7 @@ export function RebalanceModal({ groupedPosition, isOpen, onOpenChange, refetch, const sourceEntries = Object.entries(smartMaxAllocationBps).sort(([left], [right]) => left.localeCompare(right)); const debouncedEntries = Object.entries(debouncedSmartMaxAllocationBps).sort(([left], [right]) => left.localeCompare(right)); - if (sourceEntries.length !== debouncedEntries.length) return true; - - for (let index = 0; index < sourceEntries.length; index++) { - if (sourceEntries[index][0] !== debouncedEntries[index][0] || sourceEntries[index][1] !== debouncedEntries[index][1]) { - return true; - } - } - - return false; + return JSON.stringify(sourceEntries) !== JSON.stringify(debouncedEntries); }, [debouncedSmartMaxAllocationBps, smartMaxAllocationBps]); const smartCanExecute = !isSmartCalculating && !isSmartConstraintsPending && !!smartPlan && smartTotalMoved > 0n; @@ -355,6 +384,10 @@ export function RebalanceModal({ groupedPosition, isOpen, onOpenChange, refetch, ...prev, [uniqueKey]: 0, })); + setSmartMaxAllocationInputValues((prev) => ({ + ...prev, + [uniqueKey]: formatMaxAllocationInput(0), + })); return; } @@ -366,26 +399,65 @@ export function RebalanceModal({ groupedPosition, isOpen, onOpenChange, refetch, setSmartMaxAllocationBps((prev) => { if (!(uniqueKey in prev)) return prev; - const { [uniqueKey]: _removed, ...rest } = 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, rawValue: string) => { - const numeric = Number(rawValue); - if (!Number.isFinite(numeric)) return; - - const clamped = Math.max(0, Math.min(100, numeric)); - const nextBps = Math.round(clamped * 100); + 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}`, @@ -550,13 +622,33 @@ export function RebalanceModal({ groupedPosition, isOpen, onOpenChange, refetch, void executeSmartRebalance(smartSummaryItems); }, [executeSmartRebalance, smartSummaryItems]); - const handleManualRefresh = () => { - refetch(() => { - toast.info('Data refreshed', 'Position data updated', { - icon: 🚀, - }); + 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( () => [ @@ -628,9 +720,9 @@ export function RebalanceModal({ groupedPosition, isOpen, onOpenChange, refetch, } onClose={() => onOpenChange(false)} auxiliaryAction={{ - icon: , + icon: isRefreshSynced ? : , onClick: () => { - if (!isRefetching) { + if (!refreshActionLoading) { handleManualRefresh(); } }, @@ -719,7 +811,9 @@ export function RebalanceModal({ groupedPosition, isOpen, onOpenChange, refetch, {smartRows.map((row) => { - const maxAllocationValue = (smartMaxAllocationBps[row.market.uniqueKey] ?? 10_000) / 100; + const committedMaxAllocationValue = (smartMaxAllocationBps[row.market.uniqueKey] ?? 10_000) / 100; + const maxAllocationValue = + smartMaxAllocationInputValues[row.market.uniqueKey] ?? formatMaxAllocationInput(committedMaxAllocationValue); return (
updateMaxAllocation(row.market.uniqueKey, event.target.value)} + onChange={(event) => 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" />