app/positions/components/RebalanceModal.tsx
import React, { useState, useMemo, useCallback } from 'react';
import {
Modal,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
Button,
Spinner,
} from '@nextui-org/react';
import { GrRefresh } from 'react-icons/gr';
import { toast } from 'react-toastify';
import { parseUnits } from 'viem';
import useMarkets from '@/hooks/useMarkets';
import { usePagination } from '@/hooks/usePagination';
import { useRebalance } from '@/hooks/useRebalance';
import { findToken } from '@/utils/tokens';
import { Market } from '@/utils/types';
import { GroupedPosition, RebalanceAction } from '@/utils/types';
import { FromAndToMarkets } from './FromAndToMarkets';
import { RebalanceActionInput } from './RebalanceActionInput';
import { RebalanceCart } from './RebalanceCart';
import { RebalanceProcessModal } from './RebalanceProcessModal';
type RebalanceModalProps = {
groupedPosition: GroupedPosition;
isOpen: boolean;
onClose: () => void;
refetch: (onSuccess?: () => void) => void;
isRefetching: boolean;
};
export const PER_PAGE = 5;
export function RebalanceModal({
groupedPosition,
isOpen,
onClose,
refetch,
isRefetching,
}: RebalanceModalProps) {
const [fromMarketFilter, setFromMarketFilter] = useState('');
const [toMarketFilter, setToMarketFilter] = useState('');
const [selectedFromMarketUniqueKey, setSelectedFromMarketUniqueKey] = useState('');
const [selectedToMarketUniqueKey, setSelectedToMarketUniqueKey] = useState('');
const [amount, setAmount] = useState<string>('0');
const [showProcessModal, setShowProcessModal] = useState(false);
const { data: allMarkets } = useMarkets();
const {
rebalanceActions,
addRebalanceAction,
removeRebalanceAction,
executeRebalance,
isConfirming,
currentStep,
} = useRebalance(groupedPosition, refetch);
const token = findToken(groupedPosition.loanAssetAddress, groupedPosition.chainId);
const fromPagination = usePagination();
const toPagination = usePagination();
const eligibleMarkets = useMemo(() => {
return allMarkets.filter(
(market) =>
market.loanAsset.address === groupedPosition.loanAssetAddress &&
market.morphoBlue.chain.id === groupedPosition.chainId,
);
}, [allMarkets, groupedPosition.loanAssetAddress, groupedPosition.chainId]);
const getPendingDelta = (marketUniqueKey: string) => {
return rebalanceActions.reduce((acc: number, action: RebalanceAction) => {
if (action.fromMarket.uniqueKey === marketUniqueKey) {
return acc - Number(action.amount);
}
if (action.toMarket.uniqueKey === marketUniqueKey) {
return acc + Number(action.amount);
}
return acc;
}, 0);
};
const validateInputs = () => {
if (!selectedFromMarketUniqueKey || !selectedToMarketUniqueKey || !amount) {
toast.error('Please fill in all fields');
return false;
}
const scaledAmount = parseUnits(amount, groupedPosition.loanAssetDecimals);
if (scaledAmount <= 0) {
toast.error('Amount must be greater than zero');
return false;
}
return true;
};
const getMarkets = () => {
const fromMarket = eligibleMarkets.find((m) => m.uniqueKey === selectedFromMarketUniqueKey);
const toMarket = eligibleMarkets.find((m) => m.uniqueKey === selectedToMarketUniqueKey);
if (!fromMarket || !toMarket) {
toast.error('Invalid market selection');
return null;
}
return { fromMarket, toMarket };
};
const checkBalance = () => {
const oldBalance = groupedPosition.markets.find(
(m) => m.market.uniqueKey === selectedFromMarketUniqueKey,
)?.supplyAssets;
const pendingDelta = getPendingDelta(selectedFromMarketUniqueKey);
const pendingBalance = BigInt(oldBalance ?? 0) + BigInt(pendingDelta);
const scaledAmount = parseUnits(amount, groupedPosition.loanAssetDecimals);
if (scaledAmount > pendingBalance) {
toast.error('Insufficient balance for this action');
return false;
}
return true;
};
const createAction = (fromMarket: Market, toMarket: Market): RebalanceAction => {
return {
fromMarket: {
loanToken: fromMarket.loanAsset.address,
collateralToken: fromMarket.collateralAsset.address,
oracle: fromMarket.oracleAddress,
irm: fromMarket.irmAddress,
lltv: fromMarket.lltv,
uniqueKey: fromMarket.uniqueKey,
},
toMarket: {
loanToken: toMarket.loanAsset.address,
collateralToken: toMarket.collateralAsset.address,
oracle: toMarket.oracleAddress,
irm: toMarket.irmAddress,
lltv: toMarket.lltv,
uniqueKey: toMarket.uniqueKey,
},
amount: parseUnits(amount, groupedPosition.loanAssetDecimals),
};
};
const resetSelections = () => {
setSelectedFromMarketUniqueKey('');
setSelectedToMarketUniqueKey('');
setAmount('0');
};
const handleAddAction = () => {
if (!validateInputs()) return;
const markets = getMarkets();
if (!markets) return;
const { fromMarket, toMarket } = markets;
if (!checkBalance()) return;
addRebalanceAction(createAction(fromMarket, toMarket));
resetSelections();
};
const handleExecuteRebalance = useCallback(async () => {
setShowProcessModal(true);
try {
await executeRebalance();
} catch (error) {
console.error('Error during rebalance:', error);
} finally {
setShowProcessModal(false);
}
}, [executeRebalance]);
const handleManualRefresh = () => {
refetch(() => {
toast.info('Data refreshed', { icon: <span>🚀</span> });
});
};
return (
<>
<Modal
isOpen={isOpen}
onClose={onClose}
isDismissable={false}
size="5xl"
classNames={{
base: 'min-w-[1250px] z-[1000] p-4',
backdrop: showProcessModal && 'z-[999]',
}}
>
<ModalContent>
<ModalHeader className="flex items-center justify-between px-8 font-zen text-2xl">
<div className="flex items-center gap-2">
Rebalance {groupedPosition.loanAsset ?? 'Unknown'} Position
{isRefetching && <Spinner size="sm" />}
</div>
<button
onClick={handleManualRefresh}
disabled={isRefetching}
type="button"
className="flex items-center gap-2 rounded-md bg-gray-200 px-3 py-1 text-sm text-secondary transition-colors hover:bg-gray-300 disabled:opacity-50 dark:bg-gray-700 dark:hover:bg-gray-600"
>
<GrRefresh size={16} />
Refresh
</button>
</ModalHeader>
<ModalBody className="mx-2 font-zen">
<div className="mb-4 rounded-lg bg-gray-100 p-4 dark:border-gray-700 dark:bg-gray-800">
<p className="text-sm text-secondary">
Optimize your {groupedPosition.loanAsset} lending strategy by redistributing funds
across markets, add "Rebalance" actions to fine-tune your portfolio.
</p>
</div>
<RebalanceActionInput
amount={amount}
setAmount={setAmount}
selectedFromMarketUniqueKey={selectedFromMarketUniqueKey}
selectedToMarketUniqueKey={selectedToMarketUniqueKey}
groupedPosition={groupedPosition}
eligibleMarkets={eligibleMarkets}
token={token}
onAddAction={handleAddAction}
/>
<FromAndToMarkets
eligibleMarkets={eligibleMarkets}
fromMarkets={groupedPosition.markets.map((market) => ({
...market,
pendingDelta: getPendingDelta(market.market.uniqueKey),
}))}
toMarkets={eligibleMarkets}
fromFilter={fromMarketFilter}
toFilter={toMarketFilter}
onFromFilterChange={setFromMarketFilter}
onToFilterChange={setToMarketFilter}
onFromMarketSelect={setSelectedFromMarketUniqueKey}
onToMarketSelect={setSelectedToMarketUniqueKey}
fromPagination={{
currentPage: fromPagination.currentPage,
totalPages: Math.ceil(groupedPosition.markets.length / PER_PAGE),
onPageChange: fromPagination.setCurrentPage,
}}
toPagination={{
currentPage: toPagination.currentPage,
totalPages: Math.ceil(eligibleMarkets.length / PER_PAGE),
onPageChange: toPagination.setCurrentPage,
}}
selectedFromMarketUniqueKey={selectedFromMarketUniqueKey}
selectedToMarketUniqueKey={selectedToMarketUniqueKey}
/>
<RebalanceCart
rebalanceActions={rebalanceActions}
groupedPosition={groupedPosition}
eligibleMarkets={eligibleMarkets}
removeRebalanceAction={removeRebalanceAction}
/>
</ModalBody>
<ModalFooter className="mx-2">
<Button
variant="light"
onPress={onClose}
className="rounded-sm bg-gray-200 p-4 px-10 font-zen text-secondary opacity-80 transition-all duration-200 ease-in-out hover:scale-105 hover:opacity-100 dark:bg-gray-700 dark:text-gray-300"
>
Cancel
</Button>
<Button
color="primary"
onPress={() => void handleExecuteRebalance()}
isDisabled={isConfirming || rebalanceActions.length === 0}
isLoading={isConfirming}
className="rounded-sm bg-orange-500 p-4 px-10 font-zen text-white opacity-80 transition-all duration-200 ease-in-out hover:scale-105 hover:opacity-100 disabled:opacity-50 dark:bg-orange-600"
>
Execute Rebalance
</Button>
</ModalFooter>
</ModalContent>
</Modal>
{showProcessModal && (
<RebalanceProcessModal
currentStep={currentStep}
onClose={() => setShowProcessModal(false)}
tokenSymbol={groupedPosition.loanAsset}
actionsCount={rebalanceActions.length}
/>
)}
</>
);
}