From 9ad66cdce18b96666158a871f46a9dd137aaf73b Mon Sep 17 00:00:00 2001 From: antoncoding Date: Wed, 4 Mar 2026 16:37:55 +0800 Subject: [PATCH 1/2] feat: effeective algo --- AGENTS.md | 1 + .../components/apy-breakdown-tooltip.tsx | 12 +- .../components/table/market-table-body.tsx | 11 +- .../components/position-header.tsx | 48 +- .../components/position-actions-dropdown.tsx | 5 +- .../supplied-morpho-blue-grouped-table.tsx | 8 - .../positions/smart-rebalance/engine.ts | 504 +++++++++--------- .../positions/smart-rebalance/planner.ts | 114 ++-- .../swap/components/SlippageInlineEditor.tsx | 12 +- src/hooks/useSmartRebalance.ts | 32 +- .../add-collateral-and-leverage.tsx | 39 +- src/utils/merklApi.ts | 9 +- 12 files changed, 380 insertions(+), 415 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f32e3ca2..0b9574cd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -152,6 +152,7 @@ When touching transaction and position flows, validation MUST include all releva 17. **UI signal quality**: remove duplicate or low-signal metrics and keep transaction-critical UI focused on decision-relevant data. 18. **External incentive parity**: when external rewards (for example Merkl HOLD opportunities) materially change carry economics, include them in net preview calculations when global reward-inclusion is enabled, and resolve incentives using canonical chainId+address mappings rather than token symbols. 19. **APR/APY unit homogeneity**: in any reward/carry/net-rate calculation, normalize every term (base rate, each reward component, aggregates, and displayed subtotals) to the same selected mode before combining, so displayed formulas remain numerically consistent in both APR and APY modes. +20. **Rebalance objective integrity**: stepwise smart-rebalance planners must evaluate each candidate move by resulting **global weighted objective** (portfolio-level APY/APR), not by local/post-move market APR alone, and must fail safe (no-op) when projected objective is below current objective. ### REQUIRED: Regression Rule Capture After fixing any user-reported bug in a high-impact flow: diff --git a/src/features/markets/components/apy-breakdown-tooltip.tsx b/src/features/markets/components/apy-breakdown-tooltip.tsx index de128712..576d49ac 100644 --- a/src/features/markets/components/apy-breakdown-tooltip.tsx +++ b/src/features/markets/components/apy-breakdown-tooltip.tsx @@ -88,7 +88,9 @@ export function APYBreakdownTooltip({ baseAPY, activeCampaigns, children, mode =
- Base {modeLabel} {rateLabel} + + Base {modeLabel} {rateLabel} + {baseRateValue.toFixed(2)}%
{activeCampaigns.map((campaign) => { @@ -117,7 +119,9 @@ export function APYBreakdownTooltip({ baseAPY, activeCampaigns, children, mode = })}
- Net {modeLabel} {rateLabel} + + Net {modeLabel} {rateLabel} + {totalRate.toFixed(2)}%
@@ -150,8 +154,8 @@ export function APYCell({ market, mode = 'supply' }: APYCellProps) { const hasModeRewards = relevantCampaigns.length > 0; const extraRewards = hasModeRewards ? relevantCampaigns.reduce((sum, campaign) => { - return sum + getDisplayRewardRate(campaign.apr, isAprDisplay); - }, 0) + return sum + getDisplayRewardRate(campaign.apr, isAprDisplay); + }, 0) : 0; // Convert base rate if APR display is enabled diff --git a/src/features/markets/components/table/market-table-body.tsx b/src/features/markets/components/table/market-table-body.tsx index 138f5b7c..18b33647 100644 --- a/src/features/markets/components/table/market-table-body.tsx +++ b/src/features/markets/components/table/market-table-body.tsx @@ -215,7 +215,16 @@ export function MarketTableBody({ currentEntries, expandedRowId, setExpandedRowI className="z-50 text-center" style={{ minWidth: '85px', paddingLeft: 3, paddingRight: 3 }} > -

{item.state.borrowApy != null ? : '—'}

+

+ {item.state.borrowApy != null ? ( + + ) : ( + '—' + )} +

)} {columnVisibility.rateAtTarget && ( diff --git a/src/features/position-detail/components/position-header.tsx b/src/features/position-detail/components/position-header.tsx index bde53938..aae6f219 100644 --- a/src/features/position-detail/components/position-header.tsx +++ b/src/features/position-detail/components/position-header.tsx @@ -5,7 +5,6 @@ import Image from 'next/image'; import { ChevronDownIcon } from '@radix-ui/react-icons'; import { TbArrowsRightLeft } from 'react-icons/tb'; import { RiBookmarkFill, RiBookmarkLine } from 'react-icons/ri'; -import { useConnection } from 'wagmi'; import { Button } from '@/components/ui/button'; import { TokenIcon } from '@/components/shared/token-icon'; import { Tooltip } from '@/components/ui/tooltip'; @@ -51,15 +50,12 @@ export function PositionHeader({ periodLabel, }: PositionHeaderProps) { const router = useRouter(); - const { address } = useConnection(); const { isAprDisplay } = useAppSettings(); const { short: rateLabel } = useRateLabel(); const { open: openModal } = useModalStore(); const { togglePositionBookmark, isPositionBookmarked } = usePortfolioBookmarks(); - const isOwner = address === userAddress; const networkImg = getNetworkImg(chainId); - const showRebalance = isOwner; const isPositionSaved = groupedPosition && isPositionBookmarked(userAddress, chainId, groupedPosition.loanAssetAddress); const displaySymbol = groupedPosition?.loanAssetSymbol ?? loanAssetSymbol ?? ''; @@ -195,7 +191,7 @@ export function PositionHeader({ {/* RIGHT: Stats + Actions */}
{/* Key Stats */} -
+

Total Supply

@@ -306,28 +302,26 @@ export function PositionHeader({ - {showRebalance && ( - - } - > - - - - - )} + + } + > + + + +
diff --git a/src/features/positions/components/position-actions-dropdown.tsx b/src/features/positions/components/position-actions-dropdown.tsx index 46aec2b1..4502f685 100644 --- a/src/features/positions/components/position-actions-dropdown.tsx +++ b/src/features/positions/components/position-actions-dropdown.tsx @@ -7,11 +7,10 @@ import { Button } from '@/components/ui/button'; import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu'; type PositionActionsDropdownProps = { - isOwner?: boolean; onRebalanceClick: () => void; }; -export function PositionActionsDropdown({ isOwner, onRebalanceClick }: PositionActionsDropdownProps) { +export function PositionActionsDropdown({ onRebalanceClick }: PositionActionsDropdownProps) { const handleClick = (e: React.MouseEvent) => { e.stopPropagation(); }; @@ -41,8 +40,6 @@ export function PositionActionsDropdown({ isOwner, onRebalanceClick }: PositionA } - disabled={!isOwner} - className={isOwner ? '' : 'cursor-not-allowed opacity-50'} > 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 7c1a0135..b6289c3f 100644 --- a/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx +++ b/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx @@ -10,7 +10,6 @@ import Image from 'next/image'; import moment from 'moment'; import { PulseLoader } from 'react-spinners'; import { useRouter } from 'next/navigation'; -import { useConnection } from 'wagmi'; import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table'; import { Button } from '@/components/ui/button'; import { TokenIcon } from '@/components/shared/token-icon'; @@ -65,7 +64,6 @@ export function SuppliedMorphoBlueGroupedTable({ const [expandedRows, setExpandedRows] = useState>(new Set()); const { showEarningsInUsd, setShowEarningsInUsd } = usePositionsPreferences(); const { isOpen: isSettingsOpen, onOpen: onSettingsOpen, onOpenChange: onSettingsOpenChange } = useDisclosure(); - const { address } = useConnection(); const { isAprDisplay } = useAppSettings(); const { short: rateLabel } = useRateLabel(); const { open: openModal } = useModalStore(); @@ -73,11 +71,6 @@ export function SuppliedMorphoBlueGroupedTable({ const toast = useStyledToast(); - const isOwner = useMemo(() => { - if (!account) return false; - return account === address; - }, [account, address]); - const periodLabels: Record = { day: '1D', week: '7D', @@ -329,7 +322,6 @@ export function SuppliedMorphoBlueGroupedTable({ >
{ openModal('rebalance', { groupedPosition, diff --git a/src/features/positions/smart-rebalance/engine.ts b/src/features/positions/smart-rebalance/engine.ts index 27a3210f..746dfe9e 100644 --- a/src/features/positions/smart-rebalance/engine.ts +++ b/src/features/positions/smart-rebalance/engine.ts @@ -1,22 +1,7 @@ import type { Market as BlueMarket } from '@morpho-org/blue-sdk'; import type { SmartRebalanceConstraintMap, SmartRebalanceDelta, SmartRebalanceEngineInput, SmartRebalanceEngineOutput } from './types'; -const DEFAULT_MAX_ROUNDS = 50; -const DEFAULT_FRACTIONS: [bigint, bigint][] = [ - [2n, 100n], - [5n, 100n], - [10n, 100n], - [15n, 100n], - [20n, 100n], - [30n, 100n], - [40n, 100n], - [50n, 100n], - [60n, 100n], - [70n, 100n], - [80n, 100n], - [90n, 100n], - [1n, 1n], -]; +const MAX_CHUNKS = 100n; const APY_SCALE = 1_000_000_000_000n; function utilizationOf(market: BlueMarket): number { @@ -31,38 +16,6 @@ function clampBps(value: number | undefined): number | undefined { return Math.floor(value); } -function sanitizeMaxRounds(rawMaxRounds: number | undefined): number { - if (rawMaxRounds === undefined || !Number.isFinite(rawMaxRounds)) { - return DEFAULT_MAX_ROUNDS; - } - - const rounded = Math.floor(rawMaxRounds); - if (rounded < 1) return 1; - if (rounded > DEFAULT_MAX_ROUNDS) return DEFAULT_MAX_ROUNDS; - return rounded; -} - -function sanitizeFractions(rawFractions: [bigint, bigint][] | undefined): [bigint, bigint][] { - if (!rawFractions || rawFractions.length === 0) { - return DEFAULT_FRACTIONS; - } - - const deduped: [bigint, bigint][] = []; - const seen = new Set(); - - for (const [num, den] of rawFractions) { - if (den <= 0n || num <= 0n || num > den) continue; - - const key = `${num}/${den}`; - if (seen.has(key)) continue; - - seen.add(key); - deduped.push([num, den]); - } - - return deduped.length > 0 ? deduped : DEFAULT_FRACTIONS; -} - function toApyScaled(apy: number): bigint { if (!Number.isFinite(apy)) return 0n; return BigInt(Math.round(apy * Number(APY_SCALE))); @@ -94,6 +47,21 @@ function minBigInt(a: bigint, b: bigint): bigint { return a < b ? a : b; } +function simulateWithdrawAtCap(market: BlueMarket, capAmount: bigint): { amount: bigint; marketAfter: BlueMarket } { + if (capAmount <= 0n) { + return { amount: 0n, marketAfter: market }; + } + + try { + return { + amount: capAmount, + marketAfter: market.withdraw(capAmount, 0n).market, + }; + } catch { + return { amount: 0n, marketAfter: market }; + } +} + function resolveMaxAllocation( uniqueKey: string, totalPool: bigint, @@ -105,219 +73,159 @@ function resolveMaxAllocation( return (totalPool * BigInt(maxBps)) / 10_000n; } -/** - * Pure smart-rebalance optimizer. - * - * Input: - * - Fresh market snapshots (BlueMarket) - * - Current user allocation + withdrawable limits - * - Optional per-market max-allocation constraints - * - * Output: - * - Final target allocation and per-market deltas - * - Weighted APY before/after optimization - */ -export function optimizeSmartRebalance(input: SmartRebalanceEngineInput): SmartRebalanceEngineOutput | null { - const { entries, constraints } = input; - const maxRounds = sanitizeMaxRounds(input.maxRounds); - const fractions = sanitizeFractions(input.fractionRationals); - - if (entries.length === 0) return null; +type CleanStateResult = { + principal: bigint; + movableKeys: string[]; + maxAllocationMap: Map; + allocations: Map; + marketMap: Map; +}; + +type ChunkAllocationState = { + allocations: Map; + marketMap: Map; +}; + +function cleanStates( + entries: SmartRebalanceEngineInput['entries'], + constraints: SmartRebalanceConstraintMap | undefined, + totalPool: bigint, +): CleanStateResult { + const principal = 0n; + const movableKeys: string[] = []; + const maxAllocationMap = new Map(); + const allocations = new Map(); + const marketMap = new Map(); + + let runningPrincipal = principal; + + for (const entry of entries) { + maxAllocationMap.set(entry.uniqueKey, resolveMaxAllocation(entry.uniqueKey, totalPool, constraints)); + + const cappedWithdraw = minBigInt(entry.currentSupply, entry.maxWithdrawable); + const { amount: withdrawnAmount, marketAfter } = simulateWithdrawAtCap(entry.baselineMarket, cappedWithdraw); + const lockedAmount = entry.currentSupply - withdrawnAmount; + + movableKeys.push(entry.uniqueKey); + allocations.set(entry.uniqueKey, lockedAmount); + marketMap.set(entry.uniqueKey, marketAfter); + runningPrincipal += withdrawnAmount; + } - const totalPool = entries.reduce((sum, entry) => sum + entry.currentSupply, 0n); - if (totalPool <= 0n) return null; + return { + principal: runningPrincipal, + movableKeys, + maxAllocationMap, + allocations, + marketMap, + }; +} - 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])); +function buildChunks(principal: bigint): bigint[] { + if (principal <= 0n) return []; - 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)]), - ); + const chunkCount = principal < MAX_CHUNKS ? principal : MAX_CHUNKS; + const baseChunk = principal / chunkCount; + const remainder = principal % chunkCount; - // 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); - } + const chunks = Array.from({ length: Number(chunkCount) }, () => baseChunk); + if (chunks.length > 0 && remainder > 0n) { + chunks[chunks.length - 1] += remainder; } - 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; - } - } - } + return chunks; +} + +function findBestSupplyTarget( + amount: bigint, + movableKeys: string[], + uniqueKeys: string[], + allocations: Map, + marketMap: Map, + maxAllocationMap: Map, +): { uniqueKey: string; amount: bigint; marketAfter: BlueMarket; objective: bigint } | null { + let bestKey: string | null = null; + let bestAmount = 0n; + let bestObjective: bigint | null = null; + let bestMarketAfter: BlueMarket | null = null; + + for (const uniqueKey of movableKeys) { + const market = marketMap.get(uniqueKey); + if (!market) continue; + + const currentAllocation = allocations.get(uniqueKey) ?? 0n; + const maxAllocation = maxAllocationMap.get(uniqueKey); + const room = maxAllocation === undefined ? amount : maxAllocation - currentAllocation; + if (room <= 0n) continue; + + const supplyAmount = minBigInt(amount, room); + if (supplyAmount <= 0n) continue; + + let marketAfter: BlueMarket; + try { + marketAfter = market.supply(supplyAmount, 0n).market; + } catch { + continue; } - if (!bestSrcKey || !bestDstKey || !bestSrcMarket || !bestDstMarket || bestAmount <= 0n) { - break; + allocations.set(uniqueKey, currentAllocation + supplyAmount); + marketMap.set(uniqueKey, marketAfter); + const objective = computeObjective(uniqueKeys, allocations, marketMap); + allocations.set(uniqueKey, currentAllocation); + marketMap.set(uniqueKey, market); + + if (bestObjective === null || objective > bestObjective) { + bestObjective = objective; + bestKey = uniqueKey; + bestAmount = supplyAmount; + bestMarketAfter = marketAfter; } + } - simMarketMap.set(bestSrcKey, bestSrcMarket); - simMarketMap.set(bestDstKey, bestDstMarket); - allocations.set(bestSrcKey, (allocations.get(bestSrcKey) ?? 0n) - bestAmount); - allocations.set(bestDstKey, (allocations.get(bestDstKey) ?? 0n) + bestAmount); + if (!bestKey || !bestMarketAfter || bestAmount <= 0n || bestObjective === null) { + return null; } - const deltas: SmartRebalanceDelta[] = entries.map((entry) => { + return { uniqueKey: bestKey, amount: bestAmount, marketAfter: bestMarketAfter, objective: bestObjective }; +} + +function calculateAllocation( + chunks: bigint[], + movableKeys: string[], + uniqueKeys: string[], + allocations: Map, + marketMap: Map, + maxAllocationMap: Map, +): ChunkAllocationState { + for (const chunk of chunks) { + let remainingChunk = chunk; + if (remainingChunk <= 0n) continue; + + while (remainingChunk > 0n) { + const best = findBestSupplyTarget(remainingChunk, movableKeys, uniqueKeys, allocations, marketMap, maxAllocationMap); + if (!best) break; + + allocations.set(best.uniqueKey, (allocations.get(best.uniqueKey) ?? 0n) + best.amount); + marketMap.set(best.uniqueKey, best.marketAfter); + remainingChunk -= best.amount; + } + } + + return { + allocations, + marketMap, + }; +} + +function buildDeltas( + entries: SmartRebalanceEngineInput['entries'], + allocations: Map, + projectedMarketMap: Map, +): SmartRebalanceDelta[] { + return entries.map((entry) => { const currentAmount = entry.currentSupply; - const targetAmount = allocations.get(entry.uniqueKey) ?? 0n; - const projectedMarket = simMarketMap.get(entry.uniqueKey) ?? entry.baselineMarket; + const targetAmount = allocations.get(entry.uniqueKey) ?? currentAmount; + const projectedMarket = projectedMarketMap.get(entry.uniqueKey) ?? entry.baselineMarket; return { market: entry.market, @@ -331,18 +239,106 @@ export function optimizeSmartRebalance(input: SmartRebalanceEngineInput): SmartR collateralSymbol: entry.market.collateralAsset?.symbol ?? 'N/A', }; }); +} + +function sumTotalMoved(deltas: SmartRebalanceDelta[]): bigint { + return deltas.reduce((sum, delta) => { + if (delta.delta < 0n) return sum + -delta.delta; + return sum; + }, 0n); +} + +/** + * 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 planRebalance(input: SmartRebalanceEngineInput): SmartRebalanceEngineOutput | null { + const { entries, constraints } = input; + + 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); + + // 1. Simulate extra liquidity and start state: + // - attempt best-effort withdrawal from each selected market + // - keep any non-withdrawable remainder locked in-place + const cleaned = cleanStates(entries, constraints, totalPool); + if (cleaned.principal <= 0n || cleaned.movableKeys.length === 0) { + const deltas = buildDeltas(entries, cleaned.allocations, cleaned.marketMap); + 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, cleaned.allocations, cleaned.marketMap); + + 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: sumTotalMoved(deltas), + }; + } + + // 2. Divide principal into up-to-100 chunks. + const chunks = buildChunks(cleaned.principal); + + // 3. For each chunk, choose the destination that maximizes global weighted objective after that step. + const allocated = calculateAllocation( + chunks, + cleaned.movableKeys, + uniqueKeys, + cleaned.allocations, + cleaned.marketMap, + cleaned.maxAllocationMap, + ); + + // 4. Compute final delta plan (withdraw from current -> deposit to final targets). + const deltas = buildDeltas(entries, allocated.allocations, allocated.marketMap); 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 projectedObjective = computeObjective(uniqueKeys, allocated.allocations, allocated.marketMap); - const totalMoved = deltas.reduce((sum, delta) => { - if (delta.delta < 0n) return sum + -delta.delta; - return sum; - }, 0n); + // Reliability guard: never return a plan that is worse than the current weighted objective. + if (projectedObjective < currentObjective) { + const noOpAllocations = new Map(entries.map((entry) => [entry.uniqueKey, entry.currentSupply])); + const noOpMarkets = new Map(entries.map((entry) => [entry.uniqueKey, entry.baselineMarket])); + const noOpDeltas = buildDeltas(entries, noOpAllocations, noOpMarkets); + + return { + deltas: noOpDeltas.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(currentObjective, totalPool), + totalMoved: 0n, + }; + } + + const totalMoved = sumTotalMoved(deltas); return { deltas: deltas.sort((a, b) => { @@ -356,3 +352,5 @@ export function optimizeSmartRebalance(input: SmartRebalanceEngineInput): SmartR totalMoved, }; } + +export const optimizeSmartRebalance = planRebalance; diff --git a/src/features/positions/smart-rebalance/planner.ts b/src/features/positions/smart-rebalance/planner.ts index 94780bb8..78e20346 100644 --- a/src/features/positions/smart-rebalance/planner.ts +++ b/src/features/positions/smart-rebalance/planner.ts @@ -4,11 +4,9 @@ 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 { planRebalance } from './engine'; import type { SmartRebalanceConstraintMap, SmartRebalanceEngineOutput } from './types'; -const DUST_AMOUNT = 1000n; - export type SmartRebalancePlan = SmartRebalanceEngineOutput & { loanAssetSymbol: string; loanAssetDecimals: number; @@ -22,25 +20,35 @@ type BuildSmartRebalancePlanInput = { 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 hasPlannerRequiredFields(market: Market): boolean { + return ( + !!market.loanAsset?.address && + !!market.collateralAsset?.address && + !!market.oracleAddress && + !!market.irmAddress && + market.lltv !== undefined + ); } -function objectiveToWeightedApy(objective: bigint, totalPool: bigint): number { - if (totalPool <= 0n) return 0; - const scaled = objective / totalPool; - return Number(scaled) / Number(APY_SCALE); -} +function selectUniqueMarkets(candidateMarkets: Market[], includedMarketKeys: Set): Market[] { + const byKey = new Map(); + + for (const market of candidateMarkets) { + if (!includedMarketKeys.has(market.uniqueKey)) continue; + + const existing = byKey.get(market.uniqueKey); + if (!existing) { + byKey.set(market.uniqueKey, market); + continue; + } + + // Prefer a duplicate candidate that has complete planner-critical fields. + if (!hasPlannerRequiredFields(existing) && hasPlannerRequiredFields(market)) { + byKey.set(market.uniqueKey, market); + } + } -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); + return [...byKey.values()]; } /** @@ -61,7 +69,7 @@ export async function calculateSmartRebalancePlan({ }: BuildSmartRebalancePlanInput): Promise { if (includedMarketKeys.size === 0) return null; - const selectedMarkets = candidateMarkets.filter((market) => includedMarketKeys.has(market.uniqueKey)); + const selectedMarkets = selectUniqueMarkets(candidateMarkets, includedMarketKeys); if (selectedMarkets.length === 0) return null; const client = getClient(chainId); @@ -75,6 +83,7 @@ export async function calculateSmartRebalancePlan({ args: [market.uniqueKey as `0x${string}`], })), allowFailure: true, + blockTag: 'latest', }); const userSupplyByMarket = new Map( @@ -119,11 +128,7 @@ export async function calculateSmartRebalancePlan({ }); const currentSupply = userSupplyByMarket.get(market.uniqueKey) ?? 0n; - const normalizedMaxBps = normalizeMaxBps(constraints?.[market.uniqueKey]?.maxAllocationBps); - const allowFullWithdraw = normalizedMaxBps === 0; - const safeSupply = currentSupply > DUST_AMOUNT ? currentSupply - DUST_AMOUNT : 0n; - const maxFromUser = allowFullWithdraw ? currentSupply : safeSupply; - const maxWithdrawable = maxFromUser < baselineMarket.liquidity ? maxFromUser : baselineMarket.liquidity; + const maxWithdrawable = currentSupply < baselineMarket.liquidity ? currentSupply : baselineMarket.liquidity; return [ { @@ -138,64 +143,7 @@ export async function calculateSmartRebalancePlan({ if (entries.length === 0) return null; - const suppliedEntries = entries.filter((entry) => entry.currentSupply > 0n); - const totalPool = entries.reduce((sum, entry) => sum + entry.currentSupply, 0n); - const withdrawAllRequested = - suppliedEntries.length > 0 && - suppliedEntries.every((entry) => { - const normalized = normalizeMaxBps(constraints?.[entry.uniqueKey]?.maxAllocationBps); - return normalized === 0; - }); - - const hasDestinationCapacity = entries.some((entry) => { - const normalized = normalizeMaxBps(constraints?.[entry.uniqueKey]?.maxAllocationBps); - if (normalized === 0) return false; - if (normalized === undefined || normalized >= 10_000) return true; - - const maxAllowed = (totalPool * BigInt(normalized)) / 10_000n; - return entry.currentSupply < maxAllowed; - }); - - if (withdrawAllRequested && !hasDestinationCapacity) { - const currentObjective = entries.reduce((sum, entry) => sum + entry.currentSupply * toApyScaled(entry.baselineMarket.supplyApy), 0n); - - const deltas = entries.map((entry) => { - const withdrawAmount = entry.maxWithdrawable < entry.currentSupply ? entry.maxWithdrawable : entry.currentSupply; - const targetAmount = entry.currentSupply - withdrawAmount; - const projectedMarket = withdrawAmount > 0n ? entry.baselineMarket.withdraw(withdrawAmount, 0n).market : entry.baselineMarket; - - return { - market: entry.market, - currentAmount: entry.currentSupply, - targetAmount, - delta: targetAmount - entry.currentSupply, - currentApy: entry.baselineMarket.supplyApy, - projectedApy: projectedMarket.supplyApy, - currentUtilization: Number(entry.baselineMarket.utilization) / 1e18, - projectedUtilization: Number(projectedMarket.utilization) / 1e18, - collateralSymbol: entry.market.collateralAsset?.symbol ?? 'N/A', - }; - }); - - const projectedObjective = deltas.reduce((sum, delta) => sum + delta.targetAmount * toApyScaled(delta.projectedApy), 0n); - const totalMoved = deltas.reduce((sum, delta) => (delta.delta < 0n ? sum + -delta.delta : sum), 0n); - - return { - deltas: deltas.sort((a, b) => { - if (b.delta > a.delta) return 1; - if (b.delta < a.delta) return -1; - return 0; - }), - totalPool, - currentWeightedApy: objectiveToWeightedApy(currentObjective, totalPool), - projectedWeightedApy: objectiveToWeightedApy(projectedObjective, totalPool), - totalMoved, - loanAssetSymbol: groupedPosition.loanAssetSymbol, - loanAssetDecimals: groupedPosition.loanAssetDecimals, - }; - } - - const optimized = optimizeSmartRebalance({ + const optimized = planRebalance({ entries, constraints, }); diff --git a/src/features/swap/components/SlippageInlineEditor.tsx b/src/features/swap/components/SlippageInlineEditor.tsx index 787acda5..3e4d84f5 100644 --- a/src/features/swap/components/SlippageInlineEditor.tsx +++ b/src/features/swap/components/SlippageInlineEditor.tsx @@ -1,15 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { Pencil1Icon } from '@radix-ui/react-icons'; -import { - MAX_SLIPPAGE_PERCENT, - MIN_SLIPPAGE_PERCENT, - clampSlippagePercent, -} from '@/features/swap/constants'; -import { - isValidDecimalInput, - sanitizeDecimalInput, - toParseableDecimalInput, -} from '@/utils/decimal-input'; +import { MAX_SLIPPAGE_PERCENT, MIN_SLIPPAGE_PERCENT, clampSlippagePercent } from '@/features/swap/constants'; +import { isValidDecimalInput, sanitizeDecimalInput, toParseableDecimalInput } from '@/utils/decimal-input'; import { formatSlippagePercent } from '../utils/quote-preview'; type SlippageInlineEditorProps = { diff --git a/src/hooks/useSmartRebalance.ts b/src/hooks/useSmartRebalance.ts index 4a5d9f33..00c064c8 100644 --- a/src/hooks/useSmartRebalance.ts +++ b/src/hooks/useSmartRebalance.ts @@ -125,11 +125,28 @@ export const useSmartRebalance = (groupedPosition: GroupedPosition, plan: SmartR return feeBreakdown.totalFee; }, [feeBreakdown.totalFee]); + const transferAmountEstimate = useMemo(() => { + if (!plan) return 0n; + + let total = 0n; + for (const delta of plan.deltas) { + if (delta.delta <= 0n) continue; + + const feeForMarket = feeBreakdown.feeByMarket.get(delta.market.uniqueKey) ?? 0n; + const reducedAmount = delta.delta - feeForMarket; + if (reducedAmount <= 0n) continue; + + total += reducedAmount + feeForMarket; + } + + return total; + }, [feeBreakdown.feeByMarket, plan]); + const execution = useRebalanceExecution({ chainId: groupedPosition.chainId, loanAssetAddress: groupedPosition.loanAssetAddress as Address, loanAssetSymbol: groupedPosition.loanAsset, - requiredAmount: totalMoved, + requiredAmount: transferAmountEstimate, trackingType: 'smart-rebalance', toastId: 'smart-rebalance', pendingText: 'Smart rebalancing positions', @@ -146,6 +163,7 @@ export const useSmartRebalance = (groupedPosition: GroupedPosition, plan: SmartR const withdrawTxs: `0x${string}`[] = []; const supplyTxs: `0x${string}`[] = []; const touchedMarketKeys = new Set(); + let transferAmount = 0n; for (const delta of plan.deltas) { if (delta.delta >= 0n) continue; @@ -215,9 +233,11 @@ export const useSmartRebalance = (groupedPosition: GroupedPosition, plan: SmartR lltv: BigInt(market.lltv), }; - const reducedAmount = delta.delta - (feeBreakdown.feeByMarket.get(market.uniqueKey) ?? 0n); + const feeForMarket = feeBreakdown.feeByMarket.get(market.uniqueKey) ?? 0n; + const reducedAmount = delta.delta - feeForMarket; if (reducedAmount <= 0n) continue; + transferAmount += reducedAmount + feeForMarket; supplyTxs.push( encodeFunctionData({ abi: morphoBundlerAbi, @@ -227,7 +247,7 @@ export const useSmartRebalance = (groupedPosition: GroupedPosition, plan: SmartR ); } - return { withdrawTxs, supplyTxs, allMarketKeys: [...touchedMarketKeys] }; + return { withdrawTxs, supplyTxs, allMarketKeys: [...touchedMarketKeys], transferAmount }; }, [account, feeBreakdown.feeByMarket, groupedPosition.markets, plan]); const executeSmartRebalance = useCallback( @@ -236,8 +256,8 @@ export const useSmartRebalance = (groupedPosition: GroupedPosition, plan: SmartR return false; } - const { withdrawTxs, supplyTxs, allMarketKeys } = generateSmartRebalanceTxData(); - const isWithdrawOnly = supplyTxs.length === 0; + const { withdrawTxs, supplyTxs, allMarketKeys, transferAmount } = generateSmartRebalanceTxData(); + const isWithdrawOnly = supplyTxs.length === 0 || transferAmount === 0n; let gasEstimate = GAS_COSTS.BUNDLER_REBALANCE; if (supplyTxs.length > 1) { @@ -268,7 +288,7 @@ export const useSmartRebalance = (groupedPosition: GroupedPosition, plan: SmartR supplyTxs, trailingTxs, gasEstimate, - transferAmount: isWithdrawOnly ? 0n : totalMoved, + transferAmount: isWithdrawOnly ? 0n : transferAmount, requiresAssetTransfer: !isWithdrawOnly, onSubmitted: () => { batchAddUserMarkets( diff --git a/src/modals/leverage/components/add-collateral-and-leverage.tsx b/src/modals/leverage/components/add-collateral-and-leverage.tsx index 8e35cb4b..930dbc06 100644 --- a/src/modals/leverage/components/add-collateral-and-leverage.tsx +++ b/src/modals/leverage/components/add-collateral-and-leverage.tsx @@ -152,7 +152,12 @@ export function AddCollateralAndLeverage({ const collateralAssetPriceUsd = useMemo(() => { const totalCollateralAssets = BigInt(market.state.collateralAssets); const totalCollateralAssetsUsd = market.state.collateralAssetsUsd; - if (totalCollateralAssets <= 0n || totalCollateralAssetsUsd == null || !Number.isFinite(totalCollateralAssetsUsd) || totalCollateralAssetsUsd <= 0) { + if ( + totalCollateralAssets <= 0n || + totalCollateralAssetsUsd == null || + !Number.isFinite(totalCollateralAssetsUsd) || + totalCollateralAssetsUsd <= 0 + ) { return null; } @@ -185,7 +190,10 @@ export function AddCollateralAndLeverage({ if (netAddedCollateral <= 0n) return 'Net collateral after fee must be positive.'; return null; }, [hasQuoteChanges, collateralAssetPriceUsd, leverageTransferFee, netAddedCollateral]); - const addedCollateralAssets = useMemo(() => (isLeverageFeeReady && netAddedCollateral != null ? netAddedCollateral : 0n), [isLeverageFeeReady, netAddedCollateral]); + const addedCollateralAssets = useMemo( + () => (isLeverageFeeReady && netAddedCollateral != null ? netAddedCollateral : 0n), + [isLeverageFeeReady, netAddedCollateral], + ); const addedBorrowAssets = useMemo(() => (isLeverageFeeReady ? quote.flashLoanAmount : 0n), [isLeverageFeeReady, quote.flashLoanAmount]); const { projectedCollateralAssets, projectedBorrowAssets } = useMemo( () => @@ -559,13 +567,16 @@ export function AddCollateralAndLeverage({ if (collateralYieldRate == null || borrowRateForCarry == null || projectedLtvRatio == null) return null; const netRate = collateralYieldRate - projectedLtvRatio * borrowRateForCarry; return Number.isFinite(netRate) ? netRate : null; - }, [ - collateralYieldRate, - borrowRateForCarry, - projectedLtvRatio, - ]); + }, [collateralYieldRate, borrowRateForCarry, projectedLtvRatio]); const previewLeveredCarryOnCapitalRate = useMemo(() => { - if (collateralYieldRate == null || borrowRateForCarry == null || addedDebtToCollateralRatio == null || contributedCapitalAssets == null || addedCollateralAssets <= 0n) return null; + if ( + collateralYieldRate == null || + borrowRateForCarry == null || + addedDebtToCollateralRatio == null || + contributedCapitalAssets == null || + addedCollateralAssets <= 0n + ) + return null; const incrementalNetCarryRate = collateralYieldRate - addedDebtToCollateralRatio * borrowRateForCarry; if (!Number.isFinite(incrementalNetCarryRate)) return null; @@ -575,13 +586,7 @@ export function AddCollateralAndLeverage({ const leveredCarryRate = incrementalNetCarryRate * leverageFactor; return Number.isFinite(leveredCarryRate) ? leveredCarryRate : null; - }, [ - addedCollateralAssets, - addedDebtToCollateralRatio, - collateralYieldRate, - borrowRateForCarry, - contributedCapitalAssets, - ]); + }, [addedCollateralAssets, addedDebtToCollateralRatio, collateralYieldRate, borrowRateForCarry, contributedCapitalAssets]); const isLeveredCarryLoading = isLeverageFeeReady && ((isErc4626Route && (vaultRateInsight.isLoading || vaultRateInsight.vaultApy3d == null || vaultRateInsight.sharePriceNow == null)) || @@ -797,9 +802,7 @@ export function AddCollateralAndLeverage({ {shouldShowHoldRewardsRow && (
{holdRewardsLabel} - - {merklHoldIncentives.loading ? '...' : renderRateFromApy(holdRewardsApy)} - + {merklHoldIncentives.loading ? '...' : renderRateFromApy(holdRewardsApy)}
)} {shouldShowNetRate && ( diff --git a/src/utils/merklApi.ts b/src/utils/merklApi.ts index 0be773fb..ccfbeb01 100644 --- a/src/utils/merklApi.ts +++ b/src/utils/merklApi.ts @@ -105,7 +105,14 @@ export const isLiveHoldOpportunity = (opportunity: MerklOpportunity | null | und const apr = opportunity.apr; const hasLiveCampaigns = opportunity.liveCampaigns == null || opportunity.liveCampaigns > 0; - return action === MERKL_HOLD_ACTION && status === MERKL_LIVE_STATUS && hasLiveCampaigns && typeof apr === 'number' && Number.isFinite(apr) && apr > 0; + return ( + action === MERKL_HOLD_ACTION && + status === MERKL_LIVE_STATUS && + hasLiveCampaigns && + typeof apr === 'number' && + Number.isFinite(apr) && + apr > 0 + ); }; export const getMerklOpportunityAprDecimal = (opportunity: MerklOpportunity | null | undefined): number | null => { From e720003c8d2d32ff32d60f8ad568a5b78a5eaa0a Mon Sep 17 00:00:00 2001 From: antoncoding Date: Wed, 4 Mar 2026 17:05:33 +0800 Subject: [PATCH 2/2] chore: review fixes --- .../components/position-header.tsx | 50 +++++++++++-------- .../components/position-actions-dropdown.tsx | 5 +- .../supplied-morpho-blue-grouped-table.tsx | 5 ++ 3 files changed, 37 insertions(+), 23 deletions(-) diff --git a/src/features/position-detail/components/position-header.tsx b/src/features/position-detail/components/position-header.tsx index aae6f219..6fe62961 100644 --- a/src/features/position-detail/components/position-header.tsx +++ b/src/features/position-detail/components/position-header.tsx @@ -5,6 +5,7 @@ import Image from 'next/image'; import { ChevronDownIcon } from '@radix-ui/react-icons'; import { TbArrowsRightLeft } from 'react-icons/tb'; import { RiBookmarkFill, RiBookmarkLine } from 'react-icons/ri'; +import { useConnection } from 'wagmi'; import { Button } from '@/components/ui/button'; import { TokenIcon } from '@/components/shared/token-icon'; import { Tooltip } from '@/components/ui/tooltip'; @@ -50,10 +51,13 @@ export function PositionHeader({ periodLabel, }: PositionHeaderProps) { const router = useRouter(); + const { address: connectedAddress } = useConnection(); const { isAprDisplay } = useAppSettings(); const { short: rateLabel } = useRateLabel(); const { open: openModal } = useModalStore(); const { togglePositionBookmark, isPositionBookmarked } = usePortfolioBookmarks(); + const isOwner = !!connectedAddress && connectedAddress.toLowerCase() === userAddress.toLowerCase(); + const showRebalance = isOwner; const networkImg = getNetworkImg(chainId); const isPositionSaved = groupedPosition && isPositionBookmarked(userAddress, chainId, groupedPosition.loanAssetAddress); @@ -65,7 +69,7 @@ export function PositionHeader({ }; const handleRebalanceClick = () => { - if (!groupedPosition) return; + if (!groupedPosition || !isOwner) return; openModal('rebalance', { groupedPosition, refetch: onRefetch, @@ -191,7 +195,7 @@ export function PositionHeader({ {/* RIGHT: Stats + Actions */}
{/* Key Stats */} -
+

Total Supply

@@ -302,26 +306,28 @@ export function PositionHeader({ - - } - > - - - - + {showRebalance && ( + + } + > + + + + + )}
diff --git a/src/features/positions/components/position-actions-dropdown.tsx b/src/features/positions/components/position-actions-dropdown.tsx index 4502f685..8a8e8410 100644 --- a/src/features/positions/components/position-actions-dropdown.tsx +++ b/src/features/positions/components/position-actions-dropdown.tsx @@ -7,10 +7,11 @@ import { Button } from '@/components/ui/button'; import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu'; type PositionActionsDropdownProps = { + isOwner: boolean; onRebalanceClick: () => void; }; -export function PositionActionsDropdown({ onRebalanceClick }: PositionActionsDropdownProps) { +export function PositionActionsDropdown({ isOwner, onRebalanceClick }: PositionActionsDropdownProps) { const handleClick = (e: React.MouseEvent) => { e.stopPropagation(); }; @@ -40,6 +41,8 @@ export function PositionActionsDropdown({ onRebalanceClick }: PositionActionsDro } + disabled={!isOwner} + className={isOwner ? '' : 'cursor-not-allowed opacity-50'} > 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 b6289c3f..859ddda1 100644 --- a/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx +++ b/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx @@ -10,6 +10,7 @@ import Image from 'next/image'; import moment from 'moment'; import { PulseLoader } from 'react-spinners'; import { useRouter } from 'next/navigation'; +import { useConnection } from 'wagmi'; import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table'; import { Button } from '@/components/ui/button'; import { TokenIcon } from '@/components/shared/token-icon'; @@ -58,6 +59,7 @@ export function SuppliedMorphoBlueGroupedTable({ transactions, snapshotsByChain, }: SuppliedMorphoBlueGroupedTableProps) { + const { address } = useConnection(); const period = usePositionsFilters((s) => s.period); const setPeriod = usePositionsFilters((s) => s.setPeriod); @@ -80,6 +82,7 @@ export function SuppliedMorphoBlueGroupedTable({ }; const groupedPositions = useMemo(() => groupPositionsByLoanAsset(positions), [positions]); + const isOwner = useMemo(() => !!account && !!address && account.toLowerCase() === address.toLowerCase(), [account, address]); const processedPositions = useMemo(() => processCollaterals(groupedPositions), [groupedPositions]); @@ -322,7 +325,9 @@ export function SuppliedMorphoBlueGroupedTable({ >
{ + if (!isOwner) return; openModal('rebalance', { groupedPosition, refetch,