diff --git a/.eslintrc.js b/.eslintrc.js index 10119725..b591ae69 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -171,6 +171,7 @@ module.exports = { // We prefer labels to be associated with inputs 'jsx-a11y/label-has-associated-control': ['off'], 'jsx-a11y/control-has-associated-label': ['off'], + 'jsx-a11y/no-static-element-interactions': ['off'], 'jsx-a11y/label-has-for': ['error', { 'required': { 'some': ['nesting', 'id'] diff --git a/app/positions/components/FromAndToMarkets.tsx b/app/positions/components/FromAndToMarkets.tsx new file mode 100644 index 00000000..242e82e8 --- /dev/null +++ b/app/positions/components/FromAndToMarkets.tsx @@ -0,0 +1,328 @@ +import React from 'react'; +import { Input } from '@nextui-org/react'; +import { Pagination } from '@nextui-org/react'; +import Image from 'next/image'; +import { formatUnits } from 'viem'; +import { Market } from '@/hooks/useMarkets'; +import { formatReadable } from '@/utils/balance'; +import { getAssetURL } from '@/utils/external'; +import { findToken } from '@/utils/tokens'; +import { MarketPosition } from '@/utils/types'; +import { + MarketAssetIndicator, + MarketOracleIndicator, + MarketDebtIndicator, +} from '../../markets/components/RiskIndicator'; + +import { PER_PAGE } from './RebalanceModal'; + +type MarketTablesProps = { + eligibleMarkets: Market[]; + fromMarkets: (MarketPosition & { pendingDelta: number })[]; + toMarkets: Market[]; + fromFilter: string; + toFilter: string; + onFromFilterChange: (value: string) => void; + onToFilterChange: (value: string) => void; + onFromMarketSelect: (marketUniqueKey: string) => void; + onToMarketSelect: (marketUniqueKey: string) => void; + fromPagination: { + currentPage: number; + totalPages: number; + onPageChange: (page: number) => void; + }; + toPagination: { + currentPage: number; + totalPages: number; + onPageChange: (page: number) => void; + }; + selectedFromMarketUniqueKey: string; + selectedToMarketUniqueKey: string; +}; + +export function FromAndToMarkets({ + eligibleMarkets, + fromMarkets, + toMarkets, + fromFilter, + toFilter, + onFromFilterChange, + onToFilterChange, + onFromMarketSelect, + onToMarketSelect, + fromPagination, + toPagination, + selectedFromMarketUniqueKey, + selectedToMarketUniqueKey, +}: MarketTablesProps) { + const filteredFromMarkets = fromMarkets.filter( + (marketPosition) => + marketPosition.market.uniqueKey.toLowerCase().includes(fromFilter.toLowerCase()) || + marketPosition.market.collateralAsset.symbol.toLowerCase().includes(fromFilter.toLowerCase()), + ); + + const filteredToMarkets = toMarkets.filter( + (market) => + market.uniqueKey.toLowerCase().includes(toFilter.toLowerCase()) || + market.collateralAsset.symbol.toLowerCase().includes(toFilter.toLowerCase()), + ); + + const paginatedFromMarkets = filteredFromMarkets.slice( + (fromPagination.currentPage - 1) * PER_PAGE, + fromPagination.currentPage * PER_PAGE, + ); + const paginatedToMarkets = filteredToMarkets.slice( + (toPagination.currentPage - 1) * PER_PAGE, + toPagination.currentPage * PER_PAGE, + ); + + const handleFromPaginationChange = (page: number) => { + fromPagination.onPageChange(page); + }; + + const handleToPaginationChange = (page: number) => { + toPagination.onPageChange(page); + }; + + return ( +
+
+

Your Market Positions

+ onFromFilterChange(e.target.value)} + className="mb-2" + /> +
+ {fromMarkets.length === 0 ? ( +
+

Loading...

+
+ ) : ( + + + + + + + + + + + + {paginatedFromMarkets.map((marketPosition) => { + const collateralToken = findToken( + marketPosition.market.collateralAsset.address, + marketPosition.market.morphoBlue.chain.id, + ); + return ( + onFromMarketSelect(marketPosition.market.uniqueKey)} + className={`cursor-pointer border-b border-gray-200 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800 ${ + marketPosition.market.uniqueKey === selectedFromMarketUniqueKey + ? 'bg-gray-50 dark:bg-gray-800' + : '' + }`} + > + + + + + + + ); + })} + +
MarketCollateralLLTVAPYSupplied Amount
+ {marketPosition.market.uniqueKey.slice(2, 8)} + + + + {formatUnits(BigInt(marketPosition.market.lltv), 16)}% + + {formatReadable(marketPosition.market.dailyApys.netSupplyApy * 100)}% + + {formatReadable( + Number(marketPosition.supplyAssets) / + 10 ** marketPosition.market.loanAsset.decimals, + )}{' '} + {marketPosition.market.loanAsset.symbol} + {marketPosition.pendingDelta !== 0 && ( + 0 ? 'text-green-500' : 'text-red-500' + }`} + > + ({marketPosition.pendingDelta > 0 ? '+' : '-'} + {formatReadable( + Math.abs( + Number( + formatUnits( + BigInt(marketPosition.pendingDelta), + marketPosition.market.loanAsset.decimals, + ), + ), + ), + )} + ) + + )} +
+ )} +
+
+ {' '} + {/* Reserve height for pagination */} + {fromPagination.totalPages > 1 && ( // Only show pagination if more than 1 page +
+ +
+ )} +
+
+ +
+

Available Markets for Rebalancing

+ onToFilterChange(e.target.value)} + className="mb-2" + /> +
+ {toMarkets.length === 0 ? ( +
+

Loading...

+
+ ) : ( + + + + + + + + + + + + + + {paginatedToMarkets.map((market) => { + const collateralToken = findToken( + market.collateralAsset.address, + market.morphoBlue.chain.id, + ); + const completeMarket = eligibleMarkets.find( + (m) => m.uniqueKey === market.uniqueKey, + ); + return ( + onToMarketSelect(market.uniqueKey)} + className={`cursor-pointer border-b border-gray-200 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800 ${ + market.uniqueKey === selectedToMarketUniqueKey + ? 'bg-gray-50 dark:bg-gray-800' + : '' + }`} + > + + + + + + + + + ); + })} + +
MarketCollateralLLTVAPYTotal SupplyUtil RateRisks
+ {market.uniqueKey.slice(2, 8)} + + + {formatUnits(BigInt(market.lltv), 16)}%{formatReadable(market.state.supplyApy * 100)}% + {formatReadable( + Number(market.state.supplyAssets) / 10 ** market.loanAsset.decimals, + )}{' '} + {market.loanAsset.symbol} + + {formatReadable(market.state.utilization * 100)}% + + {completeMarket && ( +
+ + + +
+ )} +
+ )} +
+
+ {' '} + {/* Reserve height for pagination */} + {toPagination.totalPages > 1 && ( // Only show pagination if more than 1 page +
+ +
+ )} +
+
+
+ ); +} diff --git a/app/positions/components/MarketBadge.tsx b/app/positions/components/MarketBadge.tsx new file mode 100644 index 00000000..5ae05b99 --- /dev/null +++ b/app/positions/components/MarketBadge.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { formatUnits } from 'viem'; + +type MarketBadgeProps = { + market: + | { uniqueKey: string; lltv: string; collateralAsset: { symbol: string } } + | null + | undefined; +} + +export function MarketBadge({ market }: MarketBadgeProps) { + if (!market) + return Select market; + + return ( +
+ {market.uniqueKey.slice(2, 8)} |{' '} + {market.collateralAsset.symbol} | {formatUnits(BigInt(market.lltv), 16)} % +
+ ); +} diff --git a/app/positions/components/PositionsSummaryTable.tsx b/app/positions/components/PositionsSummaryTable.tsx index 8cf3af17..f580d1b5 100644 --- a/app/positions/components/PositionsSummaryTable.tsx +++ b/app/positions/components/PositionsSummaryTable.tsx @@ -1,11 +1,14 @@ import React, { useMemo, useState } from 'react'; import { ChevronDownIcon, ChevronUpIcon } from '@radix-ui/react-icons'; import Image from 'next/image'; +import { toast } from 'react-toastify'; +import { useAccount } from 'wagmi'; import { formatReadable, formatBalance } from '@/utils/balance'; import { getNetworkImg } from '@/utils/networks'; import { findToken } from '@/utils/tokens'; -import { MarketPosition } from '@/utils/types'; +import { MarketPosition, GroupedPosition } from '@/utils/types'; import { getCollateralColor } from '../utils/colors'; +import { RebalanceModal } from './RebalanceModal'; import { SuppliedMarketsDetail } from './SuppliedMarketsDetail'; type PositionTableProps = { @@ -14,32 +17,23 @@ type PositionTableProps = { setSelectedPosition: (position: MarketPosition) => void; }; -export type GroupedPosition = { - loanAsset: string; - loanAssetAddress: string; - chainId: number; - totalSupply: number; - totalWeightedApy: number; - collaterals: { address: string; symbol: string | undefined; amount: number }[]; - markets: MarketPosition[]; - processedCollaterals: { - address: string; - symbol: string | undefined; - amount: number; - percentage: number; - }[]; -}; - export function PositionsSummaryTable({ marketPositions, setShowModal, setSelectedPosition, }: PositionTableProps) { + const { address: account } = useAccount(); + const [expandedRows, setExpandedRows] = useState>(new Set()); + const [showRebalanceModal, setShowRebalanceModal] = useState(false); + const [selectedGroupedPosition, setSelectedGroupedPosition] = useState( + null, + ); const groupedPositions: GroupedPosition[] = useMemo(() => { return marketPositions.reduce((acc: GroupedPosition[], position) => { const loanAssetAddress = position.market.loanAsset.address; + const loanAssetDecimals = position.market.loanAsset.decimals; const chainId = position.market.morphoBlue.chain.id; let groupedPosition = acc.find( @@ -50,6 +44,7 @@ export function PositionsSummaryTable({ groupedPosition = { loanAsset: position.market.loanAsset.symbol || 'Unknown', loanAssetAddress, + loanAssetDecimals, chainId, totalSupply: 0, totalWeightedApy: 0, @@ -147,6 +142,7 @@ export function PositionsSummaryTable({ Total Supplied Avg APY Collateral Exposure + Actions @@ -192,7 +188,7 @@ export function PositionsSummaryTable({
{formatReadable(avgApy * 100)}%
-
+
{position.processedCollaterals.map((collateral, colIndex) => (
+ + + {isExpanded && ( - + + {showRebalanceModal && selectedGroupedPosition && ( + setShowRebalanceModal(false)} + isOpen={showRebalanceModal} + /> + )}
); } diff --git a/app/positions/components/RebalanceActionInput.tsx b/app/positions/components/RebalanceActionInput.tsx new file mode 100644 index 00000000..d7cfe592 --- /dev/null +++ b/app/positions/components/RebalanceActionInput.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { Button } from '@nextui-org/react'; +import { ArrowRightIcon } from '@radix-ui/react-icons'; +import Image from 'next/image'; +import { Market } from '@/hooks/useMarkets'; +import { ERC20Token } from '@/utils/tokens'; +import { GroupedPosition } from '@/utils/types'; +import { MarketBadge } from './MarketBadge'; + +type RebalanceActionInputProps = { + amount: string; + setAmount: (amount: string) => void; + selectedFromMarketUniqueKey: string; + selectedToMarketUniqueKey: string; + groupedPosition: GroupedPosition; + eligibleMarkets: Market[]; + token: ERC20Token | undefined; + onAddAction: () => void; +} + +export function RebalanceActionInput({ + amount, + setAmount, + selectedFromMarketUniqueKey, + selectedToMarketUniqueKey, + groupedPosition, + eligibleMarkets, + token, + onAddAction, +}: RebalanceActionInputProps) { + return ( +
+ Rebalance + setAmount(e.target.value)} + className="bg-hovered h-10 w-32 rounded p-2 focus:outline-none" + /> +
+ {groupedPosition.loanAsset} + {token?.img && ( + {groupedPosition.loanAsset} + )} +
+ From +
+ p.market.uniqueKey === selectedFromMarketUniqueKey) + ?.market + } + /> +
+ +
+ m.uniqueKey === selectedToMarketUniqueKey)} + /> +
+ +
+ ); +} diff --git a/app/positions/components/RebalanceCart.tsx b/app/positions/components/RebalanceCart.tsx new file mode 100644 index 00000000..edef195d --- /dev/null +++ b/app/positions/components/RebalanceCart.tsx @@ -0,0 +1,84 @@ +import React from 'react'; +import { + Table, + TableHeader, + TableColumn, + TableBody, + TableRow, + TableCell, + Button, +} from '@nextui-org/react'; +import { formatUnits } from 'viem'; +import { Market } from '@/hooks/useMarkets'; +import { GroupedPosition, RebalanceAction } from '@/utils/types'; +import { MarketBadge } from './MarketBadge'; + +type RebalanceCartProps = { + rebalanceActions: RebalanceAction[]; + groupedPosition: GroupedPosition; + eligibleMarkets: Market[]; + removeRebalanceAction: (index: number) => void; +} + +export function RebalanceCart({ + rebalanceActions, + groupedPosition, + eligibleMarkets, + removeRebalanceAction, +}: RebalanceCartProps) { + if (rebalanceActions.length === 0) { + return ( +

+ Your rebalance cart is empty. Add some actions! +

+ ); + } + + return ( + <> +

Rebalance Cart

+ + + From Market + To Market + Amount + Actions + + + {rebalanceActions.map((action, index) => ( + + + m.market.uniqueKey === action.fromMarket.uniqueKey, + )?.market + } + /> + + + m.uniqueKey === action.toMarket.uniqueKey)} + /> + + + {formatUnits(action.amount, groupedPosition.loanAssetDecimals)}{' '} + {groupedPosition.loanAsset} + + + + + + ))} + +
+ + ); +} diff --git a/app/positions/components/RebalanceModal.tsx b/app/positions/components/RebalanceModal.tsx new file mode 100644 index 00000000..c5ca524c --- /dev/null +++ b/app/positions/components/RebalanceModal.tsx @@ -0,0 +1,262 @@ +import React, { useState, useMemo, useCallback } from 'react'; +import { + Modal, + ModalContent, + ModalHeader, + ModalBody, + ModalFooter, + Button, +} from '@nextui-org/react'; +import { toast } from 'react-toastify'; +import { parseUnits } from 'viem'; +import useMarkets, { Market } from '@/hooks/useMarkets'; +import { usePagination } from '@/hooks/usePagination'; +import { useRebalance } from '@/hooks/useRebalance'; +import { findToken } from '@/utils/tokens'; +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; +}; + +export const PER_PAGE = 5; + +export function RebalanceModal({ groupedPosition, isOpen, onClose }: RebalanceModalProps) { + const [fromMarketFilter, setFromMarketFilter] = useState(''); + const [toMarketFilter, setToMarketFilter] = useState(''); + const [selectedFromMarketUniqueKey, setSelectedFromMarketUniqueKey] = useState(''); + const [selectedToMarketUniqueKey, setSelectedToMarketUniqueKey] = useState(''); + const [amount, setAmount] = useState('0'); + const [showProcessModal, setShowProcessModal] = useState(false); + + const { data: allMarkets } = useMarkets(); + const { + rebalanceActions, + addRebalanceAction, + removeRebalanceAction, + executeRebalance, + isConfirming, + currentStep, + } = useRebalance(groupedPosition); + + 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]); + + return ( + <> + + + + Rebalance {groupedPosition.loanAsset ?? 'Unknown'} Position + + +
+

+ Optimize your {groupedPosition.loanAsset} lending strategy by redistributing funds + across markets, add "Rebalance" actions to fine-tune your portfolio. +

+
+ + + + ({ + ...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} + /> + + +
+ + + + +
+
+ {showProcessModal && ( + setShowProcessModal(false)} + tokenSymbol={groupedPosition.loanAsset} + actionsCount={rebalanceActions.length} + /> + )} + + ); +} diff --git a/app/positions/components/RebalanceProcessModal.tsx b/app/positions/components/RebalanceProcessModal.tsx new file mode 100644 index 00000000..db750cad --- /dev/null +++ b/app/positions/components/RebalanceProcessModal.tsx @@ -0,0 +1,107 @@ +import React, { useMemo } from 'react'; +import { Cross1Icon } from '@radix-ui/react-icons'; +import { FaCheckCircle, FaCircle } from 'react-icons/fa'; + +type RebalanceProcessModalProps = { + currentStep: 'idle' | 'approve' | 'authorize' | 'sign' | 'execute'; + onClose: () => void; + tokenSymbol: string; + actionsCount: number; +}; + +export function RebalanceProcessModal({ + currentStep, + onClose, + tokenSymbol, + actionsCount, +}: RebalanceProcessModalProps): JSX.Element { + const steps = useMemo( + () => [ + { + key: 'idle', + label: 'Idle', + detail: 'Waiting to start the rebalance process.', + }, + { + key: 'approve', + label: 'Authorize Permit2', + detail: `This one-time approval ensures you don't need to send approval transactions in the future.`, + }, + { + key: 'authorize', + label: 'Authorize Morpho Bundler', + detail: 'Authorize the Morpho official bundler to execute batched actions.', + }, + { + key: 'sign', + label: 'Sign Permit', + detail: 'Sign a Permit2 signature to authorize the one time use of asset.', + }, + { + key: 'execute', + label: 'Confirm Rebalance', + detail: `Confirm transaction in wallet to execute ${actionsCount} rebalance action${ + actionsCount > 1 ? 's' : '' + }.`, + }, + ], + [actionsCount], + ); + + const getStepStatus = (stepKey: string) => { + if ( + steps.findIndex((step) => step.key === stepKey) < + steps.findIndex((step) => step.key === currentStep) + ) { + return 'done'; + } + if (stepKey === currentStep) { + return 'current'; + } + return 'undone'; + }; + + return ( +
+
+ + +
+ Rebalancing {tokenSymbol} Positions +
+ +
+ {steps + .filter((step) => step.key !== 'idle') + .map((step, index) => ( +
+
+ {getStepStatus(step.key) === 'done' && } + {getStepStatus(step.key) === 'current' &&
} + {getStepStatus(step.key) === 'undone' && } +
+
+
{step.label}
+ {currentStep === step.key && step.detail && ( +
+ {step.detail} +
+ )} +
+ {index < steps.length - 2 &&
} +
+ ))} +
+
+
+ ); +} diff --git a/app/positions/components/SuppliedMarketsDetail.tsx b/app/positions/components/SuppliedMarketsDetail.tsx index d3b8938c..8d583df3 100644 --- a/app/positions/components/SuppliedMarketsDetail.tsx +++ b/app/positions/components/SuppliedMarketsDetail.tsx @@ -4,8 +4,7 @@ import Image from 'next/image'; import { formatReadable, formatBalance } from '@/utils/balance'; import { getMarketURL } from '@/utils/external'; import { findToken } from '@/utils/tokens'; -import { MarketPosition } from '@/utils/types'; -import { GroupedPosition } from './PositionsSummaryTable'; +import { MarketPosition, GroupedPosition } from '@/utils/types'; type SuppliedMarketsDetailProps = { groupedPosition: GroupedPosition; @@ -28,7 +27,7 @@ export function SuppliedMarketsDetail({ const totalSupply = groupedPosition.totalSupply; return ( -
+
@@ -68,7 +67,6 @@ export function SuppliedMarketsDetail({
{position.market.collateralAsset ? (
- {position.market.collateralAsset.symbol} {findToken( position.market.collateralAsset.address, position.market.morphoBlue.chain.id, @@ -85,6 +83,7 @@ export function SuppliedMarketsDetail({ height={18} /> )} + {position.market.collateralAsset.symbol}
) : ( 'N/A' diff --git a/src/components/layout/header/AccountConnect.tsx b/src/components/layout/header/AccountConnect.tsx index d7a0a55a..7274d934 100644 --- a/src/components/layout/header/AccountConnect.tsx +++ b/src/components/layout/header/AccountConnect.tsx @@ -38,7 +38,7 @@ function AccountConnect() { diff --git a/src/hooks/useRebalance.ts b/src/hooks/useRebalance.ts new file mode 100644 index 00000000..713e0223 --- /dev/null +++ b/src/hooks/useRebalance.ts @@ -0,0 +1,305 @@ +import { useState, useCallback } from 'react'; +import { toast } from 'react-toastify'; +import { Address, encodeFunctionData, maxUint256, parseSignature } from 'viem'; +import { useAccount, useReadContract, useSignTypedData } from 'wagmi'; +import morphoBundlerAbi from '@/abis/bundlerV2'; +import morphoAbi from '@/abis/morpho'; +import { useTransactionWithToast } from '@/hooks/useTransactionWithToast'; +import { getBundlerV2, MORPHO } from '@/utils/morpho'; +import { GroupedPosition, RebalanceAction } from '@/utils/types'; +import { usePermit2 } from './usePermit2'; + +export const useRebalance = (groupedPosition: GroupedPosition) => { + const [rebalanceActions, setRebalanceActions] = useState([]); + const [isConfirming, setIsConfirming] = useState(false); + const [currentStep, setCurrentStep] = useState< + 'idle' | 'approve' | 'authorize' | 'sign' | 'execute' + >('idle'); + + const { address: account } = useAccount(); + const { signTypedDataAsync } = useSignTypedData(); + const bundlerAddress = getBundlerV2(groupedPosition.chainId); + + const { data: isAuthorized } = useReadContract({ + address: MORPHO, + abi: morphoAbi, + functionName: 'isAuthorized', + args: [account as Address, bundlerAddress as Address], + chainId: groupedPosition.chainId, + }); + + const { data: nonce } = useReadContract({ + address: MORPHO, + abi: morphoAbi, + functionName: 'nonce', + args: [account as Address], + chainId: groupedPosition.chainId, + }); + + const totalAmount = rebalanceActions.reduce( + (acc, action) => acc + BigInt(action.amount), + BigInt(0), + ); + + const { authorizePermit2, permit2Authorized, signForBundlers } = usePermit2({ + user: account as `0x${string}`, + spender: getBundlerV2(groupedPosition.chainId), + token: groupedPosition.loanAssetAddress as `0x${string}`, + refetchInterval: 10000, + chainId: groupedPosition.chainId, + tokenSymbol: groupedPosition.loanAsset, + amount: totalAmount, + }); + + const addRebalanceAction = useCallback((action: RebalanceAction) => { + setRebalanceActions((prev) => [...prev, action]); + }, []); + + const removeRebalanceAction = useCallback((index: number) => { + setRebalanceActions((prev) => prev.filter((_, i) => i !== index)); + }, []); + + const { sendTransactionAsync } = useTransactionWithToast({ + toastId: 'rebalance', + pendingText: 'Rebalancing positions', + successText: 'Positions rebalanced successfully', + errorText: 'Failed to rebalance positions', + chainId: groupedPosition.chainId, + }); + + const executeRebalance = useCallback(async () => { + if (!account) { + return; + } + setIsConfirming(true); + const transactions: `0x${string}`[] = []; + + try { + // Step 1: Authorize Permit2 if needed + setCurrentStep('approve'); + if (!permit2Authorized) { + await authorizePermit2(); + + await new Promise((resolve) => setTimeout(resolve, 800)); + } + + // Step 2: Sign and authorize bundler if needed + setCurrentStep('authorize'); + if (isAuthorized === false) { + const domain = { + chainId: groupedPosition.chainId, + verifyingContract: MORPHO as Address, + }; + + const types = { + Authorization: [ + { name: 'authorizer', type: 'address' }, + { name: 'authorized', type: 'address' }, + { name: 'isAuthorized', type: 'bool' }, + { name: 'nonce', type: 'uint256' }, + { name: 'deadline', type: 'uint256' }, + ], + }; + + const deadline = Math.floor(Date.now() / 1000) + 3600; + + const value = { + authorizer: account, + authorized: bundlerAddress, + isAuthorized: true, + nonce: nonce, + deadline: BigInt(deadline), + }; + + let signatureRaw; + try { + signatureRaw = await signTypedDataAsync({ + domain, + types, + primaryType: 'Authorization', + message: value, + }); + } catch (error) { + toast.error('Signature request was rejected or failed. Please try again.'); + return; + } + const signature = parseSignature(signatureRaw); + + const authorizationTx = encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'morphoSetAuthorizationWithSig', + args: [ + { + authorizer: account as Address, + authorized: bundlerAddress, + isAuthorized: true, + nonce: BigInt(nonce ?? 0), + deadline: BigInt(deadline), + }, + { + v: Number(signature.v), + r: signature.r, + s: signature.s, + }, + false, + ], + }); + + transactions.push(authorizationTx); + + // wait 800ms to avoid rabby wallet issue + await new Promise((resolve) => setTimeout(resolve, 800)); + } + + // Step 3: Sign permit for USDC + setCurrentStep('sign'); + const { sigs, permitSingle } = await signForBundlers(); + console.log('Signed for bundlers:', { sigs, permitSingle }); + + const permitTx = encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'approve2', + args: [permitSingle, sigs, false], + }); + const transferFromTx = encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'transferFrom2', + args: [groupedPosition.loanAssetAddress as Address, totalAmount], + }); + + // don't push the transferFromTx to the array, do it after all withdrawals. Here we only dealt with permit + transactions.push(permitTx); + + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // Step 4: Append rebalance actions and generate tx + setCurrentStep('execute'); + + const withdrawTxs: `0x${string}`[] = []; + const supplyTxs: `0x${string}`[] = []; + + // Group actions by market + const groupedWithdraws: Record = {}; + const groupedSupplies: Record = {}; + + rebalanceActions.forEach((action) => { + const withdrawKey = action.fromMarket.uniqueKey; + const supplyKey = action.toMarket.uniqueKey; + + if (!groupedWithdraws[withdrawKey]) groupedWithdraws[withdrawKey] = []; + if (!groupedSupplies[supplyKey]) groupedSupplies[supplyKey] = []; + + groupedWithdraws[withdrawKey].push(action); + groupedSupplies[supplyKey].push(action); + }); + + // Generate batched withdraw transactions + Object.values(groupedWithdraws).forEach((actions) => { + const batchAmount = actions.reduce((sum, action) => sum + BigInt(action.amount), BigInt(0)); + const market = actions[0].fromMarket; + + const withdrawTx = encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'morphoWithdraw', + args: [ + { + loanToken: market.loanToken as Address, + collateralToken: market.collateralToken as Address, + oracle: market.oracle as Address, + irm: market.irm as Address, + lltv: BigInt(market.lltv), + }, + batchAmount, // assets + BigInt(0), // shares + maxUint256, // slippageAmount => max share burned + account, // receiver + ], + }); + + withdrawTxs.push(withdrawTx); + }); + + // Generate batched supply transactions + Object.values(groupedSupplies).forEach((actions) => { + const bachedAmount = actions.reduce( + (sum, action) => sum + BigInt(action.amount), + BigInt(0), + ); + const market = actions[0].toMarket; + + const supplyTx = encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'morphoSupply', + args: [ + { + loanToken: market.loanToken as Address, + collateralToken: market.collateralToken as Address, + oracle: market.oracle as Address, + irm: market.irm as Address, + lltv: BigInt(market.lltv), + }, + bachedAmount, + BigInt(0), + BigInt(0), // slippageAmount => min share minted + account, + '0x', + ], + }); + + supplyTxs.push(supplyTx); + }); + + // Reorder transactions + transactions.push(...withdrawTxs); + transactions.push(transferFromTx); + transactions.push(...supplyTxs); + + // Execute all transactions + const multicallTx = encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'multicall', + args: [transactions], + }); + + await sendTransactionAsync({ + account, + to: bundlerAddress, + data: multicallTx, + chainId: groupedPosition.chainId, + }); + + setRebalanceActions([]); + } catch (error) { + console.error('Error during rebalance:', error); + toast.error('An error occurred during rebalance. Please try again.'); + throw error; + } finally { + setIsConfirming(false); + setCurrentStep('idle'); + } + }, [ + account, + permit2Authorized, + authorizePermit2, + signForBundlers, + isAuthorized, + nonce, + bundlerAddress, + groupedPosition.chainId, + signTypedDataAsync, + rebalanceActions, + sendTransactionAsync, + groupedPosition.loanAssetAddress, + totalAmount, + ]); + + return { + rebalanceActions, + addRebalanceAction, + removeRebalanceAction, + executeRebalance, + isConfirming, + currentStep, + isAuthorized: permit2Authorized, + }; +}; diff --git a/src/hooks/useUserPositions.ts b/src/hooks/useUserPositions.ts index fb3d6ea6..b3164dd0 100644 --- a/src/hooks/useUserPositions.ts +++ b/src/hooks/useUserPositions.ts @@ -52,6 +52,8 @@ const query = `query getUserMarketPositions( liquidityAssets supplyAssetsUsd supplyAssets + borrowAssets + borrowAssetsUsd rewards { yearlySupplyTokens asset { @@ -60,6 +62,26 @@ const query = `query getUserMarketPositions( spotPriceEth } } + utilization + } + oracleFeed { + baseFeedOneAddress + baseFeedOneDescription + baseFeedTwoAddress + baseFeedTwoDescription + quoteFeedOneAddress + quoteFeedOneDescription + quoteFeedTwoAddress + quoteFeedTwoDescription + baseVault + baseVaultDescription + baseVaultVendor + quoteVault + quoteVaultDescription + quoteVaultVendor + } + oracleInfo { + type } } } diff --git a/src/utils/types.ts b/src/utils/types.ts index edc28b76..487a3384 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -10,6 +10,10 @@ export type MarketPosition = { uniqueKey: string; lltv: string; oracleAddress: string; + oracleFeed?: OracleFeedsInfo; + oracleInfo: { + type: string; + }; irmAddress: string; morphoBlue: { id: string; @@ -41,6 +45,8 @@ export type MarketPosition = { liquidityAssets: string; supplyAssets: string; supplyAssetsUsd: number; + borrowAssets: string; + borrowAssetsUsd: number; rewards: { yearlySupplyTokens: string; asset: { @@ -49,6 +55,7 @@ export type MarketPosition = { spotPriceEth: string | null; }; }[]; + utilization: number; }; }; }; @@ -195,3 +202,40 @@ export type UniformRewardType = { // Combined RewardResponseType export type RewardResponseType = MarketProgramType | UniformRewardType; + +export type RebalanceAction = { + fromMarket: { + loanToken: string; + collateralToken: string; + oracle: string; + irm: string; + lltv: string; + uniqueKey: string; + }; + toMarket: { + loanToken: string; + collateralToken: string; + oracle: string; + irm: string; + lltv: string; + uniqueKey: string; + }; + amount: bigint; +}; + +export type GroupedPosition = { + loanAsset: string; + loanAssetAddress: string; + loanAssetDecimals: number; + chainId: number; + totalSupply: number; + totalWeightedApy: number; + collaterals: { address: string; symbol: string | undefined; amount: number }[]; + markets: MarketPosition[]; + processedCollaterals: { + address: string; + symbol: string | undefined; + amount: number; + percentage: number; + }[]; +};