diff --git a/CLAUDE.md b/CLAUDE.md index d99a961..be70cdf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,8 +24,17 @@ pnpm lint:fix # Auto-fix ESLint issues ### Testing Individual Files ```bash -pnpm test -- src/utils/__tests__/retry-with-fallback.test.js +# Run tests in non-interactive mode (CI/automated) +pnpm test -- --no-watch --passWithNoTests --watchAll=false + +# Run specific test file in non-interactive mode +pnpm test -- --no-watch --passWithNoTests --watchAll=false src/utils/__tests__/retry-with-fallback.test.js + +# Run tests by pattern pnpm test -- --testNamePattern="test name pattern" + +# Watch mode (interactive) +pnpm test -- src/utils/__tests__/retry-with-fallback.test.js ``` ## Architecture Overview diff --git a/src/components/AssignNewManager.js b/src/components/AssignNewManager.js index 5703708..f461b90 100644 --- a/src/components/AssignNewManager.js +++ b/src/components/AssignNewManager.js @@ -4,6 +4,7 @@ import { useSettings } from '../contexts/SettingsContext'; import { ethers } from 'ethers'; import toast from 'react-hot-toast'; import { motion } from 'framer-motion'; +import { handleTransactionError } from '../utils/error-handler'; const AssignNewManager = ({ assistant, onClose, onSuccess }) => { const { signer } = useWeb3(); @@ -122,46 +123,10 @@ const AssignNewManager = ({ assistant, onClose, onSuccess }) => { toast.error('Transaction failed', { id: 'assign-manager' }); } } catch (error) { - console.error('Error assigning new manager:', error); - - // Handle user rejection gracefully - if (error.code === 'ACTION_REJECTED' || error.code === 'USER_REJECTED' || - error.message?.includes('user rejected') || error.message?.includes('User denied')) { - toast.error('Transaction cancelled by user', { id: 'assign-manager' }); - return; // Don't show additional error messages for user cancellation - } - - // Handle insufficient funds - if (error.code === 'INSUFFICIENT_FUNDS' || error.message?.includes('insufficient funds')) { - toast.error('Insufficient funds for gas fees', { id: 'assign-manager' }); - } - // Handle network issues - else if (error.code === 'NETWORK_ERROR' || error.message?.includes('network')) { - toast.error('Network error. Please check your connection and try again', { id: 'assign-manager' }); - } - // Handle contract-specific errors - else if (error.message?.includes('zero address')) { - toast.error('Cannot assign zero address as manager', { id: 'assign-manager' }); - } else if (error.message?.includes('P3D precompile cannot be manager')) { - toast.error('P3D precompile cannot be assigned as manager', { id: 'assign-manager' }); - } else if (error.message?.includes('ERC20 precompile cannot be manager')) { - toast.error('ERC20 precompile cannot be assigned as manager', { id: 'assign-manager' }); - } else if (error.message?.includes('onlyManager')) { - toast.error('Only the current manager can assign a new manager', { id: 'assign-manager' }); - } - // Handle gas estimation errors - else if (error.message?.includes('gas') || error.code === 'UNPREDICTABLE_GAS_LIMIT') { - toast.error('Gas estimation failed. Please try again', { id: 'assign-manager' }); - } - // Handle timeout errors - else if (error.message?.includes('timeout') || error.code === 'TIMEOUT') { - toast.error('Transaction timeout. Please try again', { id: 'assign-manager' }); - } - // Generic error handling - else { - const errorMessage = error.message || error.reason || 'Unknown error occurred'; - toast.error(`Failed to assign new manager: ${errorMessage}`, { id: 'assign-manager' }); - } + handleTransactionError(error, { + messagePrefix: 'Failed to assign new manager: ', + toastOptions: { id: 'assign-manager' } + }); } finally { setLoading(false); } diff --git a/src/components/Challenge.js b/src/components/Challenge.js index b53816f..08a987a 100644 --- a/src/components/Challenge.js +++ b/src/components/Challenge.js @@ -14,6 +14,7 @@ import { } from 'lucide-react'; import { motion, AnimatePresence } from 'framer-motion'; import toast from 'react-hot-toast'; +import { handleTransactionError } from '../utils/error-handler'; // Get maximum allowance value (2^256 - 1) const getMaxAllowance = () => { @@ -571,23 +572,9 @@ const Challenge = ({ claim, onChallengeSuccess, onClose }) => { onClose(); } catch (error) { - console.error('Error challenging claim:', error); - - // Handle different types of errors gracefully - if (error.code === 'ACTION_REJECTED' || error.message?.includes('user rejected')) { - toast.error('Transaction was cancelled by user'); - } else if (error.code === 'INSUFFICIENT_FUNDS') { - toast.error('Insufficient funds for transaction'); - } else if (error.code === 'NETWORK_ERROR' || error.message?.includes('network')) { - toast.error('Network error. Please check your connection'); - } else if (error.message?.includes('gas')) { - toast.error('Transaction failed due to gas issues'); - } else if (error.message?.includes('revert')) { - toast.error('Transaction was reverted by the contract'); - } else { - // For other errors, show a generic message but log the full error - toast.error('Transaction failed. Please try again'); - } + handleTransactionError(error, { + messagePrefix: 'Failed to challenge: ' + }); } finally { setLoading(false); } @@ -657,21 +644,9 @@ const Challenge = ({ claim, onChallengeSuccess, onClose }) => { } ); } catch (error) { - console.error('❌ Error revoking allowance:', error); - - if (error.code === 'ACTION_REJECTED' || error.message?.includes('user rejected')) { - toast.error('Transaction was cancelled by user'); - } else if (error.code === 'INSUFFICIENT_FUNDS') { - toast.error('Insufficient funds for transaction'); - } else if (error.code === 'NETWORK_ERROR' || error.message?.includes('network')) { - toast.error('Network error. Please check your connection'); - } else if (error.message?.includes('gas')) { - toast.error('Transaction failed due to gas issues'); - } else if (error.message?.includes('revert')) { - toast.error('Transaction was reverted by the contract'); - } else { - toast.error('Transaction failed. Please try again'); - } + handleTransactionError(error, { + messagePrefix: 'Failed to revoke allowance: ' + }); } finally { setIsRevoking(false); } diff --git a/src/components/CreateNewAssistant.js b/src/components/CreateNewAssistant.js index 2f04d46..d8e55c8 100644 --- a/src/components/CreateNewAssistant.js +++ b/src/components/CreateNewAssistant.js @@ -15,6 +15,7 @@ import { import { motion, AnimatePresence } from 'framer-motion'; import toast from 'react-hot-toast'; import { ethers } from 'ethers'; +import { handleTransactionError } from '../utils/error-handler'; const CreateNewAssistant = ({ networkKey, onClose, onAssistantCreated }) => { const { signer, account } = useWeb3(); @@ -358,25 +359,9 @@ const CreateNewAssistant = ({ networkKey, onClose, onAssistantCreated }) => { } } catch (error) { - console.error('Error creating assistant:', error); - - // Handle different types of errors gracefully - if (error.code === 'ACTION_REJECTED' || error.message?.includes('user rejected')) { - toast.error('Transaction was cancelled by user'); - } else if (error.code === 'INSUFFICIENT_FUNDS') { - toast.error('Insufficient funds to pay for gas fees'); - } else if (error.code === 'UNPREDICTABLE_GAS_LIMIT') { - toast.error('Gas estimation failed. Please try again or increase gas limit'); - } else if (error.message?.includes('execution reverted')) { - // Extract revert reason if available - const revertReason = error.message.match(/execution reverted: (.+)/)?.[1] || 'Transaction failed'; - toast.error(`Transaction failed: ${revertReason}`); - } else if (error.message?.includes('network')) { - toast.error('Network error. Please check your connection and try again'); - } else { - // Generic error message for other cases - toast.error(`Failed to create assistant: ${error.message || 'Unknown error'}`); - } + handleTransactionError(error, { + messagePrefix: 'Failed to create assistant: ' + }); } finally { setIsCreating(false); } @@ -451,25 +436,9 @@ const CreateNewAssistant = ({ networkKey, onClose, onAssistantCreated }) => { toast.success('Precompile approved successfully'); } catch (error) { - console.error('Error approving precompile:', error); - - // Handle different types of errors gracefully - if (error.code === 'ACTION_REJECTED' || error.message?.includes('user rejected')) { - toast.error('Approval transaction was cancelled by user'); - } else if (error.code === 'INSUFFICIENT_FUNDS') { - toast.error('Insufficient funds to pay for gas fees'); - } else if (error.code === 'UNPREDICTABLE_GAS_LIMIT') { - toast.error('Gas estimation failed. Please try again or increase gas limit'); - } else if (error.message?.includes('execution reverted')) { - // Extract revert reason if available - const revertReason = error.message.match(/execution reverted: (.+)/)?.[1] || 'Transaction failed'; - toast.error(`Approval failed: ${revertReason}`); - } else if (error.message?.includes('network')) { - toast.error('Network error. Please check your connection and try again'); - } else { - // Generic error message for other cases - toast.error(`Failed to approve precompile: ${error.message || 'Unknown error'}`); - } + handleTransactionError(error, { + messagePrefix: 'Failed to approve precompile: ' + }); } finally { setIsApproving(false); } diff --git a/src/components/CreateNewBridge.js b/src/components/CreateNewBridge.js index 7d98ce7..19ddf7e 100644 --- a/src/components/CreateNewBridge.js +++ b/src/components/CreateNewBridge.js @@ -16,6 +16,7 @@ import { import { motion, AnimatePresence } from 'framer-motion'; import toast from 'react-hot-toast'; import { ethers } from 'ethers'; +import { handleTransactionError } from '../utils/error-handler'; // Constants const DEFAULT_COUNTERSTAKE_COEF = '160'; // 1.6% @@ -673,61 +674,10 @@ const CreateNewBridge = ({ networkKey, onClose, onBridgeCreated }) => { } } catch (error) { - console.error('Error creating bridge:', error); - - // Dismiss any loading toasts first toast.dismiss(); - - // Handle different types of errors gracefully - if (error.code === 4001 || - error.code === 'ACTION_REJECTED' || - error.message?.includes('User denied transaction') || - error.message?.includes('user rejected transaction') || - error.message?.includes('User rejected')) { - // User cancelled the transaction in MetaMask - toast.error('Transaction cancelled by user'); - } else if (error.code === -32603 || - error.message?.includes('insufficient funds') || - error.message?.includes('insufficient balance')) { - // Insufficient funds - toast.error('Insufficient funds for transaction. Please check your wallet balance.'); - } else if (error.message?.includes('gas') || - error.message?.includes('Gas') || - error.code === -32000) { - // Gas related errors - toast.error('Transaction failed due to gas issues. Please try again or increase gas limit.'); - } else if (error.message?.includes('revert') || - error.message?.includes('execution reverted')) { - // Contract revert - provide more specific error messages - if (error.message?.includes('bad oracle') || error.message?.includes('no price from oracle')) { - toast.error('Oracle validation failed. Please ensure the oracle has price data for the required token pairs.'); - } else if (error.message?.includes('bad stake token') || error.message?.includes('symbol')) { - toast.error('Stake token validation failed. Please select a different stake token that implements the symbol() function properly.'); - } else { - toast.error('Transaction failed. Please check your inputs and try again.'); - } - } else if (error.message?.includes('network') || - error.message?.includes('Network')) { - // Network related errors - toast.error('Network error. Please check your connection and try again.'); - } else if (error.message?.includes('timeout') || - error.message?.includes('Timeout')) { - // Timeout errors - toast.error('Transaction timed out. Please try again.'); - } else if (error.message?.includes('nonce')) { - // Nonce errors - toast.error('Transaction nonce error. Please try again.'); - } else { - // Generic error - show a more user-friendly message - const errorMessage = error.message || 'Unknown error occurred'; - console.error('Unhandled error details:', { - code: error.code, - message: error.message, - reason: error.reason, - action: error.action - }); - toast.error(`Failed to create bridge: ${errorMessage}`); - } + handleTransactionError(error, { + messagePrefix: 'Failed to create bridge: ' + }); } finally { setIsCreating(false); } diff --git a/src/components/DeployNewOracle.js b/src/components/DeployNewOracle.js index d957350..ee183a3 100644 --- a/src/components/DeployNewOracle.js +++ b/src/components/DeployNewOracle.js @@ -11,6 +11,7 @@ import { import { motion, AnimatePresence } from 'framer-motion'; import toast from 'react-hot-toast'; import { ethers } from 'ethers'; +import { handleTransactionError } from '../utils/error-handler'; // Oracle contract ABI (complete ABI for deployment and interaction) const ORACLE_ABI = [ @@ -232,55 +233,10 @@ const DeployNewOracle = ({ networkKey, onClose, onOracleCreated }) => { } } catch (error) { - console.error('Error deploying oracle:', error); - - // Dismiss any loading toasts first toast.dismiss(); - - // Handle different types of errors gracefully - if (error.code === 4001 || - error.code === 'ACTION_REJECTED' || - error.message?.includes('User denied transaction') || - error.message?.includes('user rejected transaction') || - error.message?.includes('User rejected')) { - // User cancelled the transaction in MetaMask - toast.error('Transaction cancelled by user'); - } else if (error.code === -32603 || - error.message?.includes('insufficient funds') || - error.message?.includes('insufficient balance')) { - // Insufficient funds - toast.error('Insufficient funds for transaction. Please check your wallet balance.'); - } else if (error.message?.includes('gas') || - error.message?.includes('Gas') || - error.code === -32000) { - // Gas related errors - toast.error('Transaction failed due to gas issues. Please try again or increase gas limit.'); - } else if (error.message?.includes('revert') || - error.message?.includes('execution reverted')) { - // Contract revert - toast.error('Transaction failed. Please check your inputs and try again.'); - } else if (error.message?.includes('network') || - error.message?.includes('Network')) { - // Network related errors - toast.error('Network error. Please check your connection and try again.'); - } else if (error.message?.includes('timeout') || - error.message?.includes('Timeout')) { - // Timeout errors - toast.error('Transaction timed out. Please try again.'); - } else if (error.message?.includes('nonce')) { - // Nonce errors - toast.error('Transaction nonce error. Please try again.'); - } else { - // Generic error - show a more user-friendly message - const errorMessage = error.message || 'Unknown error occurred'; - console.error('Unhandled error details:', { - code: error.code, - message: error.message, - reason: error.reason, - action: error.action - }); - toast.error(`Failed to deploy oracle: ${errorMessage}`); - } + handleTransactionError(error, { + messagePrefix: 'Failed to deploy oracle: ' + }); } finally { setIsDeploying(false); } diff --git a/src/components/Deposit.js b/src/components/Deposit.js index 9a1bfbb..3922036 100644 --- a/src/components/Deposit.js +++ b/src/components/Deposit.js @@ -3,14 +3,15 @@ import { useWeb3 } from '../contexts/Web3Context'; import { useSettings } from '../contexts/SettingsContext'; import { ethers } from 'ethers'; import toast from 'react-hot-toast'; -import { - EXPORT_ASSISTANT_ABI, - EXPORT_WRAPPER_ASSISTANT_ABI, - IMPORT_ASSISTANT_ABI, +import { + EXPORT_ASSISTANT_ABI, + EXPORT_WRAPPER_ASSISTANT_ABI, + IMPORT_ASSISTANT_ABI, IMPORT_WRAPPER_ASSISTANT_ABI, BATCH_ABI, IPRECOMPILE_ERC20_ABI } from '../contracts/abi'; +import { handleTransactionError } from '../utils/error-handler'; const Deposit = ({ assistant, onClose, onSuccess }) => { console.log('🎯 Deposit component rendered for assistant:', assistant.address); @@ -799,25 +800,9 @@ const Deposit = ({ assistant, onClose, onSuccess }) => { } } catch (error) { - console.error('Batch approval error:', error); - - if (error.code === 'ACTION_REJECTED') { - toast.error('Transaction was rejected by user'); - } else if (error.code === 'INSUFFICIENT_FUNDS') { - toast.error('Insufficient funds for gas'); - } else if (error.message?.includes('gas')) { - toast.error('Gas estimation failed. Please try again.'); - } else if (error.message?.includes('revert')) { - toast.error('Transaction failed. Please check your inputs.'); - } else if (error.code === 'NETWORK_ERROR') { - toast.error('Network error. Please check your connection.'); - } else if (error.code === 'NONCE_EXPIRED') { - toast.error('Transaction nonce expired. Please try again.'); - } else if (error.message?.includes('timeout')) { - toast.error('Transaction timeout. Please try again.'); - } else { - toast.error(`Batch approval failed: ${error.message || 'Unknown error'}`); - } + handleTransactionError(error, { + messagePrefix: 'Failed to approve tokens: ' + }); } finally { setLoading(false); } @@ -938,7 +923,7 @@ const Deposit = ({ assistant, onClose, onSuccess }) => { } else if (error.message?.includes('timeout')) { toast.error('Transaction timeout. Please try again.'); } else { - toast.error(`Batch revoke failed: ${error.message || 'Unknown error'}`); + toast.error(`Failed to revoke allowances: ${error.message || 'Unknown error'}`); } } finally { setIsRevoking(false); @@ -993,29 +978,9 @@ const Deposit = ({ assistant, onClose, onSuccess }) => { toast.success('Allowance revoked successfully!'); } catch (error) { - console.error('❌ Revoke failed:', error); - - // Handle different types of errors gracefully - if (error.code === 'ACTION_REJECTED' || error.message?.includes('User denied')) { - toast.error('Transaction was cancelled by user'); - } else if (error.code === 'INSUFFICIENT_FUNDS') { - toast.error('Insufficient funds for transaction'); - } else if (error.message?.includes('gas')) { - toast.error('Transaction failed due to gas issues. Please try again.'); - } else if (error.message?.includes('revert')) { - toast.error('Transaction failed. Please check your inputs and try again.'); - } else if (error.message?.includes('network')) { - toast.error('Network error. Please check your connection and try again.'); - } else if (error.message?.includes('execution reverted')) { - toast.error('Transaction reverted. Please check your inputs and try again.'); - } else if (error.message?.includes('nonce')) { - toast.error('Transaction nonce error. Please try again.'); - } else if (error.message?.includes('timeout')) { - toast.error('Transaction timeout. Please try again.'); - } else { - const errorMessage = error.reason || error.message || 'Revoke failed'; - toast.error(errorMessage); - } + handleTransactionError(error, { + messagePrefix: 'Failed to revoke allowance: ' + }); } finally { setIsRevoking(false); } @@ -1182,29 +1147,9 @@ const Deposit = ({ assistant, onClose, onSuccess }) => { toast.success('Approval successful!'); } catch (error) { - console.error('❌ Approval failed:', error); - - // Handle different types of errors gracefully - if (error.code === 'ACTION_REJECTED' || error.message?.includes('User denied')) { - toast.error('Transaction was cancelled by user'); - } else if (error.code === 'INSUFFICIENT_FUNDS') { - toast.error('Insufficient funds for transaction'); - } else if (error.message?.includes('gas')) { - toast.error('Transaction failed due to gas issues. Please try again.'); - } else if (error.message?.includes('revert')) { - toast.error('Transaction failed. Please check your inputs and try again.'); - } else if (error.message?.includes('network')) { - toast.error('Network error. Please check your connection and try again.'); - } else if (error.message?.includes('execution reverted')) { - toast.error('Transaction reverted. Please check your inputs and try again.'); - } else if (error.message?.includes('nonce')) { - toast.error('Transaction nonce error. Please try again.'); - } else if (error.message?.includes('timeout')) { - toast.error('Transaction timeout. Please try again.'); - } else { - const errorMessage = error.reason || error.message || 'Approval failed'; - toast.error(errorMessage); - } + handleTransactionError(error, { + messagePrefix: 'Failed to approve: ' + }); } finally { setLoading(false); } @@ -1981,30 +1926,9 @@ const Deposit = ({ assistant, onClose, onSuccess }) => { setStep('success'); onSuccess(); } catch (error) { - console.error('Deposit error:', error); - - // Handle different types of errors gracefully - if (error.code === 'ACTION_REJECTED' || error.message?.includes('User denied')) { - toast.error('Transaction was cancelled by user'); - } else if (error.code === 'INSUFFICIENT_FUNDS') { - toast.error('Insufficient funds for transaction'); - } else if (error.message?.includes('gas')) { - toast.error('Transaction failed due to gas issues. Please try again.'); - } else if (error.message?.includes('revert')) { - toast.error('Transaction failed. Please check your inputs and try again.'); - } else if (error.message?.includes('network')) { - toast.error('Network error. Please check your connection and try again.'); - } else if (error.message?.includes('execution reverted')) { - toast.error('Transaction reverted. Please check your inputs and try again.'); - } else if (error.message?.includes('nonce')) { - toast.error('Transaction nonce error. Please try again.'); - } else if (error.message?.includes('timeout')) { - toast.error('Transaction timeout. Please try again.'); - } else { - // Extract meaningful error message if available - const errorMessage = error.reason || error.message || 'Deposit failed'; - toast.error(errorMessage); - } + handleTransactionError(error, { + messagePrefix: 'Failed to deposit: ' + }); } finally { setLoading(false); } diff --git a/src/components/Withdraw.js b/src/components/Withdraw.js index ad7a60a..800d0d1 100644 --- a/src/components/Withdraw.js +++ b/src/components/Withdraw.js @@ -3,6 +3,7 @@ import { useWeb3 } from '../contexts/Web3Context'; import { useSettings } from '../contexts/SettingsContext'; import { ethers } from 'ethers'; import toast from 'react-hot-toast'; +import { handleTransactionError } from '../utils/error-handler'; import { EXPORT_ASSISTANT_ABI, EXPORT_WRAPPER_ASSISTANT_ABI, @@ -811,45 +812,9 @@ const Withdraw = ({ assistant, onClose, onSuccess }) => { onSuccess(); } } catch (error) { - console.error('Withdraw error:', error); - console.error('Error details:', { - code: error.code, - message: error.message, - reason: error.reason, - data: error.data, - stack: error.stack + handleTransactionError(error, { + messagePrefix: 'Failed to withdraw: ' }); - - // Handle different types of errors gracefully - if (error.code === 'ACTION_REJECTED' || error.message?.includes('User denied')) { - toast.error('Transaction was cancelled by user'); - } else if (error.code === 'INSUFFICIENT_FUNDS') { - toast.error('Insufficient funds for transaction'); - } else if (error.code === -32603) { - // Internal JSON-RPC error - usually indicates a contract revert - console.error('❌ Internal JSON-RPC error detected. This usually means the contract call reverted.'); - if (error.message?.includes('Internal JSON-RPC error')) { - toast.error('Transaction failed. The contract may have reverted. Check console for details.'); - } else { - toast.error('Internal RPC error. Please try again or check your inputs.'); - } - } else if (error.message?.includes('gas')) { - toast.error('Transaction failed due to gas issues. Please try again.'); - } else if (error.message?.includes('revert')) { - toast.error('Transaction failed. Please check your inputs and try again.'); - } else if (error.message?.includes('network')) { - toast.error('Network error. Please check your connection and try again.'); - } else if (error.message?.includes('execution reverted')) { - toast.error('Transaction reverted. Please check your inputs and try again.'); - } else if (error.message?.includes('nonce')) { - toast.error('Transaction nonce error. Please try again.'); - } else if (error.message?.includes('timeout')) { - toast.error('Transaction timeout. Please try again.'); - } else { - // Extract meaningful error message if available - const errorMessage = error.reason || error.message || 'Withdraw failed'; - toast.error(errorMessage); - } } finally { setLoading(false); setIsProcessing(false); diff --git a/src/components/WithdrawManagementFee.js b/src/components/WithdrawManagementFee.js index ee22577..5a79078 100644 --- a/src/components/WithdrawManagementFee.js +++ b/src/components/WithdrawManagementFee.js @@ -3,6 +3,7 @@ import { useWeb3 } from '../contexts/Web3Context'; import { ethers } from 'ethers'; import toast from 'react-hot-toast'; import { IMPORT_ASSISTANT_ABI, IMPORT_WRAPPER_ASSISTANT_ABI, EXPORT_ASSISTANT_ABI } from '../contracts/abi'; +import { handleTransactionError } from '../utils/error-handler'; const WithdrawManagementFee = ({ assistant, onClose, onSuccess }) => { const { provider, signer } = useWeb3(); @@ -145,34 +146,9 @@ const WithdrawManagementFee = ({ assistant, onClose, onSuccess }) => { onClose(); } catch (error) { - console.error('Error withdrawing management fee:', error); - - if (error.code === 4001 || error.code === 'ACTION_REJECTED') { - toast.error('Transaction rejected by user'); - } else if (error.message?.includes('insufficient funds') || error.message?.includes('insufficient balance')) { - toast.error('Insufficient funds for gas'); - } else if (error.message?.includes('onlyManager') || error.message?.includes('not manager')) { - toast.error('Only the manager can withdraw management fees'); - } else if (error.message?.includes('no management fee') || error.message?.includes('no fee')) { - toast.error('No management fees available to withdraw'); - } else if (error.message?.includes('network') || error.message?.includes('connection')) { - toast.error('Network error. Please check your connection and try again'); - } else if (error.message?.includes('gas')) { - toast.error('Gas estimation failed. Please try again'); - } else { - // Extract a more user-friendly error message - let errorMessage = 'Failed to withdraw management fee'; - if (error.message) { - if (error.message.includes('user rejected')) { - errorMessage = 'Transaction rejected by user'; - } else if (error.message.includes('execution reverted')) { - errorMessage = 'Transaction failed. Please check if you are the manager and try again'; - } else { - errorMessage = `Failed to withdraw management fee: ${error.message}`; - } - } - toast.error(errorMessage); - } + handleTransactionError(error, { + messagePrefix: 'Failed to withdraw management fee: ' + }); } finally { setLoading(false); } diff --git a/src/components/WithdrawSuccessFee.js b/src/components/WithdrawSuccessFee.js index 1899701..a31c079 100644 --- a/src/components/WithdrawSuccessFee.js +++ b/src/components/WithdrawSuccessFee.js @@ -3,6 +3,7 @@ import { useWeb3 } from '../contexts/Web3Context'; import { ethers } from 'ethers'; import toast from 'react-hot-toast'; import { IMPORT_ASSISTANT_ABI, IMPORT_WRAPPER_ASSISTANT_ABI, EXPORT_ASSISTANT_ABI } from '../contracts/abi'; +import { handleTransactionError } from '../utils/error-handler'; const WithdrawSuccessFee = ({ assistant, onClose, onSuccess }) => { const { provider, signer } = useWeb3(); @@ -145,34 +146,9 @@ const WithdrawSuccessFee = ({ assistant, onClose, onSuccess }) => { onClose(); } catch (error) { - console.error('Error withdrawing success fee:', error); - - if (error.code === 4001 || error.code === 'ACTION_REJECTED') { - toast.error('Transaction rejected by user'); - } else if (error.message?.includes('insufficient funds') || error.message?.includes('insufficient balance')) { - toast.error('Insufficient funds for gas'); - } else if (error.message?.includes('onlyManager') || error.message?.includes('not manager')) { - toast.error('Only the manager can withdraw success fees'); - } else if (error.message?.includes('no profit yet') || error.message?.includes('no profit')) { - toast.error('No profit available to withdraw yet'); - } else if (error.message?.includes('network') || error.message?.includes('connection')) { - toast.error('Network error. Please check your connection and try again'); - } else if (error.message?.includes('gas')) { - toast.error('Gas estimation failed. Please try again'); - } else { - // Extract a more user-friendly error message - let errorMessage = 'Failed to withdraw success fee'; - if (error.message) { - if (error.message.includes('user rejected')) { - errorMessage = 'Transaction rejected by user'; - } else if (error.message.includes('execution reverted')) { - errorMessage = 'Transaction failed. Please check if you are the manager and try again'; - } else { - errorMessage = `Failed to withdraw success fee: ${error.message}`; - } - } - toast.error(errorMessage); - } + handleTransactionError(error, { + messagePrefix: 'Failed to withdraw success fee: ' + }); } finally { setLoading(false); } diff --git a/src/utils/__tests__/error-handler.test.js b/src/utils/__tests__/error-handler.test.js new file mode 100644 index 0000000..c791ebf --- /dev/null +++ b/src/utils/__tests__/error-handler.test.js @@ -0,0 +1,452 @@ +import { handleTransactionError } from '../error-handler'; +import { parseTransactionError } from '../error-parser'; +import toast from 'react-hot-toast'; + +// Mock toast +jest.mock('react-hot-toast', () => ({ + error: jest.fn(), + success: jest.fn(), +})); + +// Mock error-parser +jest.mock('../error-parser'); + +describe('handleTransactionError', () => { + let consoleErrorSpy; + + beforeEach(() => { + jest.clearAllMocks(); + // Suppress console.error output during tests + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + }); + + afterEach(() => { + // Restore console.error after each test + consoleErrorSpy.mockRestore(); + }); + + describe('Basic error handling', () => { + it('should handle user rejection errors', () => { + const error = { + code: 'ACTION_REJECTED', + message: 'user rejected transaction' + }; + + parseTransactionError.mockReturnValue({ + type: 'user_rejection', + title: 'Transaction Cancelled', + message: 'You cancelled the transaction. No changes were made.', + canRetry: true, + isUserError: true + }); + + const result = handleTransactionError(error); + + expect(parseTransactionError).toHaveBeenCalledWith(error); + expect(toast.error).toHaveBeenCalledWith('You cancelled the transaction. No changes were made.'); + expect(result.type).toBe('user_rejection'); + }); + + it('should handle insufficient funds errors', () => { + const error = { + code: 'INSUFFICIENT_FUNDS', + message: 'insufficient funds for gas' + }; + + parseTransactionError.mockReturnValue({ + type: 'insufficient_funds', + title: 'Insufficient Funds', + message: "You don't have enough tokens or ETH to complete this transaction.", + canRetry: false, + isUserError: true + }); + + const result = handleTransactionError(error); + + expect(toast.error).toHaveBeenCalledWith( + "You don't have enough tokens or ETH to complete this transaction." + ); + expect(result.type).toBe('insufficient_funds'); + }); + + it('should handle gas estimation errors', () => { + const error = { + code: 'UNPREDICTABLE_GAS_LIMIT', + message: 'gas estimation failed' + }; + + parseTransactionError.mockReturnValue({ + type: 'gas_error', + title: 'Gas Estimation Failed', + message: 'The transaction requires more gas than available. Try increasing gas limit.', + canRetry: true, + isUserError: false + }); + + const result = handleTransactionError(error); + + expect(toast.error).toHaveBeenCalledWith( + 'The transaction requires more gas than available. Try increasing gas limit.', + { duration: 6000 } + ); + }); + + it('should handle contract revert errors', () => { + const error = { + message: 'execution reverted: Not enough liquidity' + }; + + parseTransactionError.mockReturnValue({ + type: 'contract_error', + title: 'Transaction Failed', + message: 'The transaction was rejected by the smart contract. Please check your inputs.', + canRetry: true, + isUserError: false + }); + + const result = handleTransactionError(error); + + expect(toast.error).toHaveBeenCalled(); + }); + + it('should handle network errors', () => { + const error = { + message: 'network error: timeout' + }; + + parseTransactionError.mockReturnValue({ + type: 'network_error', + title: 'Network Error', + message: 'There was a network issue. Please check your connection and try again.', + canRetry: true, + isUserError: false + }); + + const result = handleTransactionError(error); + + expect(toast.error).toHaveBeenCalledWith( + 'There was a network issue. Please check your connection and try again.', + { duration: 6000 } + ); + }); + + it('should handle transaction replaced (success case)', () => { + const error = { + code: 'TRANSACTION_REPLACED', + message: 'transaction was replaced' + }; + + parseTransactionError.mockReturnValue({ + type: 'transaction_replaced', + title: 'Transaction Repriced', + message: 'Your wallet automatically adjusted the gas price for faster confirmation. The transaction was successful.', + canRetry: false, + isUserError: false, + isSuccess: true + }); + + const result = handleTransactionError(error); + + // Success case should show success toast, not error + expect(toast.success).toHaveBeenCalledWith( + 'Your wallet automatically adjusted the gas price for faster confirmation. The transaction was successful.' + ); + expect(toast.error).not.toHaveBeenCalled(); + }); + + it('should handle unknown errors', () => { + const error = { + message: 'Something weird happened' + }; + + parseTransactionError.mockReturnValue({ + type: 'unknown', + title: 'Operation Failed', + message: 'Something weird happened', + canRetry: true, + isUserError: false + }); + + const result = handleTransactionError(error); + + expect(toast.error).toHaveBeenCalledWith('Something weird happened', { duration: 6000 }); + }); + }); + + describe('Custom messages', () => { + it('should use custom message when provided', () => { + const error = { + code: 'ACTION_REJECTED', + message: 'user rejected' + }; + + parseTransactionError.mockReturnValue({ + type: 'user_rejection', + title: 'Transaction Cancelled', + message: 'You cancelled the transaction. No changes were made.', + canRetry: true, + isUserError: true + }); + + handleTransactionError(error, { + customMessages: { + user_rejection: 'Deposit was cancelled by user' + } + }); + + expect(toast.error).toHaveBeenCalledWith('Deposit was cancelled by user'); + }); + + it('should fall back to parsed message when custom message not provided for error type', () => { + const error = { + code: 'INSUFFICIENT_FUNDS' + }; + + parseTransactionError.mockReturnValue({ + type: 'insufficient_funds', + title: 'Insufficient Funds', + message: "You don't have enough tokens or ETH to complete this transaction.", + canRetry: false, + isUserError: true + }); + + handleTransactionError(error, { + customMessages: { + user_rejection: 'Some other message' + } + }); + + expect(toast.error).toHaveBeenCalledWith( + "You don't have enough tokens or ETH to complete this transaction." + ); + }); + + it('should support custom prefix for all messages', () => { + const error = { + code: 'INSUFFICIENT_FUNDS' + }; + + parseTransactionError.mockReturnValue({ + type: 'insufficient_funds', + title: 'Insufficient Funds', + message: "You don't have enough tokens or ETH to complete this transaction.", + canRetry: false, + isUserError: true + }); + + handleTransactionError(error, { + messagePrefix: 'Deposit failed: ' + }); + + expect(toast.error).toHaveBeenCalledWith( + "Deposit failed: You don't have enough tokens or ETH to complete this transaction." + ); + }); + }); + + describe('Toast options', () => { + it('should use longer duration for non-user errors', () => { + const error = { + message: 'network timeout' + }; + + parseTransactionError.mockReturnValue({ + type: 'network_error', + title: 'Network Error', + message: 'There was a network issue. Please check your connection and try again.', + canRetry: true, + isUserError: false + }); + + handleTransactionError(error); + + expect(toast.error).toHaveBeenCalledWith( + 'There was a network issue. Please check your connection and try again.', + { duration: 6000 } + ); + }); + + it('should use default duration for user errors', () => { + const error = { + code: 'ACTION_REJECTED' + }; + + parseTransactionError.mockReturnValue({ + type: 'user_rejection', + title: 'Transaction Cancelled', + message: 'You cancelled the transaction. No changes were made.', + canRetry: true, + isUserError: true + }); + + handleTransactionError(error); + + expect(toast.error).toHaveBeenCalledWith( + 'You cancelled the transaction. No changes were made.' + ); + // Check that no duration option was passed (default behavior) + expect(toast.error.mock.calls[0][1]).toBeUndefined(); + }); + + it('should allow custom toast options', () => { + const error = { + message: 'some error' + }; + + parseTransactionError.mockReturnValue({ + type: 'unknown', + title: 'Operation Failed', + message: 'some error', + canRetry: true, + isUserError: false + }); + + handleTransactionError(error, { + toastOptions: { + duration: 10000, + position: 'top-right' + } + }); + + expect(toast.error).toHaveBeenCalledWith('some error', { + duration: 10000, + position: 'top-right' + }); + }); + }); + + describe('Silent mode', () => { + it('should not show toast when silent option is true', () => { + const error = { + code: 'INSUFFICIENT_FUNDS' + }; + + parseTransactionError.mockReturnValue({ + type: 'insufficient_funds', + title: 'Insufficient Funds', + message: "You don't have enough tokens or ETH to complete this transaction.", + canRetry: false, + isUserError: true + }); + + const result = handleTransactionError(error, { silent: true }); + + expect(toast.error).not.toHaveBeenCalled(); + expect(toast.success).not.toHaveBeenCalled(); + expect(result.type).toBe('insufficient_funds'); + }); + }); + + describe('Callback support', () => { + it('should call onError callback when provided', () => { + const error = { + code: 'INSUFFICIENT_FUNDS' + }; + + parseTransactionError.mockReturnValue({ + type: 'insufficient_funds', + title: 'Insufficient Funds', + message: "You don't have enough tokens or ETH to complete this transaction.", + canRetry: false, + isUserError: true + }); + + const onError = jest.fn(); + handleTransactionError(error, { onError }); + + expect(onError).toHaveBeenCalledWith({ + type: 'insufficient_funds', + title: 'Insufficient Funds', + message: "You don't have enough tokens or ETH to complete this transaction.", + canRetry: false, + isUserError: true + }); + }); + }); + + describe('Return value', () => { + it('should return parsed error object', () => { + const error = { + code: 'ACTION_REJECTED' + }; + + const parsedError = { + type: 'user_rejection', + title: 'Transaction Cancelled', + message: 'You cancelled the transaction. No changes were made.', + canRetry: true, + isUserError: true + }; + + parseTransactionError.mockReturnValue(parsedError); + + const result = handleTransactionError(error); + + expect(result).toEqual(parsedError); + }); + }); + + describe('Edge cases', () => { + it('should handle null error', () => { + parseTransactionError.mockReturnValue({ + type: 'unknown', + title: 'Operation Failed', + message: '', + canRetry: true, + isUserError: false + }); + + const result = handleTransactionError(null); + + expect(parseTransactionError).toHaveBeenCalledWith(null); + expect(result.type).toBe('unknown'); + }); + + it('should handle undefined error', () => { + parseTransactionError.mockReturnValue({ + type: 'unknown', + title: 'Operation Failed', + message: '', + canRetry: true, + isUserError: false + }); + + const result = handleTransactionError(undefined); + + expect(parseTransactionError).toHaveBeenCalledWith(undefined); + expect(result.type).toBe('unknown'); + }); + + it('should log error to console when not silent', () => { + const error = { message: 'test error' }; + + parseTransactionError.mockReturnValue({ + type: 'unknown', + title: 'Operation Failed', + message: 'test error', + canRetry: true, + isUserError: false + }); + + handleTransactionError(error); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Transaction error:', error); + }); + + it('should not log error to console when silent', () => { + const error = { message: 'test error' }; + + parseTransactionError.mockReturnValue({ + type: 'unknown', + title: 'Operation Failed', + message: 'test error', + canRetry: true, + isUserError: false + }); + + handleTransactionError(error, { silent: true }); + + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/utils/error-handler.js b/src/utils/error-handler.js new file mode 100644 index 0000000..d05a064 --- /dev/null +++ b/src/utils/error-handler.js @@ -0,0 +1,74 @@ +import { parseTransactionError } from './error-parser'; +import toast from 'react-hot-toast'; + +/** + * Handles transaction errors with consistent UI feedback and logging + * + * @param {Error} error - The error object to handle + * @param {Object} options - Configuration options + * @param {Object} options.customMessages - Map of error types to custom messages { type: message } + * @param {string} options.messagePrefix - Prefix to add to all error messages + * @param {boolean} options.silent - If true, don't show toast or log to console + * @param {Object} options.toastOptions - Custom options to pass to toast (duration, position, etc.) + * @param {Function} options.onError - Callback function called with parsed error object + * @returns {Object} Parsed error object from parseTransactionError + */ +export const handleTransactionError = (error, options = {}) => { + const { + customMessages = {}, + messagePrefix = '', + silent = false, + toastOptions = {}, + onError + } = options; + + // Parse the error using existing error parser + const parsedError = parseTransactionError(error); + + // Log to console unless silent + if (!silent) { + console.error('Transaction error:', error); + } + + // Get the message to display + let message = customMessages[parsedError.type] || parsedError.message; + + // Add prefix if provided + if (messagePrefix) { + message = messagePrefix + message; + } + + // Show toast notification unless silent + if (!silent && message) { + if (parsedError.isSuccess) { + // Success case (e.g., transaction replaced) + const hasOptions = Object.keys(toastOptions).length > 0; + if (hasOptions) { + toast.success(message, toastOptions); + } else { + toast.success(message); + } + } else { + // Error case - use longer duration for non-user errors unless custom options provided + const defaultToastOptions = parsedError.isUserError + ? {} + : { duration: 6000 }; + + const finalOptions = { ...defaultToastOptions, ...toastOptions }; + const hasOptions = Object.keys(finalOptions).length > 0; + + if (hasOptions) { + toast.error(message, finalOptions); + } else { + toast.error(message); + } + } + } + + // Call error callback if provided + if (onError) { + onError(parsedError); + } + + return parsedError; +};