From b1489b87c1883f36f8d26ebe74f1a5b8b00c028e Mon Sep 17 00:00:00 2001 From: Anton Cheng Date: Mon, 7 Oct 2024 19:43:40 +0800 Subject: [PATCH 01/13] feat: layout done 2 --- .eslintrc.js | 1 + app/markets/components/Pagination.tsx | 6 +- app/positions/components/MarketTables.tsx | 187 ++++++++++++++ .../components/PositionsSummaryTable.tsx | 30 ++- app/positions/components/RebalanceModal.tsx | 230 ++++++++++++++++++ .../components/SuppliedMarketsDetail.tsx | 4 +- src/hooks/useRebalance.ts | 175 +++++++++++++ src/hooks/useUserPositions.ts | 22 ++ src/utils/types.ts | 43 ++++ 9 files changed, 692 insertions(+), 6 deletions(-) create mode 100644 app/positions/components/MarketTables.tsx create mode 100644 app/positions/components/RebalanceModal.tsx create mode 100644 src/hooks/useRebalance.ts 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/markets/components/Pagination.tsx b/app/markets/components/Pagination.tsx index 893ed589..dd39f602 100644 --- a/app/markets/components/Pagination.tsx +++ b/app/markets/components/Pagination.tsx @@ -17,6 +17,7 @@ type PaginationProps = { onPageChange: (page: number) => void; entriesPerPage: number; onEntriesPerPageChange: (entries: number) => void; + showSettings?: boolean; }; export function Pagination({ @@ -25,6 +26,7 @@ export function Pagination({ onPageChange, entriesPerPage, onEntriesPerPageChange, + showSettings = true, }: PaginationProps) { const { isOpen, onOpen, onOpenChange } = useDisclosure(); const [customEntries, setCustomEntries] = useState(entriesPerPage.toString()); @@ -56,7 +58,7 @@ export function Pagination({ }} size="md" /> - + } diff --git a/app/positions/components/MarketTables.tsx b/app/positions/components/MarketTables.tsx new file mode 100644 index 00000000..eb100da4 --- /dev/null +++ b/app/positions/components/MarketTables.tsx @@ -0,0 +1,187 @@ +import React from 'react'; +import { Input } from "@nextui-org/react"; +import { formatUnits } from 'viem'; +import { formatReadable } from '@/utils/balance'; +import { HTSortable } from 'app/markets/components/MarketTableUtils'; +import { Pagination } from "@nextui-org/react"; +import { SortColumn } from 'app/markets/components/constants'; +import { MarketPosition } from '@/utils/types'; +import { Market } from '@/hooks/useMarkets'; +import { useTheme } from "next-themes"; + +type MarketTablesProps = { + fromMarkets: MarketPosition[]; + toMarkets: Market[]; + fromFilter: string; + toFilter: string; + onFromFilterChange: (value: string) => void; + onToFilterChange: (value: string) => void; + onFromMarketSelect: (marketUniqueKey: string) => void; + onToMarketSelect: (marketUniqueKey: string) => void; + sortColumn: SortColumn; + sortDirection: number; + onSortChange: (column: SortColumn) => void; + fromPagination: { + currentPage: number; + totalPages: number; + onPageChange: (page: number) => void; + }; + toPagination: { + currentPage: number; + totalPages: number; + onPageChange: (page: number) => void; + }; +}; + +export function MarketTables({ + fromMarkets, + toMarkets, + fromFilter, + toFilter, + onFromFilterChange, + onToFilterChange, + onFromMarketSelect, + onToMarketSelect, + sortColumn, + sortDirection, + onSortChange, + fromPagination, + toPagination, +}: MarketTablesProps) { + const { theme } = useTheme(); + + 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) * 5, fromPagination.currentPage * 5); + const paginatedToMarkets = filteredToMarkets.slice((toPagination.currentPage - 1) * 5, toPagination.currentPage * 5); + + return ( +
+
+

Existing Positions

+ onFromFilterChange(e.target.value)} + className="mb-2" + /> +
+ + + + + + + + + + + + + {paginatedFromMarkets.map((marketPosition) => ( + onFromMarketSelect(marketPosition.market.uniqueKey)} + className="border-b border-gray-200 hover:bg-gray-50 cursor-pointer" + > + + + + + + + + ))} + +
Market IDCollateralLLTVAPYSupplied AmountBalance
{marketPosition.market.uniqueKey.slice(2, 8)}{marketPosition.market.collateralAsset.symbol}{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} + + {formatReadable(Number(marketPosition.market.state.supplyAssets) / 10 ** marketPosition.market.loanAsset.decimals)}{' '} + {marketPosition.market.loanAsset.symbol} +
+
+
+ +
+
+ +
+

Available Markets

+ onToFilterChange(e.target.value)} + className="mb-2" + /> +
+ + + + + + + + + + + + + {paginatedToMarkets.map((market) => ( + onToMarketSelect(market.uniqueKey)} + className="border-b border-gray-200 hover:bg-gray-50 cursor-pointer" + > + + + + + + + + ))} + +
Market IDCollateral + + + + Total SupplyUtil Rate
{market.uniqueKey.slice(2, 8)}{market.collateralAsset.symbol}{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)}%
+
+
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/app/positions/components/PositionsSummaryTable.tsx b/app/positions/components/PositionsSummaryTable.tsx index 8cf3af17..71398c05 100644 --- a/app/positions/components/PositionsSummaryTable.tsx +++ b/app/positions/components/PositionsSummaryTable.tsx @@ -6,6 +6,7 @@ import { getNetworkImg } from '@/utils/networks'; import { findToken } from '@/utils/tokens'; import { MarketPosition } from '@/utils/types'; import { getCollateralColor } from '../utils/colors'; +import { RebalanceModal } from './RebalanceModal'; import { SuppliedMarketsDetail } from './SuppliedMarketsDetail'; type PositionTableProps = { @@ -36,6 +37,10 @@ export function PositionsSummaryTable({ setSelectedPosition, }: PositionTableProps) { 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) => { @@ -147,6 +152,7 @@ export function PositionsSummaryTable({ Total Supplied Avg APY Collateral Exposure + Actions @@ -192,7 +198,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/RebalanceModal.tsx b/app/positions/components/RebalanceModal.tsx new file mode 100644 index 00000000..4f6ac5ec --- /dev/null +++ b/app/positions/components/RebalanceModal.tsx @@ -0,0 +1,230 @@ +import React, { useState, useMemo } from 'react'; +import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Button, Input } from "@nextui-org/react"; +import { Table, TableHeader, TableColumn, TableBody, TableRow, TableCell } from "@nextui-org/react"; +import { ArrowRightIcon } from '@radix-ui/react-icons'; +import useMarkets, { Market } from '@/hooks/useMarkets'; +import { usePagination } from '@/hooks/usePagination'; +import { SortColumn } from 'app/markets/components/constants'; +import { GroupedPosition, RebalanceAction } from '@/utils/types'; +import { useRebalance } from '@/hooks/useRebalance'; +import { MarketTables } from './MarketTables'; +import { formatUnits } from 'viem'; +import { formatReadable, formatBalance } from '@/utils/balance'; +import { toast } from 'react-toastify'; +import { useTheme } from "next-themes"; + +type RebalanceModalProps = { + groupedPosition: GroupedPosition; + isOpen: boolean; + onClose: () => void; +}; + +const MarketBadge = ({ market }: { market: { uniqueKey: string, collateralAsset: { symbol: string }, lltv: string } | null }) => { + if (!market) return Select a market; + + return ( +
+ {market.uniqueKey.slice(2, 8)} | {market.collateralAsset.symbol} | LLTV: {formatUnits(BigInt(market.lltv), 16)}% +
+ ); +}; + +export function RebalanceModal({ groupedPosition, isOpen, onClose }: RebalanceModalProps) { + const { theme } = useTheme(); + const [fromMarketFilter, setFromMarketFilter] = useState(''); + const [toMarketFilter, setToMarketFilter] = useState(''); + const [selectedFromMarketUniqueKey, setSelectedFromMarketUniqueKey] = useState(''); + const [selectedToMarketUniqueKey, setSelectedToMarketUniqueKey] = useState(''); + const [amount, setAmount] = useState(''); + const [sortColumn, setSortColumn] = useState(SortColumn.SupplyAPY); + const [sortDirection, setSortDirection] = useState(1); + + const { data: allMarkets } = useMarkets(); + const { rebalanceActions, addRebalanceAction, removeRebalanceAction, executeRebalance, isConfirming, isAuthorized } = useRebalance(groupedPosition); + + 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]); + + const getPendingAmount = (marketUniqueKey: string) => { + return rebalanceActions.reduce((acc, action) => { + if (action.fromMarket.uniqueKey === marketUniqueKey) { + return acc - Number(action.amount); + } + if (action.toMarket.uniqueKey === marketUniqueKey) { + return acc + Number(action.amount); + } + return acc; + }, 0); + }; + + const handleAddAction = () => { + if (selectedFromMarketUniqueKey && selectedToMarketUniqueKey && amount) { + const fromMarket = groupedPosition.markets.find(m => m.market.uniqueKey === selectedFromMarketUniqueKey)!.market; + const toMarket = eligibleMarkets.find(m => m.uniqueKey === selectedToMarketUniqueKey)!; + + const fromMarketSupplied = Number(formatBalance(fromMarket.state.supplyAssets, fromMarket.loanAsset.decimals)); + const pendingAmount = getPendingAmount(fromMarket.uniqueKey); + const availableAmount = fromMarketSupplied + pendingAmount; + + if (Number(amount) > availableAmount) { + toast.error("Insufficient balance for this action"); + return; + } + + addRebalanceAction({ + 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, + }); + setSelectedFromMarketUniqueKey(''); + setSelectedToMarketUniqueKey(''); + setAmount(''); + } + }; + + return ( + + + Rebalance {groupedPosition.loanAsset} Positions + +
+

+ Use this tool to batch update positions or split one position into multiple markets. Optimize your portfolio by rebalancing across different collaterals and LLTVs. +

+
+ +
+ p.market.uniqueKey === selectedFromMarketUniqueKey)?.market as any} /> + setAmount(e.target.value)} + className="w-40" + /> + m.uniqueKey === selectedToMarketUniqueKey) as any} /> + +
+ + { + if (column === sortColumn) { + setSortDirection(sortDirection * -1); + } else { + setSortColumn(column); + setSortDirection(1); + } + }} + fromPagination={{ + currentPage: fromPagination.currentPage, + totalPages: Math.ceil(groupedPosition.markets.length / 5), + onPageChange: fromPagination.setCurrentPage, + }} + toPagination={{ + currentPage: toPagination.currentPage, + totalPages: Math.ceil(eligibleMarkets.length / 5), + onPageChange: toPagination.setCurrentPage, + }} + /> + +

Rebalance Cart

+ {rebalanceActions.length === 0 ? ( +

Your rebalance cart is empty. Add some actions!

+ ) : ( + + + From Market + {""} + To Market + Amount + Actions + + + {rebalanceActions.map((action, index) => ( + + m.market.uniqueKey === action.fromMarket.uniqueKey)?.market as any} /> + + m.uniqueKey === action.toMarket.uniqueKey) as any} /> + {formatReadable(Number(action.amount))} {groupedPosition.loanAsset} + + + + + ))} + +
+ )} +
+ + + + +
+
+ ); +} \ No newline at end of file diff --git a/app/positions/components/SuppliedMarketsDetail.tsx b/app/positions/components/SuppliedMarketsDetail.tsx index d3b8938c..dc2e7818 100644 --- a/app/positions/components/SuppliedMarketsDetail.tsx +++ b/app/positions/components/SuppliedMarketsDetail.tsx @@ -28,7 +28,7 @@ export function SuppliedMarketsDetail({ const totalSupply = groupedPosition.totalSupply; return ( -
+
@@ -68,7 +68,6 @@ export function SuppliedMarketsDetail({ + + {paginatedFromMarkets.map((marketPosition) => ( + 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' + : '' + }`} + > + + + + + + + ))} + +
{position.market.collateralAsset ? (
- {position.market.collateralAsset.symbol} {findToken( position.market.collateralAsset.address, position.market.morphoBlue.chain.id, @@ -85,6 +84,7 @@ export function SuppliedMarketsDetail({ height={18} /> )} + {position.market.collateralAsset.symbol}
) : ( 'N/A' diff --git a/src/hooks/useRebalance.ts b/src/hooks/useRebalance.ts new file mode 100644 index 00000000..5e72de1d --- /dev/null +++ b/src/hooks/useRebalance.ts @@ -0,0 +1,175 @@ +import { useState, useCallback, useMemo } from 'react'; +import { useAccount, useContractRead, useReadContract, useSignTypedData } from 'wagmi'; +import { encodeFunctionData, Address, parseSignature } from 'viem'; +import { toast } from 'react-toastify'; +import { useTransactionWithToast } from '@/hooks/useTransactionWithToast'; +import morphoBundlerAbi from '@/abis/bundlerV2'; +import morphoAbi from '@/abis/morpho'; +import { getBundlerV2, MORPHO } from '@/utils/morpho'; +import { RebalanceAction, GroupedPosition } from '@/utils/types'; + +export function useRebalance(groupedPosition: GroupedPosition) { + const [rebalanceActions, setRebalanceActions] = useState([]); + const { address: account, isConnected } = useAccount(); + const bundlerAddress = getBundlerV2(groupedPosition.chainId); + + const { data: isAuthorized } = useReadContract({ + address: MORPHO, + abi: morphoAbi, + functionName: 'isAuthorized', + args: [account as Address, bundlerAddress as Address], + }); + + const { data: nonce } = useReadContract({ + address: MORPHO, + abi: morphoAbi, + functionName: 'nonce', + args: [account as Address], + }); + + const { signTypedDataAsync } = useSignTypedData(); + + const { isConfirming, sendTransaction } = useTransactionWithToast({ + toastId: 'rebalance', + pendingText: 'Rebalancing positions', + successText: 'Positions rebalanced successfully', + errorText: 'Failed to rebalance positions', + chainId: groupedPosition.chainId, + }); + + const addRebalanceAction = useCallback((action: RebalanceAction) => { + setRebalanceActions((prev) => [...prev, action]); + }, []); + + const removeRebalanceAction = useCallback((index: number) => { + setRebalanceActions((prev) => prev.filter((_, i) => i !== index)); + }, []); + + const executeRebalance = useCallback(async () => { + if (!account) { + toast.error('Please connect your wallet'); + return; + } + + const transactions = [] as `0x${string}`[]; + + if (!isAuthorized) { + const domain = { + name: 'Morpho Blue', + version: '1', + 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), + }; + + const signatureRaw = await signTypedDataAsync({ domain, types, primaryType: 'Authorization', message: value } ); + const signature = parseSignature(signatureRaw); + + const authorizationTx = encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'morphoSetAuthorizationWithSig', + args: [{ + authorizer: account, + 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 for 0.5 seconds + await new Promise((resolve) => setTimeout(resolve, 500)); + } + + const rebalanceTxs = rebalanceActions.flatMap((action) => { + const withdrawTx = encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'morphoWithdraw', + args: [ + { + loanToken: action.fromMarket.loanToken as Address, + collateralToken: action.fromMarket.collateralToken as Address, + oracle: action.fromMarket.oracle as Address, + irm: action.fromMarket.irm as Address, + lltv: BigInt(action.fromMarket.lltv), + }, + BigInt(action.amount), + BigInt(0), + BigInt(0), + account, + ], + }); + + const supplyTx = encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'morphoSupply', + args: [ + { + loanToken: action.toMarket.loanToken as Address, + collateralToken: action.toMarket.collateralToken as Address, + oracle: action.toMarket.oracle as Address, + irm: action.toMarket.irm as Address, + lltv: BigInt(action.toMarket.lltv), + }, + BigInt(action.amount), + BigInt(0), + BigInt(0), + account, + '0x', + ], + }); + + return [withdrawTx, supplyTx]; + }); + + transactions.push(...rebalanceTxs); + + const multicallTx = encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'multicall', + args: [transactions], + }); + + await sendTransaction({ + account, + to: bundlerAddress, + data: multicallTx, + chainId: groupedPosition.chainId, + }); + + }, [account, isAuthorized, bundlerAddress, groupedPosition.chainId, rebalanceActions, sendTransaction, signTypedDataAsync]); + + return { + rebalanceActions, + addRebalanceAction, + removeRebalanceAction, + executeRebalance, + isConfirming, + isAuthorized, + }; +} \ No newline at end of file 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..f7c31c44 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,39 @@ 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: string; +}; + +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; + }[]; +}; From f1d7706b681fff4dfa9db510fbe0724fd93d7b69 Mon Sep 17 00:00:00 2001 From: Anton Cheng Date: Mon, 7 Oct 2024 20:22:16 +0800 Subject: [PATCH 02/13] feat: styling and most functionalities --- app/markets/components/Pagination.tsx | 20 +- ...{MarketTables.tsx => FromAndToMarkets.tsx} | 146 +++++++------ app/positions/components/RebalanceModal.tsx | 206 ++++++++++++------ src/hooks/useRebalance.ts | 64 ++++-- 4 files changed, 264 insertions(+), 172 deletions(-) rename app/positions/components/{MarketTables.tsx => FromAndToMarkets.tsx} (51%) diff --git a/app/markets/components/Pagination.tsx b/app/markets/components/Pagination.tsx index dd39f602..c11c12a4 100644 --- a/app/markets/components/Pagination.tsx +++ b/app/markets/components/Pagination.tsx @@ -58,15 +58,17 @@ export function Pagination({ }} size="md" /> - {showSettings && } + {showSettings && ( + + )} diff --git a/app/positions/components/MarketTables.tsx b/app/positions/components/FromAndToMarkets.tsx similarity index 51% rename from app/positions/components/MarketTables.tsx rename to app/positions/components/FromAndToMarkets.tsx index eb100da4..8141472b 100644 --- a/app/positions/components/MarketTables.tsx +++ b/app/positions/components/FromAndToMarkets.tsx @@ -1,16 +1,15 @@ import React from 'react'; -import { Input } from "@nextui-org/react"; +import { Input } from '@nextui-org/react'; +import { Pagination } from '@nextui-org/react'; +import { ArrowUpIcon, ArrowDownIcon } from '@radix-ui/react-icons'; +import { useTheme } from 'next-themes'; import { formatUnits } from 'viem'; +import { Market } from '@/hooks/useMarkets'; import { formatReadable } from '@/utils/balance'; -import { HTSortable } from 'app/markets/components/MarketTableUtils'; -import { Pagination } from "@nextui-org/react"; -import { SortColumn } from 'app/markets/components/constants'; import { MarketPosition } from '@/utils/types'; -import { Market } from '@/hooks/useMarkets'; -import { useTheme } from "next-themes"; type MarketTablesProps = { - fromMarkets: MarketPosition[]; + fromMarkets: (MarketPosition & { pendingDelta: number })[]; toMarkets: Market[]; fromFilter: string; toFilter: string; @@ -18,9 +17,6 @@ type MarketTablesProps = { onToFilterChange: (value: string) => void; onFromMarketSelect: (marketUniqueKey: string) => void; onToMarketSelect: (marketUniqueKey: string) => void; - sortColumn: SortColumn; - sortDirection: number; - onSortChange: (column: SortColumn) => void; fromPagination: { currentPage: number; totalPages: number; @@ -33,7 +29,7 @@ type MarketTablesProps = { }; }; -export function MarketTables({ +export function FromAndToMarkets({ fromMarkets, toMarkets, fromFilter, @@ -42,131 +38,144 @@ export function MarketTables({ onToFilterChange, onFromMarketSelect, onToMarketSelect, - sortColumn, - sortDirection, - onSortChange, fromPagination, toPagination, }: MarketTablesProps) { const { theme } = useTheme(); - const filteredFromMarkets = fromMarkets.filter(marketPosition => - marketPosition.market.uniqueKey.toLowerCase().includes(fromFilter.toLowerCase()) || - marketPosition.market.collateralAsset.symbol.toLowerCase().includes(fromFilter.toLowerCase()) + 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 filteredToMarkets = toMarkets.filter( + (market) => + market.uniqueKey.toLowerCase().includes(toFilter.toLowerCase()) || + market.collateralAsset.symbol.toLowerCase().includes(toFilter.toLowerCase()), ); - const paginatedFromMarkets = filteredFromMarkets.slice((fromPagination.currentPage - 1) * 5, fromPagination.currentPage * 5); - const paginatedToMarkets = filteredToMarkets.slice((toPagination.currentPage - 1) * 5, toPagination.currentPage * 5); + const paginatedFromMarkets = filteredFromMarkets.slice( + (fromPagination.currentPage - 1) * 5, + fromPagination.currentPage * 5, + ); + const paginatedToMarkets = filteredToMarkets.slice( + (toPagination.currentPage - 1) * 5, + toPagination.currentPage * 5, + ); return (
-

Existing Positions

+

Existing Positions

onFromFilterChange(e.target.value)} className="mb-2" /> -
- - +
+
+ - - + {paginatedFromMarkets.map((marketPosition) => ( - onFromMarketSelect(marketPosition.market.uniqueKey)} - className="border-b border-gray-200 hover:bg-gray-50 cursor-pointer" + className="cursor-pointer border-b border-gray-200 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800" > - + - - + ))}
Market ID Collateral LLTV APY Supplied AmountBalance
{marketPosition.market.uniqueKey.slice(2, 8)} + {marketPosition.market.uniqueKey.slice(2, 8)} + {marketPosition.market.collateralAsset.symbol}{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} + {formatUnits(BigInt(marketPosition.market.lltv), 16)}% - {formatReadable(Number(marketPosition.market.state.supplyAssets) / 10 ** marketPosition.market.loanAsset.decimals)}{' '} + {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(marketPosition.pendingDelta))}) + + )}
-
+
-

Available Markets

+

Available Markets

onToFilterChange(e.target.value)} className="mb-2" /> -
- - +
+
+ - - + + - + {paginatedToMarkets.map((market) => ( - onToMarketSelect(market.uniqueKey)} - className="border-b border-gray-200 hover:bg-gray-50 cursor-pointer" + className="cursor-pointer border-b border-gray-200 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800" > - + @@ -174,14 +183,15 @@ export function MarketTables({
Market ID Collateral - - - - LLTVAPY Total Supply Util Rate
{market.uniqueKey.slice(2, 8)} + {market.uniqueKey.slice(2, 8)} + {market.collateralAsset.symbol} {formatUnits(BigInt(market.lltv), 16)}% {formatReadable(market.state.supplyApy * 100)}% - {formatReadable(Number(market.state.supplyAssets) / 10 ** market.loanAsset.decimals)} {market.loanAsset.symbol} + {formatReadable( + Number(market.state.supplyAssets) / 10 ** market.loanAsset.decimals, + )}{' '} + {market.loanAsset.symbol} {formatReadable(market.state.utilization * 100)}%
-
+
); -} \ No newline at end of file +} diff --git a/app/positions/components/RebalanceModal.tsx b/app/positions/components/RebalanceModal.tsx index 4f6ac5ec..9b9a64d5 100644 --- a/app/positions/components/RebalanceModal.tsx +++ b/app/positions/components/RebalanceModal.tsx @@ -1,17 +1,26 @@ import React, { useState, useMemo } from 'react'; -import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Button, Input } from "@nextui-org/react"; -import { Table, TableHeader, TableColumn, TableBody, TableRow, TableCell } from "@nextui-org/react"; -import { ArrowRightIcon } from '@radix-ui/react-icons'; -import useMarkets, { Market } from '@/hooks/useMarkets'; +import { + Modal, + ModalContent, + ModalHeader, + ModalBody, + ModalFooter, + Button, + Input, +} from '@nextui-org/react'; +import { Table, TableHeader, TableColumn, TableBody, TableRow, TableCell } from '@nextui-org/react'; +import { ArrowRightIcon, ArrowDownIcon } from '@radix-ui/react-icons'; +import Image from 'next/image'; +import { useTheme } from 'next-themes'; +import { toast } from 'react-toastify'; +import { formatUnits } from 'viem'; +import useMarkets from '@/hooks/useMarkets'; import { usePagination } from '@/hooks/usePagination'; -import { SortColumn } from 'app/markets/components/constants'; -import { GroupedPosition, RebalanceAction } from '@/utils/types'; import { useRebalance } from '@/hooks/useRebalance'; -import { MarketTables } from './MarketTables'; -import { formatUnits } from 'viem'; import { formatReadable, formatBalance } from '@/utils/balance'; -import { toast } from 'react-toastify'; -import { useTheme } from "next-themes"; +import { findToken } from '@/utils/tokens'; +import { GroupedPosition } from '@/utils/types'; +import { FromAndToMarkets } from './FromAndToMarkets'; type RebalanceModalProps = { groupedPosition: GroupedPosition; @@ -19,28 +28,40 @@ type RebalanceModalProps = { onClose: () => void; }; -const MarketBadge = ({ market }: { market: { uniqueKey: string, collateralAsset: { symbol: string }, lltv: string } | null }) => { - if (!market) return Select a market; - +function MarketBadge({ + market, +}: { + market: { uniqueKey: string; collateralAsset: { symbol: string }; lltv: string } | null; +}) { + if (!market) + return Select market; + return ( -
- {market.uniqueKey.slice(2, 8)} | {market.collateralAsset.symbol} | LLTV: {formatUnits(BigInt(market.lltv), 16)}% +
+ {market.uniqueKey.slice(2, 8)} |{' '} + {market.collateralAsset.symbol}
); -}; +} export function RebalanceModal({ groupedPosition, isOpen, onClose }: RebalanceModalProps) { - const { theme } = useTheme(); const [fromMarketFilter, setFromMarketFilter] = useState(''); const [toMarketFilter, setToMarketFilter] = useState(''); const [selectedFromMarketUniqueKey, setSelectedFromMarketUniqueKey] = useState(''); const [selectedToMarketUniqueKey, setSelectedToMarketUniqueKey] = useState(''); const [amount, setAmount] = useState(''); - const [sortColumn, setSortColumn] = useState(SortColumn.SupplyAPY); - const [sortDirection, setSortDirection] = useState(1); const { data: allMarkets } = useMarkets(); - const { rebalanceActions, addRebalanceAction, removeRebalanceAction, executeRebalance, isConfirming, isAuthorized } = useRebalance(groupedPosition); + const { + rebalanceActions, + addRebalanceAction, + removeRebalanceAction, + executeRebalance, + isConfirming, + isAuthorized, + } = useRebalance(groupedPosition); + + const tokenImg = findToken(groupedPosition.loanAsset, groupedPosition.chainId); const fromPagination = usePagination(); const toPagination = usePagination(); @@ -53,7 +74,7 @@ export function RebalanceModal({ groupedPosition, isOpen, onClose }: RebalanceMo ); }, [allMarkets, groupedPosition]); - const getPendingAmount = (marketUniqueKey: string) => { + const getPendingDelta = (marketUniqueKey: string) => { return rebalanceActions.reduce((acc, action) => { if (action.fromMarket.uniqueKey === marketUniqueKey) { return acc - Number(action.amount); @@ -67,15 +88,19 @@ export function RebalanceModal({ groupedPosition, isOpen, onClose }: RebalanceMo const handleAddAction = () => { if (selectedFromMarketUniqueKey && selectedToMarketUniqueKey && amount) { - const fromMarket = groupedPosition.markets.find(m => m.market.uniqueKey === selectedFromMarketUniqueKey)!.market; - const toMarket = eligibleMarkets.find(m => m.uniqueKey === selectedToMarketUniqueKey)!; - - const fromMarketSupplied = Number(formatBalance(fromMarket.state.supplyAssets, fromMarket.loanAsset.decimals)); - const pendingAmount = getPendingAmount(fromMarket.uniqueKey); - const availableAmount = fromMarketSupplied + pendingAmount; - - if (Number(amount) > availableAmount) { - toast.error("Insufficient balance for this action"); + const fromMarket = groupedPosition.markets.find( + (m) => m.market.uniqueKey === selectedFromMarketUniqueKey, + )!.market; + const toMarket = eligibleMarkets.find((m) => m.uniqueKey === selectedToMarketUniqueKey)!; + + const currentBalance = Number( + formatBalance(fromMarket.state.supplyAssets, fromMarket.loanAsset.decimals), + ); + const pendingDelta = getPendingDelta(fromMarket.uniqueKey); + const availableBalance = currentBalance + pendingDelta; + + if (Number(amount) > availableBalance) { + toast.error('Insufficient balance for this action'); return; } @@ -105,43 +130,72 @@ export function RebalanceModal({ groupedPosition, isOpen, onClose }: RebalanceMo }; return ( - - Rebalance {groupedPosition.loanAsset} Positions + + Rebalance {groupedPosition.loanAsset} Positions + -
+

- Use this tool to batch update positions or split one position into multiple markets. Optimize your portfolio by rebalancing across different collaterals and LLTVs. + Use this tool to batch update positions or split one position into multiple markets. + Optimize your portfolio by rebalancing across different collaterals and LLTVs.

-
- p.market.uniqueKey === selectedFromMarketUniqueKey)?.market as any} /> +
+ Rebalance setAmount(e.target.value)} - className="w-40" + className="mx-2 w-40 bg-white text-gray-900 dark:bg-gray-800 dark:text-gray-100" /> - m.uniqueKey === selectedToMarketUniqueKey) as any} /> -
+ From +
+ p.market.uniqueKey === selectedFromMarketUniqueKey, + )?.market as unknown + } + /> +
+ +
+ m.uniqueKey === selectedToMarketUniqueKey) as unknown + } + /> +
+
- ({ + ...market, + pendingDelta: getPendingDelta(market.market.uniqueKey), + }))} toMarkets={eligibleMarkets} fromFilter={fromMarketFilter} toFilter={toMarketFilter} @@ -149,16 +203,6 @@ export function RebalanceModal({ groupedPosition, isOpen, onClose }: RebalanceMo onToFilterChange={setToMarketFilter} onFromMarketSelect={setSelectedFromMarketUniqueKey} onToMarketSelect={setSelectedToMarketUniqueKey} - sortColumn={sortColumn} - sortDirection={sortDirection} - onSortChange={(column) => { - if (column === sortColumn) { - setSortDirection(sortDirection * -1); - } else { - setSortColumn(column); - setSortDirection(1); - } - }} fromPagination={{ currentPage: fromPagination.currentPage, totalPages: Math.ceil(groupedPosition.markets.length / 5), @@ -173,12 +217,13 @@ export function RebalanceModal({ groupedPosition, isOpen, onClose }: RebalanceMo

Rebalance Cart

{rebalanceActions.length === 0 ? ( -

Your rebalance cart is empty. Add some actions!

+

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

) : ( From Market - {""} To Market Amount Actions @@ -186,16 +231,33 @@ export function RebalanceModal({ groupedPosition, isOpen, onClose }: RebalanceMo {rebalanceActions.map((action, index) => ( - m.market.uniqueKey === action.fromMarket.uniqueKey)?.market as any} /> - - m.uniqueKey === action.toMarket.uniqueKey) as any} /> - {formatReadable(Number(action.amount))} {groupedPosition.loanAsset} - @@ -207,11 +269,11 @@ export function RebalanceModal({ groupedPosition, isOpen, onClose }: RebalanceMo )} - @@ -219,7 +281,7 @@ export function RebalanceModal({ groupedPosition, isOpen, onClose }: RebalanceMo color="primary" onPress={executeRebalance} disabled={isConfirming || rebalanceActions.length === 0} - className="bg-orange-500 dark:bg-orange-600 text-white rounded-sm p-4 px-10 font-zen opacity-80 transition-all duration-200 ease-in-out hover:opacity-100 hover:scale-105" + 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 dark:bg-orange-600" > {isAuthorized ? 'Rebalance' : 'Authorize and Rebalance'} @@ -227,4 +289,4 @@ export function RebalanceModal({ groupedPosition, isOpen, onClose }: RebalanceMo ); -} \ No newline at end of file +} diff --git a/src/hooks/useRebalance.ts b/src/hooks/useRebalance.ts index 5e72de1d..1c949f04 100644 --- a/src/hooks/useRebalance.ts +++ b/src/hooks/useRebalance.ts @@ -1,10 +1,10 @@ -import { useState, useCallback, useMemo } from 'react'; -import { useAccount, useContractRead, useReadContract, useSignTypedData } from 'wagmi'; -import { encodeFunctionData, Address, parseSignature } from 'viem'; +import { useState, useCallback } from 'react'; import { toast } from 'react-toastify'; -import { useTransactionWithToast } from '@/hooks/useTransactionWithToast'; +import { encodeFunctionData, Address, 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 { RebalanceAction, GroupedPosition } from '@/utils/types'; @@ -55,8 +55,6 @@ export function useRebalance(groupedPosition: GroupedPosition) { if (!isAuthorized) { const domain = { - name: 'Morpho Blue', - version: '1', chainId: groupedPosition.chainId, verifyingContract: MORPHO as Address, }; @@ -81,31 +79,42 @@ export function useRebalance(groupedPosition: GroupedPosition) { deadline: BigInt(deadline), }; - const signatureRaw = await signTypedDataAsync({ domain, types, primaryType: 'Authorization', message: value } ); - const signature = parseSignature(signatureRaw); + const signatureRaw = await signTypedDataAsync({ + domain, + types, + primaryType: 'Authorization', + message: value, + }); + const signature = parseSignature(signatureRaw); const authorizationTx = encodeFunctionData({ abi: morphoBundlerAbi, functionName: 'morphoSetAuthorizationWithSig', - args: [{ - authorizer: account, - authorized: bundlerAddress, - isAuthorized: true, - nonce: BigInt(nonce ?? 0), - deadline: BigInt(deadline), - }, { - v: Number(signature.v), - r: signature.r, - s: signature.s, - }, false], + args: [ + { + authorizer: account, + 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 for 0.5 seconds - await new Promise((resolve) => setTimeout(resolve, 500)); + await new Promise((resolve) => setTimeout(resolve, 1000)); } + console.log('rebalanceActions', rebalanceActions); + const rebalanceTxs = rebalanceActions.flatMap((action) => { const withdrawTx = encodeFunctionData({ abi: morphoBundlerAbi, @@ -149,6 +158,8 @@ export function useRebalance(groupedPosition: GroupedPosition) { transactions.push(...rebalanceTxs); + console.log('transactions', transactions); + const multicallTx = encodeFunctionData({ abi: morphoBundlerAbi, functionName: 'multicall', @@ -161,8 +172,15 @@ export function useRebalance(groupedPosition: GroupedPosition) { data: multicallTx, chainId: groupedPosition.chainId, }); - - }, [account, isAuthorized, bundlerAddress, groupedPosition.chainId, rebalanceActions, sendTransaction, signTypedDataAsync]); + }, [ + account, + isAuthorized, + bundlerAddress, + groupedPosition.chainId, + rebalanceActions, + sendTransaction, + signTypedDataAsync, + ]); return { rebalanceActions, @@ -172,4 +190,4 @@ export function useRebalance(groupedPosition: GroupedPosition) { isConfirming, isAuthorized, }; -} \ No newline at end of file +} From 34679a980e6ff3efc5d6ffdc6fc26dcff006830b Mon Sep 17 00:00:00 2001 From: Anton Cheng Date: Mon, 7 Oct 2024 22:11:15 +0800 Subject: [PATCH 03/13] feat: rebalance, except permit --- app/positions/components/FromAndToMarkets.tsx | 15 +++++--- app/positions/components/RebalanceModal.tsx | 35 +++++++++++-------- src/hooks/useRebalance.ts | 22 +++++++----- 3 files changed, 45 insertions(+), 27 deletions(-) diff --git a/app/positions/components/FromAndToMarkets.tsx b/app/positions/components/FromAndToMarkets.tsx index 8141472b..f8c57887 100644 --- a/app/positions/components/FromAndToMarkets.tsx +++ b/app/positions/components/FromAndToMarkets.tsx @@ -2,7 +2,6 @@ import React from 'react'; import { Input } from '@nextui-org/react'; import { Pagination } from '@nextui-org/react'; import { ArrowUpIcon, ArrowDownIcon } from '@radix-ui/react-icons'; -import { useTheme } from 'next-themes'; import { formatUnits } from 'viem'; import { Market } from '@/hooks/useMarkets'; import { formatReadable } from '@/utils/balance'; @@ -41,8 +40,6 @@ export function FromAndToMarkets({ fromPagination, toPagination, }: MarketTablesProps) { - const { theme } = useTheme(); - const filteredFromMarkets = fromMarkets.filter( (marketPosition) => marketPosition.market.uniqueKey.toLowerCase().includes(fromFilter.toLowerCase()) || @@ -64,6 +61,14 @@ export function FromAndToMarkets({ toPagination.currentPage * 5, ); + const handleFromPaginationChange = (page: number) => { + fromPagination.onPageChange(page); + }; + + const handleToPaginationChange = (page: number) => { + toPagination.onPageChange(page); + }; + return (
@@ -132,7 +137,7 @@ export function FromAndToMarkets({
@@ -187,7 +192,7 @@ export function FromAndToMarkets({
diff --git a/app/positions/components/RebalanceModal.tsx b/app/positions/components/RebalanceModal.tsx index 9b9a64d5..b75d275e 100644 --- a/app/positions/components/RebalanceModal.tsx +++ b/app/positions/components/RebalanceModal.tsx @@ -9,12 +9,10 @@ import { Input, } from '@nextui-org/react'; import { Table, TableHeader, TableColumn, TableBody, TableRow, TableCell } from '@nextui-org/react'; -import { ArrowRightIcon, ArrowDownIcon } from '@radix-ui/react-icons'; +import { ArrowRightIcon } from '@radix-ui/react-icons'; import Image from 'next/image'; -import { useTheme } from 'next-themes'; import { toast } from 'react-toastify'; -import { formatUnits } from 'viem'; -import useMarkets from '@/hooks/useMarkets'; +import useMarkets, { Market } from '@/hooks/useMarkets'; import { usePagination } from '@/hooks/usePagination'; import { useRebalance } from '@/hooks/useRebalance'; import { formatReadable, formatBalance } from '@/utils/balance'; @@ -61,7 +59,7 @@ export function RebalanceModal({ groupedPosition, isOpen, onClose }: RebalanceMo isAuthorized, } = useRebalance(groupedPosition); - const tokenImg = findToken(groupedPosition.loanAsset, groupedPosition.chainId); + const token = findToken(groupedPosition.loanAssetAddress, groupedPosition.chainId); const fromPagination = usePagination(); const toPagination = usePagination(); @@ -90,8 +88,13 @@ export function RebalanceModal({ groupedPosition, isOpen, onClose }: RebalanceMo if (selectedFromMarketUniqueKey && selectedToMarketUniqueKey && amount) { const fromMarket = groupedPosition.markets.find( (m) => m.market.uniqueKey === selectedFromMarketUniqueKey, - )!.market; - const toMarket = eligibleMarkets.find((m) => m.uniqueKey === selectedToMarketUniqueKey)!; + )?.market; + const toMarket = eligibleMarkets.find((m) => m.uniqueKey === selectedToMarketUniqueKey); + + if (!fromMarket || !toMarket) { + toast.error('Invalid market selection'); + return; + } const currentBalance = Number( formatBalance(fromMarket.state.supplyAssets, fromMarket.loanAsset.decimals), @@ -161,8 +164,8 @@ export function RebalanceModal({ groupedPosition, isOpen, onClose }: RebalanceMo />
{groupedPosition.loanAsset} - {tokenImg?.img && ( - + {token?.img && ( + )}
From @@ -171,7 +174,7 @@ export function RebalanceModal({ groupedPosition, isOpen, onClose }: RebalanceMo market={ groupedPosition.markets.find( (p) => p.market.uniqueKey === selectedFromMarketUniqueKey, - )?.market as unknown + )?.market as unknown as Market } /> @@ -179,7 +182,9 @@ export function RebalanceModal({ groupedPosition, isOpen, onClose }: RebalanceMo
m.uniqueKey === selectedToMarketUniqueKey) as unknown + eligibleMarkets.find( + (m) => m.uniqueKey === selectedToMarketUniqueKey, + ) as unknown as Market } />
@@ -236,7 +241,7 @@ export function RebalanceModal({ groupedPosition, isOpen, onClose }: RebalanceMo market={ groupedPosition.markets.find( (m) => m.market.uniqueKey === action.fromMarket.uniqueKey, - )?.market as unknown + )?.market as unknown as Market } />
@@ -245,7 +250,7 @@ export function RebalanceModal({ groupedPosition, isOpen, onClose }: RebalanceMo market={ eligibleMarkets.find( (m) => m.uniqueKey === action.toMarket.uniqueKey, - ) as unknown + ) as unknown as Market } /> @@ -279,7 +284,9 @@ export function RebalanceModal({ groupedPosition, isOpen, onClose }: RebalanceMo - )} + diff --git a/app/positions/components/FromAndToMarkets.tsx b/app/positions/components/FromAndToMarkets.tsx index f8c57887..3ddf4fd9 100644 --- a/app/positions/components/FromAndToMarkets.tsx +++ b/app/positions/components/FromAndToMarkets.tsx @@ -136,7 +136,7 @@ export function FromAndToMarkets({
diff --git a/app/positions/components/PositionsSummaryTable.tsx b/app/positions/components/PositionsSummaryTable.tsx index 71398c05..acce7da0 100644 --- a/app/positions/components/PositionsSummaryTable.tsx +++ b/app/positions/components/PositionsSummaryTable.tsx @@ -4,7 +4,7 @@ import Image from 'next/image'; 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'; @@ -15,22 +15,6 @@ 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, diff --git a/app/positions/components/RebalanceModal.tsx b/app/positions/components/RebalanceModal.tsx index b75d275e..371232b0 100644 --- a/app/positions/components/RebalanceModal.tsx +++ b/app/positions/components/RebalanceModal.tsx @@ -86,6 +86,10 @@ export function RebalanceModal({ groupedPosition, isOpen, onClose }: RebalanceMo const handleAddAction = () => { if (selectedFromMarketUniqueKey && selectedToMarketUniqueKey && amount) { + if (Number(amount) <= 0) { + toast.error('Amount must be greater than zero'); + return; + } const fromMarket = groupedPosition.markets.find( (m) => m.market.uniqueKey === selectedFromMarketUniqueKey, )?.market; @@ -142,8 +146,8 @@ export function RebalanceModal({ groupedPosition, isOpen, onClose }: RebalanceMo }} > - - Rebalance {groupedPosition.loanAsset} Positions + + Rebalance {groupedPosition.loanAsset ?? 'Unknown'} Positions
diff --git a/src/hooks/useRebalance.ts b/src/hooks/useRebalance.ts index 91e4ade5..0f68e8a1 100644 --- a/src/hooks/useRebalance.ts +++ b/src/hooks/useRebalance.ts @@ -53,7 +53,7 @@ export function useRebalance(groupedPosition: GroupedPosition) { const transactions = [] as `0x${string}`[]; - if (!isAuthorized) { + if (isAuthorized === false) { const domain = { chainId: groupedPosition.chainId, verifyingContract: MORPHO as Address, @@ -79,12 +79,18 @@ export function useRebalance(groupedPosition: GroupedPosition) { deadline: BigInt(deadline), }; - const signatureRaw = await signTypedDataAsync({ - domain, - types, - primaryType: 'Authorization', - message: value, - }); + 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({ @@ -181,6 +187,7 @@ export function useRebalance(groupedPosition: GroupedPosition) { }, [ account, isAuthorized, + nonce, bundlerAddress, groupedPosition.chainId, rebalanceActions, From b9e9c0319794547641b3340d5add52fb0eb075f9 Mon Sep 17 00:00:00 2001 From: Anton Cheng Date: Mon, 7 Oct 2024 22:59:19 +0800 Subject: [PATCH 05/13] chore: type export --- app/positions/components/SuppliedMarketsDetail.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/positions/components/SuppliedMarketsDetail.tsx b/app/positions/components/SuppliedMarketsDetail.tsx index dc2e7818..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; From e900689672a4947ee31cdb50ee7c40b1c6f3fb37 Mon Sep 17 00:00:00 2001 From: Anton Cheng Date: Tue, 8 Oct 2024 01:00:57 +0800 Subject: [PATCH 06/13] feat: try adding the whole flow, still reverting with transferFrom --- .../components/PositionsSummaryTable.tsx | 2 + app/positions/components/RebalanceModal.tsx | 354 ++++++++++-------- .../components/RebalanceProcessModal.tsx | 100 +++++ src/hooks/useRebalance.ts | 327 +++++++++------- src/utils/types.ts | 3 +- 5 files changed, 478 insertions(+), 308 deletions(-) create mode 100644 app/positions/components/RebalanceProcessModal.tsx diff --git a/app/positions/components/PositionsSummaryTable.tsx b/app/positions/components/PositionsSummaryTable.tsx index acce7da0..f3c5c7f5 100644 --- a/app/positions/components/PositionsSummaryTable.tsx +++ b/app/positions/components/PositionsSummaryTable.tsx @@ -29,6 +29,7 @@ export function PositionsSummaryTable({ 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( @@ -39,6 +40,7 @@ export function PositionsSummaryTable({ groupedPosition = { loanAsset: position.market.loanAsset.symbol || 'Unknown', loanAssetAddress, + loanAssetDecimals, chainId, totalSupply: 0, totalWeightedApy: 0, diff --git a/app/positions/components/RebalanceModal.tsx b/app/positions/components/RebalanceModal.tsx index 371232b0..9a787775 100644 --- a/app/positions/components/RebalanceModal.tsx +++ b/app/positions/components/RebalanceModal.tsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo } from 'react'; +import React, { useState, useMemo, useCallback } from 'react'; import { Modal, ModalContent, @@ -6,9 +6,9 @@ import { ModalBody, ModalFooter, Button, - Input, } from '@nextui-org/react'; import { Table, TableHeader, TableColumn, TableBody, TableRow, TableCell } from '@nextui-org/react'; +import Input from '@/components/Input/Input' import { ArrowRightIcon } from '@radix-ui/react-icons'; import Image from 'next/image'; import { toast } from 'react-toastify'; @@ -17,8 +17,10 @@ import { usePagination } from '@/hooks/usePagination'; import { useRebalance } from '@/hooks/useRebalance'; import { formatReadable, formatBalance } from '@/utils/balance'; import { findToken } from '@/utils/tokens'; -import { GroupedPosition } from '@/utils/types'; +import { GroupedPosition, RebalanceAction } from '@/utils/types'; import { FromAndToMarkets } from './FromAndToMarkets'; +import { RebalanceProcessModal } from './RebalanceProcessModal'; +import { formatUnits, maxUint256 } from 'viem'; type RebalanceModalProps = { groupedPosition: GroupedPosition; @@ -37,7 +39,7 @@ function MarketBadge({ return (
{market.uniqueKey.slice(2, 8)} |{' '} - {market.collateralAsset.symbol} + {market.collateralAsset.symbol} | {' '} {formatUnits(BigInt(market.lltv), 16)} %
); } @@ -47,7 +49,8 @@ export function RebalanceModal({ groupedPosition, isOpen, onClose }: RebalanceMo const [toMarketFilter, setToMarketFilter] = useState(''); const [selectedFromMarketUniqueKey, setSelectedFromMarketUniqueKey] = useState(''); const [selectedToMarketUniqueKey, setSelectedToMarketUniqueKey] = useState(''); - const [amount, setAmount] = useState(''); + const [amount, setAmount] = useState(BigInt(0)); + const [showProcessModal, setShowProcessModal] = useState(false); const { data: allMarkets } = useMarkets(); const { @@ -56,7 +59,7 @@ export function RebalanceModal({ groupedPosition, isOpen, onClose }: RebalanceMo removeRebalanceAction, executeRebalance, isConfirming, - isAuthorized, + currentStep } = useRebalance(groupedPosition); const token = findToken(groupedPosition.loanAssetAddress, groupedPosition.chainId); @@ -73,7 +76,7 @@ export function RebalanceModal({ groupedPosition, isOpen, onClose }: RebalanceMo }, [allMarkets, groupedPosition]); const getPendingDelta = (marketUniqueKey: string) => { - return rebalanceActions.reduce((acc, action) => { + return rebalanceActions.reduce((acc: number, action: RebalanceAction) => { if (action.fromMarket.uniqueKey === marketUniqueKey) { return acc - Number(action.amount); } @@ -104,7 +107,7 @@ export function RebalanceModal({ groupedPosition, isOpen, onClose }: RebalanceMo formatBalance(fromMarket.state.supplyAssets, fromMarket.loanAsset.decimals), ); const pendingDelta = getPendingDelta(fromMarket.uniqueKey); - const availableBalance = currentBalance + pendingDelta; + const availableBalance = currentBalance + formatBalance(pendingDelta.toString(), fromMarket.loanAsset.decimals); if (Number(amount) > availableBalance) { toast.error('Insufficient balance for this action'); @@ -132,172 +135,191 @@ export function RebalanceModal({ groupedPosition, isOpen, onClose }: RebalanceMo }); setSelectedFromMarketUniqueKey(''); setSelectedToMarketUniqueKey(''); - setAmount(''); + setAmount(BigInt(0)); } }; - return ( - - - - Rebalance {groupedPosition.loanAsset ?? 'Unknown'} Positions - - -
-

- Use this tool to batch update positions or split one position into multiple markets. - Optimize your portfolio by rebalancing across different collaterals and LLTVs. -

-
+ const handleExecuteRebalance = useCallback(async () => { + setShowProcessModal(true); + try { + await executeRebalance(); + } catch (error) { + console.error('Error during rebalance:', error); + } finally { + setShowProcessModal(false); + } + }, [executeRebalance]); -
- Rebalance - setAmount(e.target.value)} - className="mx-2 w-40 bg-white text-gray-900 dark:bg-gray-800 dark:text-gray-100" - /> -
- {groupedPosition.loanAsset} - {token?.img && ( - - )} -
- From -
- p.market.uniqueKey === selectedFromMarketUniqueKey, - )?.market as unknown as Market - } - /> + return ( + <> + + + + Rebalance {groupedPosition.loanAsset ?? 'Unknown'} Positions + + +
+

+ Use this tool to batch update positions or split one position into multiple markets. + Optimize your portfolio by rebalancing across different collaterals and LLTVs. +

- -
- m.uniqueKey === selectedToMarketUniqueKey, - ) as unknown as Market - } + +
+ Rebalance + +
+ {groupedPosition.loanAsset} + {token?.img && ( + + )} +
+ From +
+ p.market.uniqueKey === selectedFromMarketUniqueKey, + )?.market as unknown as Market + } + /> +
+ +
+ m.uniqueKey === selectedToMarketUniqueKey, + ) as unknown as 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 / 5), + onPageChange: fromPagination.setCurrentPage, + }} + toPagination={{ + currentPage: toPagination.currentPage, + totalPages: Math.ceil(eligibleMarkets.length / 5), + onPageChange: toPagination.setCurrentPage, + }} + /> + +

Rebalance Cart

+ {rebalanceActions.length === 0 ? ( +

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

+ ) : ( +
+ + From Market + To Market + Amount + Actions + + + {rebalanceActions.map((action: RebalanceAction, index: number) => ( + + + m.market.uniqueKey === action.fromMarket.uniqueKey, + )?.market as unknown as Market + } + /> + + + m.uniqueKey === action.toMarket.uniqueKey, + ) as unknown as Market + } + /> + + + {formatReadable(Number(action.amount))} {groupedPosition.loanAsset} + + + + + + ))} + +
+ )} + + -
- - ({ - ...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 / 5), - onPageChange: fromPagination.setCurrentPage, - }} - toPagination={{ - currentPage: toPagination.currentPage, - totalPages: Math.ceil(eligibleMarkets.length / 5), - onPageChange: toPagination.setCurrentPage, - }} - /> - -

Rebalance Cart

- {rebalanceActions.length === 0 ? ( -

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

- ) : ( - - - From Market - To Market - Amount - Actions - - - {rebalanceActions.map((action, index) => ( - - - m.market.uniqueKey === action.fromMarket.uniqueKey, - )?.market as unknown as Market - } - /> - - - m.uniqueKey === action.toMarket.uniqueKey, - ) as unknown as Market - } - /> - - - {formatReadable(Number(action.amount))} {groupedPosition.loanAsset} - - - - - - ))} - -
- )} -
- - - - -
-
+ + + + + {showProcessModal && ( + setShowProcessModal(false)} + tokenSymbol={groupedPosition.loanAsset} + actionsCount={rebalanceActions.length} + /> + )} + ); -} +} \ No newline at end of file diff --git a/app/positions/components/RebalanceProcessModal.tsx b/app/positions/components/RebalanceProcessModal.tsx new file mode 100644 index 00000000..ad45adca --- /dev/null +++ b/app/positions/components/RebalanceProcessModal.tsx @@ -0,0 +1,100 @@ +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: '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.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 - 1 &&
} +
+ ))} +
+
+
+ ); +} \ No newline at end of file diff --git a/src/hooks/useRebalance.ts b/src/hooks/useRebalance.ts index 0f68e8a1..33eabf23 100644 --- a/src/hooks/useRebalance.ts +++ b/src/hooks/useRebalance.ts @@ -1,16 +1,21 @@ import { useState, useCallback } from 'react'; import { toast } from 'react-toastify'; -import { encodeFunctionData, Address, parseSignature, maxUint256 } from 'viem'; import { useAccount, useReadContract, useSignTypedData } from 'wagmi'; +import { usePermit2 } from './usePermit2'; +import { GroupedPosition, RebalanceAction } from '@/utils/types'; import morphoBundlerAbi from '@/abis/bundlerV2'; import morphoAbi from '@/abis/morpho'; import { useTransactionWithToast } from '@/hooks/useTransactionWithToast'; import { getBundlerV2, MORPHO } from '@/utils/morpho'; -import { RebalanceAction, GroupedPosition } from '@/utils/types'; +import { Address, encodeFunctionData, maxUint256, parseSignature } from 'viem'; -export function useRebalance(groupedPosition: GroupedPosition) { +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({ @@ -18,6 +23,7 @@ export function useRebalance(groupedPosition: GroupedPosition) { abi: morphoAbi, functionName: 'isAuthorized', args: [account as Address, bundlerAddress as Address], + chainId: groupedPosition.chainId, }); const { data: nonce } = useReadContract({ @@ -25,16 +31,23 @@ export function useRebalance(groupedPosition: GroupedPosition) { abi: morphoAbi, functionName: 'nonce', args: [account as Address], + chainId: groupedPosition.chainId, }); - const { signTypedDataAsync } = useSignTypedData(); - - const { isConfirming, sendTransaction } = useTransactionWithToast({ - toastId: 'rebalance', - pendingText: 'Rebalancing positions', - successText: 'Positions rebalanced successfully', - errorText: 'Failed to rebalance positions', + 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) => { @@ -45,155 +58,186 @@ export function useRebalance(groupedPosition: GroupedPosition) { 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) { toast.error('Please connect your wallet'); return; } - const transactions = [] as `0x${string}`[]; + setIsConfirming(true); + const transactions: `0x${string}`[] = []; - 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; + try { + // Step 1: Authorize Permit2 if needed + setCurrentStep('approve'); + if (!permit2Authorized) { + await authorizePermit2(); + + await new Promise((resolve) => setTimeout(resolve, 800)); } - const signature = parseSignature(signatureRaw); - const authorizationTx = encodeFunctionData({ - abi: morphoBundlerAbi, - functionName: 'morphoSetAuthorizationWithSig', - args: [ - { - authorizer: account, - authorized: bundlerAddress, - isAuthorized: true, - nonce: BigInt(nonce ?? 0), - deadline: BigInt(deadline), - }, - { - v: Number(signature.v), - r: signature.r, - s: signature.s, - }, - false, - ], - }); + // 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)); + } - transactions.push(authorizationTx); + // Step 3: Sign permit for USDC + setCurrentStep('sign'); + const { sigs, permitSingle } = await signForBundlers(); + console.log('Signed for bundlers:', { sigs, permitSingle }); - // Wait for 0.5 seconds + const permitTx = encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'approve2', + args: [permitSingle, sigs, false], + }); + transactions.push(permitTx); await new Promise((resolve) => setTimeout(resolve, 1000)); - } - console.log('rebalanceActions', rebalanceActions); + // Step 4: Append rebalance actions and generate tx + setCurrentStep('execute'); + + const rebalanceTxs = rebalanceActions.flatMap((action) => { + console.log('action', action.amount.toString()); + const withdrawTx = encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'morphoWithdraw', + args: [ + { + loanToken: action.fromMarket.loanToken as Address, + collateralToken: action.fromMarket.collateralToken as Address, + oracle: action.fromMarket.oracle as Address, + irm: action.fromMarket.irm as Address, + lltv: BigInt(action.fromMarket.lltv), + }, + action.amount, // assets + BigInt(0), // shares + maxUint256, // slippageAmount => max share burned + account, // receiver + ], + }); + + const supplyTx = encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'morphoSupply', + args: [ + { + loanToken: action.toMarket.loanToken as Address, + collateralToken: action.toMarket.collateralToken as Address, + oracle: action.toMarket.oracle as Address, + irm: action.toMarket.irm as Address, + lltv: BigInt(action.toMarket.lltv), + }, + action.amount, + BigInt(0), + BigInt(0), // slippageAmount => min share minted + account, + '0x', + ], + }); + + return [withdrawTx, supplyTx]; + }); + transactions.push(...rebalanceTxs); + + console.log('transactions', transactions); - const totalAmount = rebalanceActions.reduce( - (acc, action) => acc + BigInt(action.amount), - BigInt(0), - ); - console.log('permitting totalAmount', totalAmount.toString()); - const rebalanceTxs = rebalanceActions.flatMap((action) => { - const withdrawTx = encodeFunctionData({ + // Execute all transactions + const multicallTx = encodeFunctionData({ abi: morphoBundlerAbi, - functionName: 'morphoWithdraw', - args: [ - { - loanToken: action.fromMarket.loanToken as Address, - collateralToken: action.fromMarket.collateralToken as Address, - oracle: action.fromMarket.oracle as Address, - irm: action.fromMarket.irm as Address, - lltv: BigInt(action.fromMarket.lltv), - }, - BigInt(action.amount), // assets - BigInt(0), // shares - maxUint256, // slippageAmount => max share burned - account, // receiver - ], + functionName: 'multicall', + args: [transactions], }); - const supplyTx = encodeFunctionData({ - abi: morphoBundlerAbi, - functionName: 'morphoSupply', - args: [ - { - loanToken: action.toMarket.loanToken as Address, - collateralToken: action.toMarket.collateralToken as Address, - oracle: action.toMarket.oracle as Address, - irm: action.toMarket.irm as Address, - lltv: BigInt(action.toMarket.lltv), - }, - BigInt(action.amount), - BigInt(0), - BigInt(0), // slippageAmount => min share minted - account, - '0x', - ], + await sendTransactionAsync({ + account, + to: bundlerAddress, + data: multicallTx, + chainId: groupedPosition.chainId, }); - return [withdrawTx, supplyTx]; - }); - - transactions.push(...rebalanceTxs); - - console.log('transactions', transactions); - - const multicallTx = encodeFunctionData({ - abi: morphoBundlerAbi, - functionName: 'multicall', - args: [transactions], - }); - - sendTransaction({ - account, - to: bundlerAddress, - data: multicallTx, - chainId: groupedPosition.chainId, - }); - }, [ - account, - isAuthorized, - nonce, - bundlerAddress, - groupedPosition.chainId, - rebalanceActions, - sendTransaction, - signTypedDataAsync, - ]); + 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]); return { rebalanceActions, @@ -201,6 +245,7 @@ export function useRebalance(groupedPosition: GroupedPosition) { removeRebalanceAction, executeRebalance, isConfirming, - isAuthorized, + currentStep, + isAuthorized: permit2Authorized, }; -} +}; \ No newline at end of file diff --git a/src/utils/types.ts b/src/utils/types.ts index f7c31c44..487a3384 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -220,12 +220,13 @@ export type RebalanceAction = { lltv: string; uniqueKey: string; }; - amount: string; + amount: bigint; }; export type GroupedPosition = { loanAsset: string; loanAssetAddress: string; + loanAssetDecimals: number; chainId: number; totalSupply: number; totalWeightedApy: number; From ab56ec58ffbb6aa25bf62255ff5ddbd14e29a56e Mon Sep 17 00:00:00 2001 From: Anton Cheng Date: Tue, 8 Oct 2024 01:15:44 +0800 Subject: [PATCH 07/13] feat: feature complete --- app/markets/components/Pagination.tsx | 2 +- app/positions/components/RebalanceModal.tsx | 19 ++++---- .../components/RebalanceProcessModal.tsx | 4 +- src/hooks/useRebalance.ts | 46 +++++++++++++------ 4 files changed, 46 insertions(+), 25 deletions(-) diff --git a/app/markets/components/Pagination.tsx b/app/markets/components/Pagination.tsx index d812143d..893ed589 100644 --- a/app/markets/components/Pagination.tsx +++ b/app/markets/components/Pagination.tsx @@ -24,7 +24,7 @@ export function Pagination({ currentPage, onPageChange, entriesPerPage, - onEntriesPerPageChange + onEntriesPerPageChange, }: PaginationProps) { const { isOpen, onOpen, onOpenChange } = useDisclosure(); const [customEntries, setCustomEntries] = useState(entriesPerPage.toString()); diff --git a/app/positions/components/RebalanceModal.tsx b/app/positions/components/RebalanceModal.tsx index 9a787775..3503fda3 100644 --- a/app/positions/components/RebalanceModal.tsx +++ b/app/positions/components/RebalanceModal.tsx @@ -8,10 +8,11 @@ import { Button, } from '@nextui-org/react'; import { Table, TableHeader, TableColumn, TableBody, TableRow, TableCell } from '@nextui-org/react'; -import Input from '@/components/Input/Input' import { ArrowRightIcon } from '@radix-ui/react-icons'; import Image from 'next/image'; import { toast } from 'react-toastify'; +import { formatUnits, maxUint256 } from 'viem'; +import Input from '@/components/Input/Input'; import useMarkets, { Market } from '@/hooks/useMarkets'; import { usePagination } from '@/hooks/usePagination'; import { useRebalance } from '@/hooks/useRebalance'; @@ -20,7 +21,6 @@ import { findToken } from '@/utils/tokens'; import { GroupedPosition, RebalanceAction } from '@/utils/types'; import { FromAndToMarkets } from './FromAndToMarkets'; import { RebalanceProcessModal } from './RebalanceProcessModal'; -import { formatUnits, maxUint256 } from 'viem'; type RebalanceModalProps = { groupedPosition: GroupedPosition; @@ -39,7 +39,7 @@ function MarketBadge({ return (
{market.uniqueKey.slice(2, 8)} |{' '} - {market.collateralAsset.symbol} | {' '} {formatUnits(BigInt(market.lltv), 16)} % + {market.collateralAsset.symbol} | {formatUnits(BigInt(market.lltv), 16)} %
); } @@ -59,7 +59,7 @@ export function RebalanceModal({ groupedPosition, isOpen, onClose }: RebalanceMo removeRebalanceAction, executeRebalance, isConfirming, - currentStep + currentStep, } = useRebalance(groupedPosition); const token = findToken(groupedPosition.loanAssetAddress, groupedPosition.chainId); @@ -107,7 +107,8 @@ export function RebalanceModal({ groupedPosition, isOpen, onClose }: RebalanceMo formatBalance(fromMarket.state.supplyAssets, fromMarket.loanAsset.decimals), ); const pendingDelta = getPendingDelta(fromMarket.uniqueKey); - const availableBalance = currentBalance + formatBalance(pendingDelta.toString(), fromMarket.loanAsset.decimals); + const availableBalance = + currentBalance + formatBalance(pendingDelta.toString(), fromMarket.loanAsset.decimals); if (Number(amount) > availableBalance) { toast.error('Insufficient balance for this action'); @@ -162,7 +163,7 @@ export function RebalanceModal({ groupedPosition, isOpen, onClose }: RebalanceMo }} > - + Rebalance {groupedPosition.loanAsset ?? 'Unknown'} Positions @@ -302,12 +303,12 @@ export function RebalanceModal({ groupedPosition, isOpen, onClose }: RebalanceMo @@ -322,4 +323,4 @@ export function RebalanceModal({ groupedPosition, isOpen, onClose }: RebalanceMo )} ); -} \ No newline at end of file +} diff --git a/app/positions/components/RebalanceProcessModal.tsx b/app/positions/components/RebalanceProcessModal.tsx index ad45adca..7fd9d541 100644 --- a/app/positions/components/RebalanceProcessModal.tsx +++ b/app/positions/components/RebalanceProcessModal.tsx @@ -20,7 +20,7 @@ export function RebalanceProcessModal({ { key: 'approve', label: 'Authorize Permit2', - detail: `This one-time approval ensures you don't need to send approval transactions in the future.`, + detail: `This one-time approval ensures you don't need to send approval transactions in the future.`, }, { key: 'authorize', @@ -97,4 +97,4 @@ export function RebalanceProcessModal({
); -} \ No newline at end of file +} diff --git a/src/hooks/useRebalance.ts b/src/hooks/useRebalance.ts index 33eabf23..d5f3bad7 100644 --- a/src/hooks/useRebalance.ts +++ b/src/hooks/useRebalance.ts @@ -1,18 +1,20 @@ import { useState, useCallback } from 'react'; import { toast } from 'react-toastify'; +import { Address, encodeFunctionData, maxUint256, parseSignature } from 'viem'; import { useAccount, useReadContract, useSignTypedData } from 'wagmi'; -import { usePermit2 } from './usePermit2'; -import { GroupedPosition, RebalanceAction } from '@/utils/types'; import morphoBundlerAbi from '@/abis/bundlerV2'; import morphoAbi from '@/abis/morpho'; import { useTransactionWithToast } from '@/hooks/useTransactionWithToast'; import { getBundlerV2, MORPHO } from '@/utils/morpho'; -import { Address, encodeFunctionData, maxUint256, parseSignature } from 'viem'; +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 [currentStep, setCurrentStep] = useState< + 'idle' | 'approve' | 'authorize' | 'sign' | 'execute' + >('idle'); const { address: account } = useAccount(); const { signTypedDataAsync } = useSignTypedData(); @@ -34,13 +36,12 @@ export const useRebalance = (groupedPosition: GroupedPosition) => { chainId: groupedPosition.chainId, }); - const totalAmount = rebalanceActions.reduce((acc, action) => acc + BigInt(action.amount), BigInt(0)); + const totalAmount = rebalanceActions.reduce( + (acc, action) => acc + BigInt(action.amount), + BigInt(0), + ); - const { - authorizePermit2, - permit2Authorized, - signForBundlers, - } = usePermit2({ + const { authorizePermit2, permit2Authorized, signForBundlers } = usePermit2({ user: account as `0x${string}`, spender: getBundlerV2(groupedPosition.chainId), token: groupedPosition.loanAssetAddress as `0x${string}`, @@ -162,7 +163,13 @@ export const useRebalance = (groupedPosition: GroupedPosition) => { functionName: 'approve2', args: [permitSingle, sigs, false], }); + const transferFromTx = encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'transferFrom2', + args: [groupedPosition.loanAssetAddress as Address, totalAmount], + }); transactions.push(permitTx); + transactions.push(transferFromTx); await new Promise((resolve) => setTimeout(resolve, 1000)); // Step 4: Append rebalance actions and generate tx @@ -213,7 +220,6 @@ export const useRebalance = (groupedPosition: GroupedPosition) => { console.log('transactions', transactions); - // Execute all transactions const multicallTx = encodeFunctionData({ abi: morphoBundlerAbi, @@ -237,7 +243,21 @@ export const useRebalance = (groupedPosition: GroupedPosition) => { setIsConfirming(false); setCurrentStep('idle'); } - }, [account, permit2Authorized, authorizePermit2, signForBundlers, isAuthorized, nonce, bundlerAddress, groupedPosition.chainId, signTypedDataAsync, rebalanceActions, sendTransactionAsync]); + }, [ + account, + permit2Authorized, + authorizePermit2, + signForBundlers, + isAuthorized, + nonce, + bundlerAddress, + groupedPosition.chainId, + signTypedDataAsync, + rebalanceActions, + sendTransactionAsync, + groupedPosition.loanAssetAddress, + totalAmount + ]); return { rebalanceActions, @@ -248,4 +268,4 @@ export const useRebalance = (groupedPosition: GroupedPosition) => { currentStep, isAuthorized: permit2Authorized, }; -}; \ No newline at end of file +}; From 6388eab4377afe3eca251e95a07697821ae4da76 Mon Sep 17 00:00:00 2001 From: Anton Cheng Date: Tue, 8 Oct 2024 16:01:28 +0800 Subject: [PATCH 08/13] feat: more details and fix sequence issue --- app/positions/components/FromAndToMarkets.tsx | 258 +++++++++++------- app/positions/components/RebalanceModal.tsx | 66 ++--- src/hooks/useRebalance.ts | 71 +++-- 3 files changed, 240 insertions(+), 155 deletions(-) diff --git a/app/positions/components/FromAndToMarkets.tsx b/app/positions/components/FromAndToMarkets.tsx index 3ddf4fd9..034798c3 100644 --- a/app/positions/components/FromAndToMarkets.tsx +++ b/app/positions/components/FromAndToMarkets.tsx @@ -26,6 +26,8 @@ type MarketTablesProps = { totalPages: number; onPageChange: (page: number) => void; }; + selectedFromMarketUniqueKey: string; + selectedToMarketUniqueKey: string; }; export function FromAndToMarkets({ @@ -39,6 +41,8 @@ export function FromAndToMarkets({ onToMarketSelect, fromPagination, toPagination, + selectedFromMarketUniqueKey, + selectedToMarketUniqueKey, }: MarketTablesProps) { const filteredFromMarkets = fromMarkets.filter( (marketPosition) => @@ -72,129 +76,173 @@ export function FromAndToMarkets({ return (
-

Existing Positions

+

Your Lending Positions

{/* Updated title */} onFromFilterChange(e.target.value)} className="mb-2" /> -
- - - - - - - - - - - - {paginatedFromMarkets.map((marketPosition) => ( - 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" - > - - - - - +
+ {fromMarkets.length === 0 ? ( +
+

Loading...

+
+ ) : ( +
Market IDCollateralLLTVAPYSupplied Amount
- {marketPosition.market.uniqueKey.slice(2, 8)} - {marketPosition.market.collateralAsset.symbol} - {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(marketPosition.pendingDelta))}) - - )} -
+ + + + + + + - ))} - -
Market IDCollateralLLTVAPYSupplied Amount
+
+ {marketPosition.market.uniqueKey.slice(2, 8)} + {marketPosition.market.collateralAsset.symbol} + {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

+

Available Markets for Rebalancing

{' '} + {/* Updated title */} onToFilterChange(e.target.value)} className="mb-2" /> -
- - - - - - - - - - - - - {paginatedToMarkets.map((market) => ( - onToMarketSelect(market.uniqueKey)} - className="cursor-pointer border-b border-gray-200 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800" - > - - - - - - +
+ {toMarkets.length === 0 ? ( +
+

Loading...

+
+ ) : ( +
Market IDCollateralLLTVAPYTotal SupplyUtil Rate
- {market.uniqueKey.slice(2, 8)} - {market.collateralAsset.symbol}{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)}%
+ + + + + + + + - ))} - -
Market IDCollateralLLTVAPYTotal SupplyUtil Rate
+ + + {paginatedToMarkets.map((market) => ( + 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' + : '' + }`} + > + + {market.uniqueKey.slice(2, 8)} + + {market.collateralAsset.symbol} + {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)}% + + ))} + + + )}
-
- +
+ {' '} + {/* Reserve height for pagination */} + {toPagination.totalPages > 1 && ( // Only show pagination if more than 1 page +
+ +
+ )}
diff --git a/app/positions/components/RebalanceModal.tsx b/app/positions/components/RebalanceModal.tsx index 3503fda3..2708b393 100644 --- a/app/positions/components/RebalanceModal.tsx +++ b/app/positions/components/RebalanceModal.tsx @@ -11,12 +11,10 @@ import { Table, TableHeader, TableColumn, TableBody, TableRow, TableCell } from import { ArrowRightIcon } from '@radix-ui/react-icons'; import Image from 'next/image'; import { toast } from 'react-toastify'; -import { formatUnits, maxUint256 } from 'viem'; -import Input from '@/components/Input/Input'; +import { formatUnits, parseUnits } from 'viem'; import useMarkets, { Market } from '@/hooks/useMarkets'; import { usePagination } from '@/hooks/usePagination'; import { useRebalance } from '@/hooks/useRebalance'; -import { formatReadable, formatBalance } from '@/utils/balance'; import { findToken } from '@/utils/tokens'; import { GroupedPosition, RebalanceAction } from '@/utils/types'; import { FromAndToMarkets } from './FromAndToMarkets'; @@ -49,7 +47,7 @@ export function RebalanceModal({ groupedPosition, isOpen, onClose }: RebalanceMo const [toMarketFilter, setToMarketFilter] = useState(''); const [selectedFromMarketUniqueKey, setSelectedFromMarketUniqueKey] = useState(''); const [selectedToMarketUniqueKey, setSelectedToMarketUniqueKey] = useState(''); - const [amount, setAmount] = useState(BigInt(0)); + const [amount, setAmount] = useState('0'); const [showProcessModal, setShowProcessModal] = useState(false); const { data: allMarkets } = useMarkets(); @@ -63,7 +61,6 @@ export function RebalanceModal({ groupedPosition, isOpen, onClose }: RebalanceMo } = useRebalance(groupedPosition); const token = findToken(groupedPosition.loanAssetAddress, groupedPosition.chainId); - const fromPagination = usePagination(); const toPagination = usePagination(); @@ -89,13 +86,15 @@ export function RebalanceModal({ groupedPosition, isOpen, onClose }: RebalanceMo const handleAddAction = () => { if (selectedFromMarketUniqueKey && selectedToMarketUniqueKey && amount) { - if (Number(amount) <= 0) { + const scaledAmount = parseUnits(amount, groupedPosition.loanAssetDecimals); + if (scaledAmount <= 0) { toast.error('Amount must be greater than zero'); return; } const fromMarket = groupedPosition.markets.find( (m) => m.market.uniqueKey === selectedFromMarketUniqueKey, )?.market; + const toMarket = eligibleMarkets.find((m) => m.uniqueKey === selectedToMarketUniqueKey); if (!fromMarket || !toMarket) { @@ -103,14 +102,14 @@ export function RebalanceModal({ groupedPosition, isOpen, onClose }: RebalanceMo return; } - const currentBalance = Number( - formatBalance(fromMarket.state.supplyAssets, fromMarket.loanAsset.decimals), - ); - const pendingDelta = getPendingDelta(fromMarket.uniqueKey); - const availableBalance = - currentBalance + formatBalance(pendingDelta.toString(), fromMarket.loanAsset.decimals); + const oldBalance = groupedPosition.markets.find( + (m) => m.market.uniqueKey === selectedFromMarketUniqueKey, + )?.supplyAssets; + + const pendingDelta = getPendingDelta(selectedFromMarketUniqueKey); + const pendingBalance = BigInt(oldBalance ?? 0) + BigInt(pendingDelta); - if (Number(amount) > availableBalance) { + if (scaledAmount > pendingBalance) { toast.error('Insufficient balance for this action'); return; } @@ -132,11 +131,11 @@ export function RebalanceModal({ groupedPosition, isOpen, onClose }: RebalanceMo lltv: toMarket.lltv, uniqueKey: toMarket.uniqueKey, }, - amount, + amount: BigInt(scaledAmount), }); setSelectedFromMarketUniqueKey(''); setSelectedToMarketUniqueKey(''); - setAmount(BigInt(0)); + setAmount('0'); } }; @@ -156,30 +155,33 @@ export function RebalanceModal({ groupedPosition, isOpen, onClose }: RebalanceMo - - Rebalance {groupedPosition.loanAsset ?? 'Unknown'} Positions + + Rebalance {groupedPosition.loanAsset ?? 'Unknown'} Position -
-

- Use this tool to batch update positions or split one position into multiple markets. +

+

+ You can batch update your position by adding "Rebalance" actions to the cart. +
Optimize your portfolio by rebalancing across different collaterals and LLTVs.

-
+
Rebalance - setAmount(e.target.value)} + className="bg-hovered h-10 w-32 rounded p-2 focus:outline-none" />
{groupedPosition.loanAsset} @@ -237,15 +239,17 @@ export function RebalanceModal({ groupedPosition, isOpen, onClose }: RebalanceMo totalPages: Math.ceil(eligibleMarkets.length / 5), onPageChange: toPagination.setCurrentPage, }} + selectedFromMarketUniqueKey={selectedFromMarketUniqueKey} + selectedToMarketUniqueKey={selectedToMarketUniqueKey} />

Rebalance Cart

{rebalanceActions.length === 0 ? ( -

+

Your rebalance cart is empty. Add some actions!

) : ( - +
From Market To Market @@ -274,7 +278,8 @@ export function RebalanceModal({ groupedPosition, isOpen, onClose }: RebalanceMo /> - {formatReadable(Number(action.amount))} {groupedPosition.loanAsset} + {formatUnits(action.amount, groupedPosition.loanAssetDecimals)}{' '} + {groupedPosition.loanAsset} diff --git a/src/hooks/useRebalance.ts b/src/hooks/useRebalance.ts index d5f3bad7..79306637 100644 --- a/src/hooks/useRebalance.ts +++ b/src/hooks/useRebalance.ts @@ -168,45 +168,76 @@ export const useRebalance = (groupedPosition: GroupedPosition) => { 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); - transactions.push(transferFromTx); + await new Promise((resolve) => setTimeout(resolve, 1000)); // Step 4: Append rebalance actions and generate tx setCurrentStep('execute'); - const rebalanceTxs = rebalanceActions.flatMap((action) => { - console.log('action', action.amount.toString()); + 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: action.fromMarket.loanToken as Address, - collateralToken: action.fromMarket.collateralToken as Address, - oracle: action.fromMarket.oracle as Address, - irm: action.fromMarket.irm as Address, - lltv: BigInt(action.fromMarket.lltv), + loanToken: market.loanToken as Address, + collateralToken: market.collateralToken as Address, + oracle: market.oracle as Address, + irm: market.irm as Address, + lltv: BigInt(market.lltv), }, - action.amount, // assets + 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 totalAmount = actions.reduce((sum, action) => sum + BigInt(action.amount), BigInt(0)); + const market = actions[0].toMarket; + const supplyTx = encodeFunctionData({ abi: morphoBundlerAbi, functionName: 'morphoSupply', args: [ { - loanToken: action.toMarket.loanToken as Address, - collateralToken: action.toMarket.collateralToken as Address, - oracle: action.toMarket.oracle as Address, - irm: action.toMarket.irm as Address, - lltv: BigInt(action.toMarket.lltv), + loanToken: market.loanToken as Address, + collateralToken: market.collateralToken as Address, + oracle: market.oracle as Address, + irm: market.irm as Address, + lltv: BigInt(market.lltv), }, - action.amount, + totalAmount, BigInt(0), BigInt(0), // slippageAmount => min share minted account, @@ -214,11 +245,13 @@ export const useRebalance = (groupedPosition: GroupedPosition) => { ], }); - return [withdrawTx, supplyTx]; + supplyTxs.push(supplyTx); }); - transactions.push(...rebalanceTxs); - console.log('transactions', transactions); + // Reorder transactions + transactions.push(...withdrawTxs); + transactions.push(transferFromTx); + transactions.push(...supplyTxs); // Execute all transactions const multicallTx = encodeFunctionData({ @@ -256,7 +289,7 @@ export const useRebalance = (groupedPosition: GroupedPosition) => { rebalanceActions, sendTransactionAsync, groupedPosition.loanAssetAddress, - totalAmount + totalAmount, ]); return { From 9f842a830162cb5ee3b16159cd346bb63e60f7a8 Mon Sep 17 00:00:00 2001 From: Anton Cheng Date: Tue, 8 Oct 2024 16:20:09 +0800 Subject: [PATCH 09/13] fix: build --- src/hooks/useRebalance.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/hooks/useRebalance.ts b/src/hooks/useRebalance.ts index 79306637..62df05d8 100644 --- a/src/hooks/useRebalance.ts +++ b/src/hooks/useRebalance.ts @@ -223,7 +223,7 @@ export const useRebalance = (groupedPosition: GroupedPosition) => { // Generate batched supply transactions Object.values(groupedSupplies).forEach((actions) => { - const totalAmount = actions.reduce((sum, action) => sum + BigInt(action.amount), BigInt(0)); + const bachedAmount = actions.reduce((sum, action) => sum + BigInt(action.amount), BigInt(0)); const market = actions[0].toMarket; const supplyTx = encodeFunctionData({ @@ -237,7 +237,7 @@ export const useRebalance = (groupedPosition: GroupedPosition) => { irm: market.irm as Address, lltv: BigInt(market.lltv), }, - totalAmount, + bachedAmount, BigInt(0), BigInt(0), // slippageAmount => min share minted account, From 3fa92a62a94abc44cd8dc88e07004e5e1f8b31fd Mon Sep 17 00:00:00 2001 From: Anton Cheng Date: Tue, 8 Oct 2024 16:54:53 +0800 Subject: [PATCH 10/13] feat: add icons + better descriptions --- app/positions/components/FromAndToMarkets.tsx | 246 ++++++++++++------ app/positions/components/RebalanceModal.tsx | 10 +- .../components/RebalanceProcessModal.tsx | 41 +-- src/hooks/useRebalance.ts | 5 +- 4 files changed, 197 insertions(+), 105 deletions(-) diff --git a/app/positions/components/FromAndToMarkets.tsx b/app/positions/components/FromAndToMarkets.tsx index 034798c3..5deb1b5a 100644 --- a/app/positions/components/FromAndToMarkets.tsx +++ b/app/positions/components/FromAndToMarkets.tsx @@ -2,12 +2,21 @@ import React from 'react'; import { Input } from '@nextui-org/react'; import { Pagination } from '@nextui-org/react'; import { ArrowUpIcon, ArrowDownIcon } from '@radix-ui/react-icons'; +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'; type MarketTablesProps = { + eligibleMarkets: Market[]; fromMarkets: (MarketPosition & { pendingDelta: number })[]; toMarkets: Market[]; fromFilter: string; @@ -31,6 +40,7 @@ type MarketTablesProps = { }; export function FromAndToMarkets({ + eligibleMarkets, fromMarkets, toMarkets, fromFilter, @@ -75,8 +85,8 @@ export function FromAndToMarkets({ return (
-
-

Your Lending Positions

{/* Updated title */} +
+

Your Market Positions

- {paginatedFromMarkets.map((marketPosition) => ( - 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' - : '' - }`} - > - - - - - 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' + : '' + }`} + > + + + + + - - ))} + )} + ) + + )} + + + ); + })}
- {marketPosition.market.uniqueKey.slice(2, 8)} - {marketPosition.market.collateralAsset.symbol} - {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 ? ( - - ) : ( - + {paginatedFromMarkets.map((marketPosition) => { + const collateralToken = findToken( + marketPosition.market.collateralAsset.address, + marketPosition.market.morphoBlue.chain.id, + ); + return ( +
+ {marketPosition.market.uniqueKey.slice(2, 8)} + +
+ {collateralToken?.img && ( + {marketPosition.market.collateralAsset.symbol} )} - ( - {formatReadable( - Math.abs( - Number( - formatUnits( - BigInt(marketPosition.pendingDelta), - marketPosition.market.loanAsset.decimals, + e.stopPropagation()} + className="flex items-center gap-1 no-underline hover:underline" + > + {marketPosition.market.collateralAsset.symbol} + +
+
+ {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, + ), ), ), - ), - )} - ) - - )} -
)} @@ -174,9 +213,8 @@ export function FromAndToMarkets({
-
-

Available Markets for Rebalancing

{' '} - {/* Updated title */} +
+

Available Markets for Rebalancing

- Market ID + Market Collateral LLTV APY Total Supply Util Rate + Risks - {paginatedToMarkets.map((market) => ( - 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' - : '' - }`} - > - - {market.uniqueKey.slice(2, 8)} - - {market.collateralAsset.symbol} - {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)}% - - ))} + {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' + : '' + }`} + > + + {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 && ( +
+ + + +
+ )} + + + ); + })} )} diff --git a/app/positions/components/RebalanceModal.tsx b/app/positions/components/RebalanceModal.tsx index 2708b393..eaf83530 100644 --- a/app/positions/components/RebalanceModal.tsx +++ b/app/positions/components/RebalanceModal.tsx @@ -158,20 +158,19 @@ export function RebalanceModal({ groupedPosition, isOpen, onClose }: RebalanceMo isDismissable={false} // Prevent closing on overlay click size="5xl" classNames={{ - base: 'min-w-[1200px] z-[1000] p-6', + base: 'min-w-[1200px] z-[1000] p-4', backdrop: showProcessModal && 'z-[999]', }} > - + Rebalance {groupedPosition.loanAsset ?? 'Unknown'} Position

- You can batch update your position by adding "Rebalance" actions to the cart. -
- Optimize your portfolio by rebalancing across different collaterals and LLTVs. + Optimize your {groupedPosition.loanAsset} lending strategy by redistributing funds + across markets, add "Rebalance" actions to fine-tune your portfolio.

@@ -218,6 +217,7 @@ export function RebalanceModal({ groupedPosition, isOpen, onClose }: RebalanceMo
({ ...market, pendingDelta: getPendingDelta(market.market.uniqueKey), diff --git a/app/positions/components/RebalanceProcessModal.tsx b/app/positions/components/RebalanceProcessModal.tsx index 7fd9d541..db750cad 100644 --- a/app/positions/components/RebalanceProcessModal.tsx +++ b/app/positions/components/RebalanceProcessModal.tsx @@ -17,6 +17,11 @@ export function RebalanceProcessModal({ }: RebalanceProcessModalProps): JSX.Element { const steps = useMemo( () => [ + { + key: 'idle', + label: 'Idle', + detail: 'Waiting to start the rebalance process.', + }, { key: 'approve', label: 'Authorize Permit2', @@ -75,24 +80,26 @@ export function RebalanceProcessModal({
- {steps.map((step, index) => ( -
-
- {getStepStatus(step.key) === 'done' && } - {getStepStatus(step.key) === 'current' &&
} - {getStepStatus(step.key) === 'undone' && } -
-
-
{step.label}
- {currentStep === step.key && step.detail && ( -
- {step.detail} -
- )} + {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 &&
}
- {index < steps.length - 1 &&
} -
- ))} + ))}
diff --git a/src/hooks/useRebalance.ts b/src/hooks/useRebalance.ts index 62df05d8..c439a518 100644 --- a/src/hooks/useRebalance.ts +++ b/src/hooks/useRebalance.ts @@ -223,7 +223,10 @@ export const useRebalance = (groupedPosition: GroupedPosition) => { // Generate batched supply transactions Object.values(groupedSupplies).forEach((actions) => { - const bachedAmount = actions.reduce((sum, action) => sum + BigInt(action.amount), BigInt(0)); + const bachedAmount = actions.reduce( + (sum, action) => sum + BigInt(action.amount), + BigInt(0), + ); const market = actions[0].toMarket; const supplyTx = encodeFunctionData({ From a22df5b9c314bdffa1a451701e6e6895413f89b1 Mon Sep 17 00:00:00 2001 From: Anton Cheng Date: Tue, 8 Oct 2024 16:57:58 +0800 Subject: [PATCH 11/13] chore: fixes and nitpic --- app/positions/components/RebalanceModal.tsx | 4 ++-- src/hooks/useRebalance.ts | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/app/positions/components/RebalanceModal.tsx b/app/positions/components/RebalanceModal.tsx index eaf83530..097e4919 100644 --- a/app/positions/components/RebalanceModal.tsx +++ b/app/positions/components/RebalanceModal.tsx @@ -308,9 +308,9 @@ export function RebalanceModal({ groupedPosition, isOpen, onClose }: RebalanceMo diff --git a/src/hooks/useRebalance.ts b/src/hooks/useRebalance.ts index c439a518..65c914ca 100644 --- a/src/hooks/useRebalance.ts +++ b/src/hooks/useRebalance.ts @@ -72,7 +72,6 @@ export const useRebalance = (groupedPosition: GroupedPosition) => { toast.error('Please connect your wallet'); return; } - setIsConfirming(true); const transactions: `0x${string}`[] = []; From 4f9f1f8a36af388ab8c08aac3939cc11edffb0d6 Mon Sep 17 00:00:00 2001 From: Anton Cheng Date: Tue, 8 Oct 2024 17:49:36 +0800 Subject: [PATCH 12/13] feat: refactor feedback --- app/positions/components/FromAndToMarkets.tsx | 12 +- app/positions/components/MarketBadge.tsx | 21 ++ .../components/PositionsSummaryTable.tsx | 12 +- .../components/RebalanceActionInput.tsx | 69 +++++ app/positions/components/RebalanceCart.tsx | 84 ++++++ app/positions/components/RebalanceModal.tsx | 260 +++++++----------- .../layout/header/AccountConnect.tsx | 2 +- src/hooks/useRebalance.ts | 1 - 8 files changed, 283 insertions(+), 178 deletions(-) create mode 100644 app/positions/components/MarketBadge.tsx create mode 100644 app/positions/components/RebalanceActionInput.tsx create mode 100644 app/positions/components/RebalanceCart.tsx diff --git a/app/positions/components/FromAndToMarkets.tsx b/app/positions/components/FromAndToMarkets.tsx index 5deb1b5a..d077af01 100644 --- a/app/positions/components/FromAndToMarkets.tsx +++ b/app/positions/components/FromAndToMarkets.tsx @@ -1,7 +1,6 @@ import React from 'react'; import { Input } from '@nextui-org/react'; import { Pagination } from '@nextui-org/react'; -import { ArrowUpIcon, ArrowDownIcon } from '@radix-ui/react-icons'; import Image from 'next/image'; import { formatUnits } from 'viem'; import { Market } from '@/hooks/useMarkets'; @@ -102,7 +101,7 @@ export function FromAndToMarkets({ - + @@ -166,16 +165,11 @@ export function FromAndToMarkets({ {marketPosition.market.loanAsset.symbol} {marketPosition.pendingDelta !== 0 && ( 0 ? 'text-green-500' : 'text-red-500' }`} > - {marketPosition.pendingDelta > 0 ? ( - - ) : ( - - )} - ( + ({marketPosition.pendingDelta > 0 ? '+' : '-'} {formatReadable( Math.abs( Number( 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 f3c5c7f5..f580d1b5 100644 --- a/app/positions/components/PositionsSummaryTable.tsx +++ b/app/positions/components/PositionsSummaryTable.tsx @@ -1,6 +1,8 @@ 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'; @@ -20,6 +22,8 @@ export function PositionsSummaryTable({ setShowModal, setSelectedPosition, }: PositionTableProps) { + const { address: account } = useAccount(); + const [expandedRows, setExpandedRows] = useState>(new Set()); const [showRebalanceModal, setShowRebalanceModal] = useState(false); const [selectedGroupedPosition, setSelectedGroupedPosition] = useState( @@ -227,8 +231,12 @@ export function PositionsSummaryTable({ className="bg-hovered rounded-sm p-2 text-xs duration-300 ease-in-out hover:bg-orange-500" onClick={(e) => { e.stopPropagation(); - setSelectedGroupedPosition(position); - setShowRebalanceModal(true); + if (account) { + setSelectedGroupedPosition(position); + setShowRebalanceModal(true); + } else { + toast.info('Please connect your wallet to rebalance'); + } }} > Rebalance 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 && ( + + )} +
+ 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

+
Market IDMarket Collateral LLTV APY
+ + 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 index 097e4919..fab65f85 100644 --- a/app/positions/components/RebalanceModal.tsx +++ b/app/positions/components/RebalanceModal.tsx @@ -7,17 +7,16 @@ import { ModalFooter, Button, } from '@nextui-org/react'; -import { Table, TableHeader, TableColumn, TableBody, TableRow, TableCell } from '@nextui-org/react'; -import { ArrowRightIcon } from '@radix-ui/react-icons'; -import Image from 'next/image'; import { toast } from 'react-toastify'; -import { formatUnits, parseUnits } from 'viem'; +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 = { @@ -26,22 +25,6 @@ type RebalanceModalProps = { onClose: () => void; }; -function MarketBadge({ - market, -}: { - market: { uniqueKey: string; collateralAsset: { symbol: string }; lltv: string } | null; -}) { - if (!market) - return Select market; - - return ( -
- {market.uniqueKey.slice(2, 8)} |{' '} - {market.collateralAsset.symbol} | {formatUnits(BigInt(market.lltv), 16)} % -
- ); -} - export function RebalanceModal({ groupedPosition, isOpen, onClose }: RebalanceModalProps) { const [fromMarketFilter, setFromMarketFilter] = useState(''); const [toMarketFilter, setToMarketFilter] = useState(''); @@ -84,59 +67,84 @@ export function RebalanceModal({ groupedPosition, isOpen, onClose }: RebalanceMo }, 0); }; - const handleAddAction = () => { - if (selectedFromMarketUniqueKey && selectedToMarketUniqueKey && amount) { - const scaledAmount = parseUnits(amount, groupedPosition.loanAssetDecimals); - if (scaledAmount <= 0) { - toast.error('Amount must be greater than zero'); - return; - } - const fromMarket = groupedPosition.markets.find( - (m) => m.market.uniqueKey === selectedFromMarketUniqueKey, - )?.market; + 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 toMarket = eligibleMarkets.find((m) => m.uniqueKey === selectedToMarketUniqueKey); + const getMarkets = () => { + const fromMarket = eligibleMarkets.find((m) => m.uniqueKey === selectedFromMarketUniqueKey); - if (!fromMarket || !toMarket) { - toast.error('Invalid market selection'); - return; - } + const toMarket = eligibleMarkets.find((m) => m.uniqueKey === selectedToMarketUniqueKey); - const oldBalance = groupedPosition.markets.find( - (m) => m.market.uniqueKey === selectedFromMarketUniqueKey, - )?.supplyAssets; + if (!fromMarket || !toMarket) { + toast.error('Invalid market selection'); + return null; + } - const pendingDelta = getPendingDelta(selectedFromMarketUniqueKey); - const pendingBalance = BigInt(oldBalance ?? 0) + BigInt(pendingDelta); + return { fromMarket, toMarket }; + }; - if (scaledAmount > pendingBalance) { - toast.error('Insufficient balance for this action'); - return; - } + const checkBalance = () => { + const oldBalance = groupedPosition.markets.find( + (m) => m.market.uniqueKey === selectedFromMarketUniqueKey, + )?.supplyAssets; + + const pendingDelta = getPendingDelta(selectedFromMarketUniqueKey); + const pendingBalance = BigInt(oldBalance ?? 0) + BigInt(pendingDelta); - addRebalanceAction({ - 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: BigInt(scaledAmount), - }); - setSelectedFromMarketUniqueKey(''); - setSelectedToMarketUniqueKey(''); - setAmount('0'); + 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 () => { @@ -155,10 +163,10 @@ export function RebalanceModal({ groupedPosition, isOpen, onClose }: RebalanceMo @@ -166,7 +174,7 @@ export function RebalanceModal({ groupedPosition, isOpen, onClose }: RebalanceMo Rebalance {groupedPosition.loanAsset ?? 'Unknown'} Position - +

Optimize your {groupedPosition.loanAsset} lending strategy by redistributing funds @@ -174,47 +182,16 @@ export function RebalanceModal({ groupedPosition, isOpen, onClose }: RebalanceMo

-
- 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 as unknown as Market - } - /> -
- -
- m.uniqueKey === selectedToMarketUniqueKey, - ) as unknown as Market - } - /> -
- -
+ -

Rebalance Cart

- {rebalanceActions.length === 0 ? ( -

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

- ) : ( - - - From Market - To Market - Amount - Actions - - - {rebalanceActions.map((action: RebalanceAction, index: number) => ( - - - m.market.uniqueKey === action.fromMarket.uniqueKey, - )?.market as unknown as Market - } - /> - - - m.uniqueKey === action.toMarket.uniqueKey, - ) as unknown as Market - } - /> - - - {formatUnits(action.amount, groupedPosition.loanAssetDecimals)}{' '} - {groupedPosition.loanAsset} - - - - - - ))} - -
- )} +
- + 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 index 65c914ca..713e0223 100644 --- a/src/hooks/useRebalance.ts +++ b/src/hooks/useRebalance.ts @@ -69,7 +69,6 @@ export const useRebalance = (groupedPosition: GroupedPosition) => { const executeRebalance = useCallback(async () => { if (!account) { - toast.error('Please connect your wallet'); return; } setIsConfirming(true); From aac43217e5f74d31ce58a72ff2f4b8b89e055371 Mon Sep 17 00:00:00 2001 From: Anton Cheng Date: Tue, 8 Oct 2024 17:59:18 +0800 Subject: [PATCH 13/13] fix: review fixes --- app/positions/components/FromAndToMarkets.tsx | 10 ++++++---- app/positions/components/RebalanceModal.tsx | 8 +++++--- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/app/positions/components/FromAndToMarkets.tsx b/app/positions/components/FromAndToMarkets.tsx index d077af01..242e82e8 100644 --- a/app/positions/components/FromAndToMarkets.tsx +++ b/app/positions/components/FromAndToMarkets.tsx @@ -14,6 +14,8 @@ import { MarketDebtIndicator, } from '../../markets/components/RiskIndicator'; +import { PER_PAGE } from './RebalanceModal'; + type MarketTablesProps = { eligibleMarkets: Market[]; fromMarkets: (MarketPosition & { pendingDelta: number })[]; @@ -66,12 +68,12 @@ export function FromAndToMarkets({ ); const paginatedFromMarkets = filteredFromMarkets.slice( - (fromPagination.currentPage - 1) * 5, - fromPagination.currentPage * 5, + (fromPagination.currentPage - 1) * PER_PAGE, + fromPagination.currentPage * PER_PAGE, ); const paginatedToMarkets = filteredToMarkets.slice( - (toPagination.currentPage - 1) * 5, - toPagination.currentPage * 5, + (toPagination.currentPage - 1) * PER_PAGE, + toPagination.currentPage * PER_PAGE, ); const handleFromPaginationChange = (page: number) => { diff --git a/app/positions/components/RebalanceModal.tsx b/app/positions/components/RebalanceModal.tsx index fab65f85..c5ca524c 100644 --- a/app/positions/components/RebalanceModal.tsx +++ b/app/positions/components/RebalanceModal.tsx @@ -25,6 +25,8 @@ type RebalanceModalProps = { onClose: () => void; }; +export const PER_PAGE = 5; + export function RebalanceModal({ groupedPosition, isOpen, onClose }: RebalanceModalProps) { const [fromMarketFilter, setFromMarketFilter] = useState(''); const [toMarketFilter, setToMarketFilter] = useState(''); @@ -53,7 +55,7 @@ export function RebalanceModal({ groupedPosition, isOpen, onClose }: RebalanceMo market.loanAsset.address === groupedPosition.loanAssetAddress && market.morphoBlue.chain.id === groupedPosition.chainId, ); - }, [allMarkets, groupedPosition]); + }, [allMarkets, groupedPosition.loanAssetAddress, groupedPosition.chainId]); const getPendingDelta = (marketUniqueKey: string) => { return rebalanceActions.reduce((acc: number, action: RebalanceAction) => { @@ -208,12 +210,12 @@ export function RebalanceModal({ groupedPosition, isOpen, onClose }: RebalanceMo onToMarketSelect={setSelectedToMarketUniqueKey} fromPagination={{ currentPage: fromPagination.currentPage, - totalPages: Math.ceil(groupedPosition.markets.length / 5), + totalPages: Math.ceil(groupedPosition.markets.length / PER_PAGE), onPageChange: fromPagination.setCurrentPage, }} toPagination={{ currentPage: toPagination.currentPage, - totalPages: Math.ceil(eligibleMarkets.length / 5), + totalPages: Math.ceil(eligibleMarkets.length / PER_PAGE), onPageChange: toPagination.setCurrentPage, }} selectedFromMarketUniqueKey={selectedFromMarketUniqueKey}