From f0fa88a1da8cd02328a911ca59d0601a77abf7c7 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Thu, 17 Apr 2025 11:32:32 +0800 Subject: [PATCH 1/4] feat: add standard approval flow --- app/positions/components/RebalanceModal.tsx | 17 +- .../components/RebalanceProcessModal.tsx | 78 ++-- src/hooks/useMorphoBundlerAuthorization.ts | 179 +++++++ src/hooks/useRebalance.ts | 439 +++++++++--------- src/hooks/useSupplyMarket.ts | 3 +- 5 files changed, 463 insertions(+), 253 deletions(-) create mode 100644 src/hooks/useMorphoBundlerAuthorization.ts diff --git a/app/positions/components/RebalanceModal.tsx b/app/positions/components/RebalanceModal.tsx index a61d9f28..c8e6a20b 100644 --- a/app/positions/components/RebalanceModal.tsx +++ b/app/positions/components/RebalanceModal.tsx @@ -7,6 +7,7 @@ import { Spinner } from '@/components/common/Spinner'; import { useMarketNetwork } from '@/hooks/useMarketNetwork'; import { useMarkets } from '@/hooks/useMarkets'; import { usePagination } from '@/hooks/usePagination'; +import { useLocalStorage } from '@/hooks/useLocalStorage'; import { useRebalance } from '@/hooks/useRebalance'; import { useStyledToast } from '@/hooks/useStyledToast'; import { Market } from '@/utils/types'; @@ -39,6 +40,7 @@ export function RebalanceModal({ const [amount, setAmount] = useState('0'); const [showProcessModal, setShowProcessModal] = useState(false); const toast = useStyledToast(); + const [usePermit2Setting] = useLocalStorage('usePermit2', true); const { markets: allMarkets } = useMarkets(); const { @@ -46,9 +48,9 @@ export function RebalanceModal({ addRebalanceAction, removeRebalanceAction, executeRebalance, - isConfirming, + isProcessing, currentStep, - } = useRebalance(groupedPosition, refetch); + } = useRebalance(groupedPosition); const fromPagination = usePagination(); const toPagination = usePagination(); @@ -258,12 +260,16 @@ export function RebalanceModal({ setShowProcessModal(true); try { await executeRebalance(); + // Explicitly refetch AFTER successful execution + refetch(() => { + toast.info('Data refreshed', 'Position data updated after rebalance.'); + }); } catch (error) { console.error('Error during rebalance:', error); } finally { setShowProcessModal(false); } - }, [executeRebalance, needSwitchChain, switchToNetwork, toast]); + }, [executeRebalance, needSwitchChain, switchToNetwork, toast, refetch]); const handleManualRefresh = () => { refetch(() => { @@ -372,8 +378,8 @@ export function RebalanceModal({ diff --git a/app/positions/components/PositionsSummaryTable.tsx b/app/positions/components/PositionsSummaryTable.tsx index 4afcd514..96ce0ef8 100644 --- a/app/positions/components/PositionsSummaryTable.tsx +++ b/app/positions/components/PositionsSummaryTable.tsx @@ -1,10 +1,11 @@ import React, { useMemo, useState, useEffect } from 'react'; import { Dropdown, DropdownTrigger, DropdownMenu, DropdownItem, Tooltip } from '@nextui-org/react'; import { ChevronDownIcon, ChevronUpIcon } from '@radix-ui/react-icons'; +import { ReloadIcon } from '@radix-ui/react-icons'; import { motion, AnimatePresence } from 'framer-motion'; import Image from 'next/image'; import { BsQuestionCircle } from 'react-icons/bs'; -import { IoRefreshOutline, IoChevronDownOutline } from 'react-icons/io5'; +import { IoChevronDownOutline } from 'react-icons/io5'; import { PiHandCoins } from 'react-icons/pi'; import { PulseLoader } from 'react-spinners'; import { useAccount } from 'wagmi'; @@ -147,9 +148,10 @@ export function PositionsSummaryTable({ variant="light" size="sm" onClick={handleManualRefresh} + disabled={isRefetching} className="font-zen text-secondary opacity-80 transition-all duration-200 ease-in-out hover:opacity-100" > - + Refresh diff --git a/app/positions/components/RebalanceModal.tsx b/app/positions/components/RebalanceModal.tsx index c8e6a20b..05ca6fe0 100644 --- a/app/positions/components/RebalanceModal.tsx +++ b/app/positions/components/RebalanceModal.tsx @@ -1,6 +1,6 @@ import React, { useState, useMemo, useCallback } from 'react'; import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from '@nextui-org/react'; -import { GrRefresh } from 'react-icons/gr'; +import { ReloadIcon } from '@radix-ui/react-icons'; import { parseUnits, formatUnits } from 'viem'; import { Button } from '@/components/common'; import { Spinner } from '@/components/common/Spinner'; @@ -301,7 +301,7 @@ export function RebalanceModal({ onClick={handleManualRefresh} isDisabled={isRefetching} > - + Refresh diff --git a/src/hooks/useMorphoBundlerAuthorization.ts b/src/hooks/useMorphoBundlerAuthorization.ts index ff24962c..9ea6c120 100644 --- a/src/hooks/useMorphoBundlerAuthorization.ts +++ b/src/hooks/useMorphoBundlerAuthorization.ts @@ -4,7 +4,7 @@ import { useAccount, useReadContract, useSignTypedData } from 'wagmi'; import morphoBundlerAbi from '@/abis/bundlerV2'; import morphoAbi from '@/abis/morpho'; import { useTransactionWithToast } from '@/hooks/useTransactionWithToast'; -import { MONARCH_TX_IDENTIFIER, MORPHO } from '@/utils/morpho'; +import { MORPHO } from '@/utils/morpho'; import { useStyledToast } from './useStyledToast'; interface UseMorphoBundlerAuthorizationProps { @@ -141,16 +141,14 @@ export const useMorphoBundlerAuthorization = ({ setIsAuthorizing(true); try { // Simple Morpho setAuthorization transaction - const setAuthorizationTxData = encodeFunctionData({ - abi: morphoAbi, - functionName: 'setAuthorization', - args: [bundlerAddress, true] - }); - - await sendBundlerAuthorizationTx({ + await sendBundlerAuthorizationTx({ account: account, to: MORPHO, - data: (setAuthorizationTxData + MONARCH_TX_IDENTIFIER) as `0x${string}`, // Use Morpho directly + data: encodeFunctionData({ + abi: morphoAbi, + functionName: 'setAuthorization', + args: [bundlerAddress, true] + }), chainId: chainId, }); return true; diff --git a/src/hooks/useRebalance.ts b/src/hooks/useRebalance.ts index 1f6f7914..7990e598 100644 --- a/src/hooks/useRebalance.ts +++ b/src/hooks/useRebalance.ts @@ -22,7 +22,7 @@ export type RebalanceStepType = | 'approve_token' // For standard flow: Step 2 (if needed) | 'execute'; // Common final step -export const useRebalance = (groupedPosition: GroupedPosition) => { +export const useRebalance = (groupedPosition: GroupedPosition, onRebalance?: () => void) => { const [rebalanceActions, setRebalanceActions] = useState([]); const [isProcessing, setIsProcessing] = useState(false); // Renamed from isConfirming for clarity const [currentStep, setCurrentStep] = useState('idle'); @@ -97,9 +97,10 @@ export const useRebalance = (groupedPosition: GroupedPosition) => { successText: 'Positions rebalanced successfully', errorText: 'Failed to rebalance positions', chainId: groupedPosition.chainId, - onSuccess: async () => { // Only include internal state updates + onSuccess: async () => { setRebalanceActions([]); // Clear actions on success await refetchIsBundlerAuthorized(); // Refetch bundler auth status + if (onRebalance) onRebalance(); // Call external callback }, }); @@ -253,7 +254,7 @@ export const useRebalance = (groupedPosition: GroupedPosition) => { setCurrentStep('approve_token'); if (!isTokenApproved) { await approveToken(); // Approve ERC20 token - await new Promise((resolve) => setTimeout(resolve, 800)); // UI delay + await new Promise((resolve) => setTimeout(resolve, 1000)); // UI delay } const erc20TransferTx = encodeFunctionData({ @@ -291,7 +292,19 @@ export const useRebalance = (groupedPosition: GroupedPosition) => { ); } catch (error) { - console.error('Error during rebalance:', error); + console.error('Error during rebalance executeRebalance:', error); + // Log specific details if available, especially for standard flow issues + if (!usePermit2Setting) { + console.error('Error occurred during standard ERC20 rebalance flow.'); + } + if (error instanceof Error) { + console.error('Error message:', error.message); + // Attempt to log simulation failure details if present (common pattern) + if (error.message.toLowerCase().includes('simulation failed') || error.message.toLowerCase().includes('gas estimation failed')) { + console.error('Potential transaction simulation/estimation failure details:', error); + } + } + // Specific errors should be handled within the sub-functions (auth, approve, sign) with toasts if (error instanceof Error && !error.message.toLowerCase().includes('rejected')) { toast.error('Rebalance Failed', 'An unexpected error occurred during rebalance.'); From 459e17f25241f2eeeb7678d53af1730a8f1e0b85 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Thu, 17 Apr 2025 12:21:56 +0800 Subject: [PATCH 3/4] misc: lint and comment fixes --- .../[marketid]/components/PositionStats.tsx | 35 ++-- app/market/[chainId]/[marketid]/content.tsx | 51 ++--- app/markets/components/markets.tsx | 3 +- app/positions/components/RebalanceModal.tsx | 2 +- .../components/RebalanceProcessModal.tsx | 20 +- .../Borrow/AddCollateralAndBorrow.tsx | 6 +- .../Borrow/WithdrawCollateralAndRepay.tsx | 6 +- src/hooks/useMorphoBundlerAuthorization.ts | 115 +++++++----- src/hooks/useRebalance.ts | 177 ++++++++++-------- 9 files changed, 229 insertions(+), 186 deletions(-) diff --git a/app/market/[chainId]/[marketid]/components/PositionStats.tsx b/app/market/[chainId]/[marketid]/components/PositionStats.tsx index cb9bd2f0..2956441a 100644 --- a/app/market/[chainId]/[marketid]/components/PositionStats.tsx +++ b/app/market/[chainId]/[marketid]/components/PositionStats.tsx @@ -1,8 +1,8 @@ import { useState } from 'react'; import { Card } from '@nextui-org/card'; import { Switch } from '@nextui-org/switch'; -import { FiUser } from "react-icons/fi"; -import { HiOutlineGlobeAsiaAustralia } from "react-icons/hi2"; +import { FiUser } from 'react-icons/fi'; +import { HiOutlineGlobeAsiaAustralia } from 'react-icons/hi2'; import { Spinner } from '@/components/common/Spinner'; import { TokenIcon } from '@/components/TokenIcon'; import { formatBalance, formatReadable } from '@/utils/balance'; @@ -13,18 +13,27 @@ type PositionStatsProps = { userPosition: MarketPosition | null; positionLoading: boolean; cardStyle: string; -} +}; function ThumbIcon({ isSelected, className }: { isSelected: boolean; className?: string }) { - return isSelected ? : ; + return isSelected ? ( + + ) : ( + + ); } -export function PositionStats({ market, userPosition, positionLoading, cardStyle }: PositionStatsProps) { +export function PositionStats({ + market, + userPosition, + positionLoading, + cardStyle, +}: PositionStatsProps) { // Default to user view if they have a position, otherwise global const [viewMode, setViewMode] = useState<'global' | 'user'>(userPosition ? 'user' : 'global'); const toggleView = () => { - setViewMode(prev => prev === 'global' ? 'user' : 'global'); + setViewMode((prev) => (prev === 'global' ? 'user' : 'global')); }; const renderStats = () => { @@ -122,7 +131,7 @@ export function PositionStats({ market, userPosition, positionLoading, cardStyle formatBalance( BigInt(market.state.supplyAssets || 0), market.loanAsset.decimals, - ).toString() + ).toString(), )}{' '} {market.loanAsset.symbol} @@ -143,7 +152,7 @@ export function PositionStats({ market, userPosition, positionLoading, cardStyle formatBalance( BigInt(market.state.borrowAssets || 0), market.loanAsset.decimals, - ).toString() + ).toString(), )}{' '} {market.loanAsset.symbol} @@ -164,7 +173,7 @@ export function PositionStats({ market, userPosition, positionLoading, cardStyle formatBalance( BigInt(market.state.collateralAssets || 0), market.collateralAsset.decimals, - ).toString() + ).toString(), )}{' '} {market.collateralAsset.symbol} @@ -184,15 +193,13 @@ export function PositionStats({ market, userPosition, positionLoading, cardStyle color="primary" classNames={{ wrapper: 'mx-0', - thumbIcon: 'p-0 mr-0' + thumbIcon: 'p-0 mr-0', }} onChange={toggleView} thumbIcon={ThumbIcon} /> -
- {renderStats()} -
+
{renderStats()}
); -} \ No newline at end of file +} diff --git a/app/market/[chainId]/[marketid]/content.tsx b/app/market/[chainId]/[marketid]/content.tsx index 3751ee8d..d04c38f1 100644 --- a/app/market/[chainId]/[marketid]/content.tsx +++ b/app/market/[chainId]/[marketid]/content.tsx @@ -81,11 +81,12 @@ function MarketContent() { }); const { address } = useAccount(); - - const { - position: userPosition, - loading: positionLoading, - } = useUserPositions(address, network, marketid as string); + + const { position: userPosition, loading: positionLoading } = useUserPositions( + address, + network, + marketid as string, + ); // 6. All memoized values and callbacks const formattedOraclePrice = useMemo(() => { @@ -143,7 +144,7 @@ function MarketContent() { // 8. Derived values that depend on market data const cardStyle = 'bg-surface rounded shadow-sm p-4'; - + return ( <>
@@ -172,18 +173,18 @@ function MarketContent() { {showSupplyModal && ( - setShowSupplyModal(false)} + setShowSupplyModal(false)} position={userPosition} isMarketPage /> )} {showBorrowModal && ( - setShowBorrowModal(false)} + setShowBorrowModal(false)} oraclePrice={oraclePrice} /> )} @@ -196,18 +197,20 @@ function MarketContent() { Basic Info -
- {networkImg && ( - {network.toString()} - )} - {getNetworkName(network)} -
+ +
+ {networkImg && ( + {network.toString()} + )} + {getNetworkName(network)} +
+
diff --git a/app/markets/components/markets.tsx b/app/markets/components/markets.tsx index 37fe841b..d8e72a41 100644 --- a/app/markets/components/markets.tsx +++ b/app/markets/components/markets.tsx @@ -1,11 +1,10 @@ 'use client'; import { useCallback, useEffect, useState, useRef } from 'react'; import { useDisclosure } from '@nextui-org/react'; +import { ReloadIcon } from '@radix-ui/react-icons'; import { Chain } from '@rainbow-me/rainbowkit'; import storage from 'local-storage-fallback'; import { useRouter, useSearchParams } from 'next/navigation'; -import { FaSync } from 'react-icons/fa'; -import { ReloadIcon } from '@radix-ui/react-icons'; import { FiSettings } from 'react-icons/fi'; import { Button } from '@/components/common'; import Header from '@/components/layout/header/Header'; diff --git a/app/positions/components/RebalanceModal.tsx b/app/positions/components/RebalanceModal.tsx index 05ca6fe0..d4f0c301 100644 --- a/app/positions/components/RebalanceModal.tsx +++ b/app/positions/components/RebalanceModal.tsx @@ -4,10 +4,10 @@ import { ReloadIcon } from '@radix-ui/react-icons'; import { parseUnits, formatUnits } from 'viem'; import { Button } from '@/components/common'; import { Spinner } from '@/components/common/Spinner'; +import { useLocalStorage } from '@/hooks/useLocalStorage'; import { useMarketNetwork } from '@/hooks/useMarketNetwork'; import { useMarkets } from '@/hooks/useMarkets'; import { usePagination } from '@/hooks/usePagination'; -import { useLocalStorage } from '@/hooks/useLocalStorage'; import { useRebalance } from '@/hooks/useRebalance'; import { useStyledToast } from '@/hooks/useStyledToast'; import { Market } from '@/utils/types'; diff --git a/app/positions/components/RebalanceProcessModal.tsx b/app/positions/components/RebalanceProcessModal.tsx index b68dfcea..3ba66aaf 100644 --- a/app/positions/components/RebalanceProcessModal.tsx +++ b/app/positions/components/RebalanceProcessModal.tsx @@ -38,7 +38,9 @@ export function RebalanceProcessModal({ { key: 'execute', label: 'Confirm Rebalance', - detail: `Confirm transaction in wallet to execute ${actionsCount} rebalance action${actionsCount > 1 ? 's' : ''}.`, + detail: `Confirm transaction in wallet to execute ${actionsCount} rebalance action${ + actionsCount > 1 ? 's' : '' + }.`, }, ]; @@ -56,7 +58,9 @@ export function RebalanceProcessModal({ { key: 'execute', label: 'Confirm Rebalance', - detail: `Confirm transaction in wallet to execute ${actionsCount} rebalance action${actionsCount > 1 ? 's' : ''}.`, + detail: `Confirm transaction in wallet to execute ${actionsCount} rebalance action${ + actionsCount > 1 ? 's' : '' + }.`, }, ]; @@ -105,9 +109,15 @@ export function RebalanceProcessModal({ .map((step, index) => (
- {getStepStatus(step.key as RebalanceStepType) === 'done' && } - {getStepStatus(step.key as RebalanceStepType) === 'current' &&
} - {getStepStatus(step.key as RebalanceStepType) === 'undone' && } + {getStepStatus(step.key as RebalanceStepType) === 'done' && ( + + )} + {getStepStatus(step.key as RebalanceStepType) === 'current' && ( +
+ )} + {getStepStatus(step.key as RebalanceStepType) === 'undone' && ( + + )}
{step.label}
diff --git a/src/components/Borrow/AddCollateralAndBorrow.tsx b/src/components/Borrow/AddCollateralAndBorrow.tsx index cac3ae95..9c193100 100644 --- a/src/components/Borrow/AddCollateralAndBorrow.tsx +++ b/src/components/Borrow/AddCollateralAndBorrow.tsx @@ -84,8 +84,7 @@ export function AddCollateralAndBorrow({ } else { // Calculate current LTV from position data using oracle price const currentCollateralValue = - (BigInt(currentPosition.state.collateral) * oraclePrice) / - BigInt(10 ** 36); + (BigInt(currentPosition.state.collateral) * oraclePrice) / BigInt(10 ** 36); const currentBorrowValue = BigInt(currentPosition.state.borrowAssets || 0); if (currentCollateralValue > 0) { @@ -102,8 +101,7 @@ export function AddCollateralAndBorrow({ const newCollateral = BigInt(currentPosition?.state.collateral ?? 0) + collateralAmount; const newBorrow = BigInt(currentPosition?.state.borrowAssets ?? 0) + borrowAmount; - const newCollateralValueInLoan = - (newCollateral * oraclePrice) / BigInt(10 ** 36); + const newCollateralValueInLoan = (newCollateral * oraclePrice) / BigInt(10 ** 36); if (newCollateralValueInLoan > 0) { const ltv = (newBorrow * BigInt(10 ** 18)) / newCollateralValueInLoan; diff --git a/src/components/Borrow/WithdrawCollateralAndRepay.tsx b/src/components/Borrow/WithdrawCollateralAndRepay.tsx index 3acae0a9..89930cb2 100644 --- a/src/components/Borrow/WithdrawCollateralAndRepay.tsx +++ b/src/components/Borrow/WithdrawCollateralAndRepay.tsx @@ -107,8 +107,7 @@ export function WithdrawCollateralAndRepay({ } else { // Calculate current LTV from position data using oracle price const currentCollateralValue = - (BigInt(currentPosition.state.collateral) * oraclePrice) / - BigInt(10 ** 36); + (BigInt(currentPosition.state.collateral) * oraclePrice) / BigInt(10 ** 36); const currentBorrowValue = BigInt(currentPosition.state.borrowAssets || 0); if (currentCollateralValue > 0) { @@ -127,8 +126,7 @@ export function WithdrawCollateralAndRepay({ const newCollateral = BigInt(currentPosition.state.collateral) - withdrawAmount; const newBorrow = BigInt(currentPosition.state.borrowAssets || 0) - repayAssets; - const newCollateralValueInLoan = - (newCollateral * oraclePrice) / BigInt(10 ** 36); + const newCollateralValueInLoan = (newCollateral * oraclePrice) / BigInt(10 ** 36); if (newCollateralValueInLoan > 0) { const ltv = (newBorrow * BigInt(10 ** 18)) / newCollateralValueInLoan; diff --git a/src/hooks/useMorphoBundlerAuthorization.ts b/src/hooks/useMorphoBundlerAuthorization.ts index 9ea6c120..13c0c465 100644 --- a/src/hooks/useMorphoBundlerAuthorization.ts +++ b/src/hooks/useMorphoBundlerAuthorization.ts @@ -7,10 +7,10 @@ import { useTransactionWithToast } from '@/hooks/useTransactionWithToast'; import { MORPHO } from '@/utils/morpho'; import { useStyledToast } from './useStyledToast'; -interface UseMorphoBundlerAuthorizationProps { +type UseMorphoBundlerAuthorizationProps = { chainId: number; bundlerAddress: Address; -} +}; export const useMorphoBundlerAuthorization = ({ chainId, @@ -29,7 +29,7 @@ export const useMorphoBundlerAuthorization = ({ chainId: chainId, query: { enabled: !!account && !!bundlerAddress, - } + }, }); const { data: nonce, refetch: refetchNonce } = useReadContract({ @@ -39,25 +39,30 @@ export const useMorphoBundlerAuthorization = ({ args: [account as Address], chainId: chainId, query: { - enabled: !!account, - } + enabled: !!account, + }, }); - const { sendTransactionAsync: sendBundlerAuthorizationTx, isConfirming: isConfirmingBundlerTx } = useTransactionWithToast({ + const { sendTransactionAsync: sendBundlerAuthorizationTx, isConfirming: isConfirmingBundlerTx } = + useTransactionWithToast({ toastId: 'morpho-authorize', pendingText: 'Authorizing Bundler on Morpho', successText: 'Bundler Authorized', errorText: 'Failed to authorize Bundler', chainId, - onSuccess: async () => { - await refetchIsBundlerAuthorized(); - await refetchNonce(); - } - }); + onSuccess: () => { + void refetchIsBundlerAuthorized(); + void refetchNonce(); + }, + }); const authorizeBundlerWithSignature = useCallback(async () => { - if (!account || isBundlerAuthorized === true || !nonce) { - console.log("Skipping authorizeBundlerWithSignature:", { account, isBundlerAuthorized, nonce }); + if (!account || isBundlerAuthorized === true || nonce === undefined) { + console.log('Skipping authorizeBundlerWithSignature:', { + account, + isBundlerAuthorized, + nonce, + }); return null; // Already authorized or missing data } @@ -121,50 +126,58 @@ export const useMorphoBundlerAuthorization = ({ return authorizationTxData; } catch (error) { console.error('Error during signature authorization:', error); - if (error instanceof Error && error.message.includes('User rejected')) { - toast.error('Signature Rejected', 'Authorization signature rejected by user'); - } else { - toast.error('Authorization Failed', 'Could not authorize bundler via signature'); - } + if (error instanceof Error && error.message.includes('User rejected')) { + toast.error('Signature Rejected', 'Authorization signature rejected by user'); + } else { + toast.error('Authorization Failed', 'Could not authorize bundler via signature'); + } throw error; // Re-throw to be caught by the calling function } finally { setIsAuthorizing(false); } - }, [account, isBundlerAuthorized, nonce, chainId, bundlerAddress, signTypedDataAsync, refetchIsBundlerAuthorized, refetchNonce, toast]); + }, [ + account, + isBundlerAuthorized, + nonce, + chainId, + bundlerAddress, + signTypedDataAsync, + refetchIsBundlerAuthorized, + refetchNonce, + toast, + ]); const authorizeBundlerWithTransaction = useCallback(async () => { - if (!account || isBundlerAuthorized === true) { - console.log("Skipping authorizeBundlerWithTransaction:", { account, isBundlerAuthorized }); - return true; // Already authorized or no account - } - - setIsAuthorizing(true); - try { - // Simple Morpho setAuthorization transaction - await sendBundlerAuthorizationTx({ - account: account, - to: MORPHO, - data: encodeFunctionData({ - abi: morphoAbi, - functionName: 'setAuthorization', - args: [bundlerAddress, true] - }), - chainId: chainId, - }); - return true; - - } catch (error) { - console.error('Error during transaction authorization:', error); - // Toast is handled by useTransactionWithToast - if (error instanceof Error && error.message.includes('User rejected')) { - // Handle specific user rejection if not caught by useTransactionWithToast - toast.error('Transaction Rejected', 'Authorization transaction rejected by user'); - } - return false; // Indicate failure - } finally { - setIsAuthorizing(false); - } + if (!account || isBundlerAuthorized === true) { + console.log('Skipping authorizeBundlerWithTransaction:', { account, isBundlerAuthorized }); + return true; // Already authorized or no account + } + setIsAuthorizing(true); + try { + // Simple Morpho setAuthorization transaction + await sendBundlerAuthorizationTx({ + account: account, + to: MORPHO, + data: encodeFunctionData({ + abi: morphoAbi, + functionName: 'setAuthorization', + args: [bundlerAddress, true], + }), + chainId: chainId, + }); + return true; + } catch (error) { + console.error('Error during transaction authorization:', error); + // Toast is handled by useTransactionWithToast + if (error instanceof Error && error.message.includes('User rejected')) { + // Handle specific user rejection if not caught by useTransactionWithToast + toast.error('Transaction Rejected', 'Authorization transaction rejected by user'); + } + return false; // Indicate failure + } finally { + setIsAuthorizing(false); + } }, [account, isBundlerAuthorized, bundlerAddress, sendBundlerAuthorizationTx, chainId, toast]); return { @@ -174,4 +187,4 @@ export const useMorphoBundlerAuthorization = ({ authorizeBundlerWithTransaction, refetchIsBundlerAuthorized, }; -}; \ No newline at end of file +}; diff --git a/src/hooks/useRebalance.ts b/src/hooks/useRebalance.ts index 7990e598..1c716db2 100644 --- a/src/hooks/useRebalance.ts +++ b/src/hooks/useRebalance.ts @@ -3,14 +3,14 @@ import { Address, encodeFunctionData, maxUint256 } from 'viem'; import { useAccount } from 'wagmi'; import morphoBundlerAbi from '@/abis/bundlerV2'; import { useTransactionWithToast } from '@/hooks/useTransactionWithToast'; -import { getBundlerV2, MONARCH_TX_IDENTIFIER, MORPHO } from '@/utils/morpho'; +import { getBundlerV2, MONARCH_TX_IDENTIFIER } from '@/utils/morpho'; import { GroupedPosition, RebalanceAction } from '@/utils/types'; +import { useERC20Approval } from './useERC20Approval'; +import { useLocalStorage } from './useLocalStorage'; +import { useMorphoBundlerAuthorization } from './useMorphoBundlerAuthorization'; import { usePermit2 } from './usePermit2'; import { useStyledToast } from './useStyledToast'; import { useUserMarketsCache } from './useUserMarketsCache'; -import { useLocalStorage } from './useLocalStorage'; -import { useMorphoBundlerAuthorization } from './useMorphoBundlerAuthorization'; -import { useERC20Approval } from './useERC20Approval'; // Define more specific step types export type RebalanceStepType = @@ -32,7 +32,6 @@ export const useRebalance = (groupedPosition: GroupedPosition, onRebalance?: () const toast = useStyledToast(); const [usePermit2Setting] = useLocalStorage('usePermit2', true); // Read user setting - const totalAmount = rebalanceActions.reduce( (acc, action) => acc + BigInt(action.amount), BigInt(0), @@ -40,14 +39,14 @@ export const useRebalance = (groupedPosition: GroupedPosition, onRebalance?: () // Hook for Morpho bundler authorization (both sig and tx) const { - isBundlerAuthorized, - isAuthorizingBundler, - authorizeBundlerWithSignature, - authorizeBundlerWithTransaction, - refetchIsBundlerAuthorized, + isBundlerAuthorized, + isAuthorizingBundler, + authorizeBundlerWithSignature, + authorizeBundlerWithTransaction, + refetchIsBundlerAuthorized, } = useMorphoBundlerAuthorization({ - chainId: groupedPosition.chainId, - bundlerAddress, + chainId: groupedPosition.chainId, + bundlerAddress, }); // Hook for Permit2 handling @@ -68,15 +67,15 @@ export const useRebalance = (groupedPosition: GroupedPosition, onRebalance?: () // Hook for standard ERC20 approval const { - isApproved: isTokenApproved, - approve: approveToken, - isApproving: isTokenApproving, + isApproved: isTokenApproved, + approve: approveToken, + isApproving: isTokenApproving, } = useERC20Approval({ - token: groupedPosition.loanAssetAddress as Address, - spender: bundlerAddress, - amount: totalAmount, - tokenSymbol: groupedPosition.loanAsset, - chainId: groupedPosition.chainId, + token: groupedPosition.loanAssetAddress as Address, + spender: bundlerAddress, + amount: totalAmount, + tokenSymbol: groupedPosition.loanAsset, + chainId: groupedPosition.chainId, }); // Add newly used markets to the cache @@ -97,10 +96,10 @@ export const useRebalance = (groupedPosition: GroupedPosition, onRebalance?: () successText: 'Positions rebalanced successfully', errorText: 'Failed to rebalance positions', chainId: groupedPosition.chainId, - onSuccess: async () => { - setRebalanceActions([]); // Clear actions on success - await refetchIsBundlerAuthorized(); // Refetch bundler auth status - if (onRebalance) onRebalance(); // Call external callback + onSuccess: () => { + setRebalanceActions([]); // Clear actions on success + void refetchIsBundlerAuthorized(); // Refetch bundler auth status + if (onRebalance) void onRebalance(); // Call external callback }, }); @@ -130,19 +129,29 @@ export const useRebalance = (groupedPosition: GroupedPosition, onRebalance?: () Object.values(groupedWithdraws).forEach((actions) => { const batchAmount = actions.reduce((sum, action) => sum + BigInt(action.amount), BigInt(0)); const isWithdrawMax = actions.some((action) => action.isMax); - const shares = isWithdrawMax ? groupedPosition.markets.find( - (m) => m.market.uniqueKey === actions[0].fromMarket.uniqueKey, - )?.state.supplyShares : undefined; + const shares = isWithdrawMax + ? groupedPosition.markets.find( + (m) => m.market.uniqueKey === actions[0].fromMarket.uniqueKey, + )?.state.supplyShares + : undefined; if (isWithdrawMax && shares === undefined) { - throw new Error(`No shares found for max withdraw from market ${actions[0].fromMarket.uniqueKey}`); + throw new Error( + `No shares found for max withdraw from market ${actions[0].fromMarket.uniqueKey}`, + ); } const market = actions[0].fromMarket; // Add checks for required market properties - if (!market.loanToken || !market.collateralToken || !market.oracle || !market.irm || market.lltv === undefined) { - throw new Error(`Market data incomplete for withdraw from ${market.uniqueKey}`); + if ( + !market.loanToken || + !market.collateralToken || + !market.oracle || + !market.irm || + market.lltv === undefined + ) { + throw new Error(`Market data incomplete for withdraw from ${market.uniqueKey}`); } const withdrawTx = encodeFunctionData({ @@ -165,46 +174,48 @@ export const useRebalance = (groupedPosition: GroupedPosition, onRebalance?: () withdrawTxs.push(withdrawTx); }); - Object.values(groupedSupplies).forEach((actions) => { - const bachedAmount = actions.reduce( - (sum, action) => sum + BigInt(action.amount), - BigInt(0), - ); - const market = actions[0].toMarket; + Object.values(groupedSupplies).forEach((actions) => { + const batchedAmount = actions.reduce((sum, action) => sum + BigInt(action.amount), BigInt(0)); + const market = actions[0].toMarket; - // Add checks for required market properties - if (!market.loanToken || !market.collateralToken || !market.oracle || !market.irm || market.lltv === undefined) { - throw new Error(`Market data incomplete for supply to ${market.uniqueKey}`); - } + // Add checks for required market properties + if ( + !market.loanToken || + !market.collateralToken || + !market.oracle || + !market.irm || + market.lltv === undefined + ) { + throw new Error(`Market data incomplete for supply to ${market.uniqueKey}`); + } - const supplyTx = encodeFunctionData({ - abi: morphoBundlerAbi, - functionName: 'morphoSupply', - args: [ - { - loanToken: market.loanToken! as Address, - collateralToken: market.collateralToken! as Address, - oracle: market.oracle as Address, - irm: market.irm as Address, - lltv: BigInt(market.lltv), - }, - bachedAmount, // assets - BigInt(0), // shares (must be 0 if assets > 0) - BigInt(1), // minShares (slippage control - accept at least 1 share) - account!, // onBehalf (supply deposited for this account) - '0x', // callback data - ], - }); - supplyTxs.push(supplyTx); + const supplyTx = encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'morphoSupply', + args: [ + { + loanToken: market.loanToken! as Address, + collateralToken: market.collateralToken! as Address, + oracle: market.oracle as Address, + irm: market.irm as Address, + lltv: BigInt(market.lltv), + }, + batchedAmount, // assets + BigInt(0), // shares (must be 0 if assets > 0) + BigInt(1), // minShares (slippage control - accept at least 1 share) + account!, // onBehalf (supply deposited for this account) + '0x', // callback data + ], }); + supplyTxs.push(supplyTx); + }); return { withdrawTxs, supplyTxs, allMarketKeys }; }, [rebalanceActions, groupedPosition.markets, account]); - const executeRebalance = useCallback(async () => { if (!account || rebalanceActions.length === 0) { - toast.info('No actions', 'Please add rebalance actions first.'); + toast.info('No actions', 'Please add rebalance actions first.'); return; } setIsProcessing(true); @@ -218,36 +229,39 @@ export const useRebalance = (groupedPosition: GroupedPosition, onRebalance?: () setCurrentStep('approve_permit2'); if (!permit2Authorized) { await authorizePermit2(); // Authorize Permit2 contract - await new Promise((resolve) => setTimeout(resolve, 800)); // UI delay + await new Promise((resolve) => setTimeout(resolve, 800)); // UI delay } setCurrentStep('authorize_bundler_sig'); const bundlerAuthSigTx = await authorizeBundlerWithSignature(); // Get signature for Bundler auth if needed if (bundlerAuthSigTx) { transactions.push(bundlerAuthSigTx); - await new Promise((resolve) => setTimeout(resolve, 800)); // UI delay + await new Promise((resolve) => setTimeout(resolve, 800)); // UI delay } setCurrentStep('sign_permit'); const { sigs, permitSingle } = await signForBundlers(); // Sign for Permit2 token transfer const permitTx = encodeFunctionData({ - abi: morphoBundlerAbi, functionName: 'approve2', args: [permitSingle, sigs, false] + abi: morphoBundlerAbi, + functionName: 'approve2', + args: [permitSingle, sigs, false], }); const transferFromTx = encodeFunctionData({ - abi: morphoBundlerAbi, functionName: 'transferFrom2', args: [groupedPosition.loanAssetAddress as Address, totalAmount] + abi: morphoBundlerAbi, + functionName: 'transferFrom2', + args: [groupedPosition.loanAssetAddress as Address, totalAmount], }); transactions.push(permitTx); transactions.push(...withdrawTxs); // Withdraw first transactions.push(transferFromTx); // Then transfer assets via Permit2 transactions.push(...supplyTxs); // Then supply - } else { // --- Standard ERC20 Flow --- setCurrentStep('authorize_bundler_tx'); const bundlerTxAuthorized = await authorizeBundlerWithTransaction(); // Authorize Bundler via TX if needed if (!bundlerTxAuthorized) { - throw new Error('Failed to authorize Bundler via transaction.'); // Stop if auth tx fails/is rejected + throw new Error('Failed to authorize Bundler via transaction.'); // Stop if auth tx fails/is rejected } // Wait for tx confirmation implicitly handled by useTransactionWithToast within authorizeBundlerWithTransaction @@ -257,10 +271,10 @@ export const useRebalance = (groupedPosition: GroupedPosition, onRebalance?: () await new Promise((resolve) => setTimeout(resolve, 1000)); // UI delay } - const erc20TransferTx = encodeFunctionData({ - abi: morphoBundlerAbi, - functionName: 'erc20TransferFrom', - args: [groupedPosition.loanAssetAddress as Address, totalAmount], + const erc20TransferTx = encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'erc20TransferFrom', + args: [groupedPosition.loanAssetAddress as Address, totalAmount], }); transactions.push(...withdrawTxs); // Withdraw first @@ -283,31 +297,32 @@ export const useRebalance = (groupedPosition: GroupedPosition, onRebalance?: () chainId: groupedPosition.chainId, }); - // Add newly used markets to the cache (on success callback of sendTransactionAsync handles this now) - batchAddUserMarkets( + batchAddUserMarkets( allMarketKeys.map((key) => ({ marketUniqueKey: key, chainId: groupedPosition.chainId, })), ); - } catch (error) { console.error('Error during rebalance executeRebalance:', error); // Log specific details if available, especially for standard flow issues if (!usePermit2Setting) { - console.error('Error occurred during standard ERC20 rebalance flow.'); + console.error('Error occurred during standard ERC20 rebalance flow.'); } if (error instanceof Error) { console.error('Error message:', error.message); // Attempt to log simulation failure details if present (common pattern) - if (error.message.toLowerCase().includes('simulation failed') || error.message.toLowerCase().includes('gas estimation failed')) { - console.error('Potential transaction simulation/estimation failure details:', error); + if ( + error.message.toLowerCase().includes('simulation failed') || + error.message.toLowerCase().includes('gas estimation failed') + ) { + console.error('Potential transaction simulation/estimation failure details:', error); } } // Specific errors should be handled within the sub-functions (auth, approve, sign) with toasts if (error instanceof Error && !error.message.toLowerCase().includes('rejected')) { - toast.error('Rebalance Failed', 'An unexpected error occurred during rebalance.'); + toast.error('Rebalance Failed', 'An unexpected error occurred during rebalance.'); } // Don't re-throw generic errors if specific ones were already handled } finally { @@ -337,8 +352,8 @@ export const useRebalance = (groupedPosition: GroupedPosition, onRebalance?: () ]); // Determine overall loading state - const isLoading = isProcessing || isLoadingPermit2 || isTokenApproving || isAuthorizingBundler || isExecuting; - + const isLoading = + isProcessing || isLoadingPermit2 || isTokenApproving || isAuthorizingBundler || isExecuting; return { rebalanceActions, @@ -350,6 +365,6 @@ export const useRebalance = (groupedPosition: GroupedPosition, onRebalance?: () // Expose relevant states for UI feedback isBundlerAuthorized, permit2Authorized, // Relevant only if usePermit2Setting is true - isTokenApproved, // Relevant only if usePermit2Setting is false + isTokenApproved, // Relevant only if usePermit2Setting is false }; }; From 604046cb9c391e70e3233f1109cd321e442e6bfa Mon Sep 17 00:00:00 2001 From: antoncoding Date: Thu, 17 Apr 2025 12:28:27 +0800 Subject: [PATCH 4/4] chore: fix first time user issue --- src/hooks/useUserPositionsSummaryData.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/hooks/useUserPositionsSummaryData.ts b/src/hooks/useUserPositionsSummaryData.ts index a7a7fb9c..fcb0ec77 100644 --- a/src/hooks/useUserPositionsSummaryData.ts +++ b/src/hooks/useUserPositionsSummaryData.ts @@ -75,8 +75,6 @@ const fetchBlockNumbers = async () => { const useUserPositionsSummaryData = (user: string | undefined) => { const [hasInitialData, setHasInitialData] = useState(false); - console.log('usePositionsSummaryData', user); - const { data: positions, loading: positionsLoading, @@ -85,6 +83,9 @@ const useUserPositionsSummaryData = (user: string | undefined) => { refetch: refetchPositions, } = useUserPositions(user, true); + console.log('positionsLoading', positionsLoading); + console.log('hasInitialData', hasInitialData); + const { fetchTransactions } = useUserTransactions(); // Query for block numbers - this runs once and is cached @@ -165,7 +166,7 @@ const useUserPositionsSummaryData = (user: string | undefined) => { // Update hasInitialData when we first get positions with earnings useEffect(() => { - if (positionsWithEarnings && positionsWithEarnings.length > 0 && !hasInitialData) { + if (positionsWithEarnings && !hasInitialData) { setHasInitialData(true); } }, [positionsWithEarnings, hasInitialData]);